eeb483a4fa
Image- and bin-indexed plots have no meaningful x baseline, so the themed bottom spine kept by use() doesn't apply to them. Adds an explicit opt-in to disable all spines on such Axes, documents it in plotstyle/CLAUDE.md, and updates the showcase notebook's colormap and multi-panel examples to use it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
331 lines
9.5 KiB
Python
331 lines
9.5 KiB
Python
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
import matplotlib.pyplot as plt
|
|
import pytest
|
|
from matplotlib.colors import Colormap
|
|
|
|
import plotstyle as ps
|
|
from plotstyle import colors
|
|
|
|
|
|
def test_categorical_palette_matches_kit_hex():
|
|
assert colors.CATEGORICAL == [
|
|
"#009682",
|
|
"#DF9B1B",
|
|
"#4664AA",
|
|
"#A78230",
|
|
"#23A1E0",
|
|
"#A3107C",
|
|
"#8CB63C",
|
|
"#A22223",
|
|
"#FCE500",
|
|
]
|
|
|
|
|
|
def test_diverging_palette_matches_kit_hex():
|
|
assert colors.DIVERGING == {"low": "#4664AA", "mid": "#f7f7f7", "high": "#A22223"}
|
|
|
|
|
|
def test_status_palette_matches_validated_hex():
|
|
assert colors.STATUS == {
|
|
"good": "#0ca30c",
|
|
"warning": "#fab219",
|
|
"serious": "#ec835a",
|
|
"critical": "#d03b3b",
|
|
}
|
|
|
|
|
|
def test_get_color_returns_categorical_slot():
|
|
assert colors.get_color(0) == colors.CATEGORICAL[0]
|
|
assert colors.get_color(len(colors.CATEGORICAL) - 1) == colors.CATEGORICAL[-1]
|
|
|
|
|
|
def test_get_color_out_of_range_raises():
|
|
with pytest.raises(ValueError):
|
|
colors.get_color(len(colors.CATEGORICAL))
|
|
|
|
|
|
def test_sequential_steps_are_kit_blue_variations():
|
|
assert colors.SEQUENTIAL_STEPS[-1].lower() == "#4664aa"
|
|
# Lightest step should be a lighter (higher-lightness) tint than the brand color.
|
|
import colorsys
|
|
|
|
def lightness(hex_color):
|
|
r, g, b = (int(hex_color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
|
return colorsys.rgb_to_hls(r, g, b)[1]
|
|
|
|
assert lightness(colors.SEQUENTIAL_STEPS[0]) > lightness(colors.SEQUENTIAL_STEPS[-1])
|
|
|
|
|
|
def test_sequential_cmap_is_usable_colormap():
|
|
cmap = colors.sequential_cmap()
|
|
assert isinstance(cmap, Colormap)
|
|
assert cmap(0.0) != cmap(1.0)
|
|
|
|
|
|
def test_diverging_cmap_is_usable_colormap():
|
|
cmap = colors.diverging_cmap()
|
|
assert isinstance(cmap, Colormap)
|
|
assert cmap(0.0) != cmap(1.0)
|
|
|
|
|
|
def test_use_sets_expected_rcparams():
|
|
ps.use()
|
|
try:
|
|
assert plt.rcParams["axes.facecolor"] == "#ffffff"
|
|
assert plt.rcParams["text.usetex"] is True
|
|
assert "lmodern" in plt.rcParams["text.latex.preamble"]
|
|
assert "sfmath" in plt.rcParams["text.latex.preamble"]
|
|
assert "fontenc" in plt.rcParams["text.latex.preamble"]
|
|
assert r"\sfdefault" in plt.rcParams["text.latex.preamble"]
|
|
cycle_colors = [entry["color"] for entry in plt.rcParams["axes.prop_cycle"]]
|
|
assert cycle_colors == colors.CATEGORICAL
|
|
|
|
assert plt.rcParams["axes.spines.top"] is False
|
|
assert plt.rcParams["axes.spines.right"] is False
|
|
assert plt.rcParams["axes.spines.left"] is False
|
|
assert plt.rcParams["axes.spines.bottom"] is True
|
|
assert plt.rcParams["xtick.bottom"] is True
|
|
assert plt.rcParams["ytick.left"] is True
|
|
assert plt.rcParams["axes.edgecolor"] == "#000000"
|
|
assert plt.rcParams["axes.linewidth"] > 0.8
|
|
|
|
assert plt.rcParams["axes.grid"] is True
|
|
assert plt.rcParams["axes.grid.axis"] == "y"
|
|
assert plt.rcParams["axes.titlelocation"] == "left"
|
|
|
|
assert plt.rcParams["xtick.minor.visible"] is True
|
|
assert plt.rcParams["ytick.minor.visible"] is False
|
|
assert plt.rcParams["xtick.major.size"] > plt.rcParams["xtick.minor.size"]
|
|
finally:
|
|
ps.reset()
|
|
|
|
|
|
def test_use_default_does_not_cycle_linestyles():
|
|
ps.use()
|
|
try:
|
|
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
|
|
assert cycle_keys == {"color"}
|
|
finally:
|
|
ps.reset()
|
|
|
|
|
|
def test_use_cycle_linestyles_opt_in():
|
|
ps.use(cycle_linestyles=True)
|
|
try:
|
|
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
|
|
assert cycle_keys == {"color", "linestyle"}
|
|
linestyles = {entry["linestyle"] for entry in plt.rcParams["axes.prop_cycle"]}
|
|
assert len(linestyles) > 1
|
|
finally:
|
|
ps.reset()
|
|
|
|
|
|
def test_new_figure_styles_major_and_minor_tick_labels_differently():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
assert ax.xaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
|
|
assert ax.xaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
|
|
assert ax.yaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
|
|
assert ax.yaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_new_figure_preset_sizes():
|
|
fig, ax = ps.new_figure("thesis-single")
|
|
try:
|
|
assert tuple(fig.get_size_inches()) == ps.FIGSIZES["thesis-single"]
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_new_figure_explicit_tuple():
|
|
fig, ax = ps.new_figure((3.0, 2.0))
|
|
try:
|
|
assert tuple(fig.get_size_inches()) == (3.0, 2.0)
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_new_figure_unknown_preset_raises():
|
|
with pytest.raises(ValueError):
|
|
ps.new_figure("not-a-real-preset")
|
|
|
|
|
|
def test_new_figure_title_sets_left_aligned_figure_suptitle():
|
|
fig, ax = ps.new_figure("square", title="My Title")
|
|
try:
|
|
assert fig._suptitle is not None
|
|
assert "My Title" in fig._suptitle.get_text()
|
|
assert fig._suptitle.get_ha() == "left"
|
|
assert ax.get_title() == ""
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_new_figure_params_adds_subtitle_line():
|
|
fig, ax = ps.new_figure("square", title="My Title", params={"N": 100, "seed": 42})
|
|
try:
|
|
text = fig._suptitle.get_text()
|
|
assert "My Title" in text
|
|
assert "N: 100" in text
|
|
assert "seed: 42" in text
|
|
assert "|" in text
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_new_figure_without_title_or_params_has_no_suptitle():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
assert fig._suptitle is None
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_colorbar_matches_axes_height():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
im = ax.imshow([[0, 1], [2, 3]])
|
|
cb = ps.colorbar(im, ax)
|
|
fig.canvas.draw()
|
|
ax_bbox = ax.get_position()
|
|
cax_bbox = cb.ax.get_position()
|
|
assert ax_bbox.y0 == pytest.approx(cax_bbox.y0, abs=1e-6)
|
|
assert ax_bbox.y1 == pytest.approx(cax_bbox.y1, abs=1e-6)
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_colorbar_has_no_outline():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
im = ax.imshow([[0, 1], [2, 3]])
|
|
cb = ps.colorbar(im, ax)
|
|
assert cb.outline.get_visible() is False
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_no_spines_hides_all_spines_on_single_axes():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
ax.imshow([[0, 1], [2, 3]])
|
|
ps.no_spines(ax)
|
|
assert all(not spine.get_visible() for spine in ax.spines.values())
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_no_spines_hides_all_spines_on_axes_array():
|
|
fig, axes = ps.new_figure("square", nrows=1, ncols=2)
|
|
try:
|
|
ps.no_spines(axes)
|
|
for ax in axes:
|
|
assert all(not spine.get_visible() for spine in ax.spines.values())
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_savefig_writes_requested_formats(tmp_path):
|
|
fig, ax = ps.new_figure("square")
|
|
ax.plot([0, 1], [0, 1])
|
|
try:
|
|
out_dir = tmp_path / "nested" / "plots"
|
|
written = ps.savefig(fig, out_dir / "my_plot", formats=("pdf", "png"))
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
assert [p.name for p in written] == ["my_plot.pdf", "my_plot.png"]
|
|
for p in written:
|
|
assert p.exists()
|
|
assert p.stat().st_size > 0
|
|
|
|
|
|
def test_style_legend_places_legend_on_figure_outside_axes():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
ax.plot([0, 1], [0, 1], label="series")
|
|
legend = ps.style_legend(ax, title="Series")
|
|
assert legend in fig.legends
|
|
assert ax.get_legend() is None
|
|
assert legend.get_title().get_text() == "Series"
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_style_legend_without_title_warns():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
ax.plot([0, 1], [0, 1], label="series")
|
|
with pytest.warns(UserWarning):
|
|
ps.style_legend(ax)
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_panel_label_does_not_raise():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
ax.plot([0, 1], [0, 1], label="series")
|
|
ps.panel_label(ax, "a")
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_panel_label_unknown_loc_raises():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
with pytest.raises(ValueError):
|
|
ps.panel_label(ax, "a", loc="middle")
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_panel_label_defaults_to_lower_right():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
text = ps.panel_label(ax, "a")
|
|
assert text.get_ha() == "right"
|
|
assert text.get_va() == "bottom"
|
|
x, y = text.get_position()
|
|
assert x > 0.5
|
|
assert y < 0.5
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_panel_label_color_matches_xlabel():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
ax.set_xlabel("Time (s)")
|
|
text = ps.panel_label(ax, "a")
|
|
assert text.get_color() == ax.xaxis.label.get_color()
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_panel_label_has_rounded_semi_transparent_box_by_default():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
text = ps.panel_label(ax, "a")
|
|
patch = text.get_bbox_patch()
|
|
assert patch is not None
|
|
assert "round" in patch.get_boxstyle().__class__.__name__.lower()
|
|
assert patch.get_facecolor()[3] < 1.0
|
|
assert patch.get_edgecolor()[3] == 1.0
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
|
|
def test_panel_label_box_can_be_disabled():
|
|
fig, ax = ps.new_figure("square")
|
|
try:
|
|
text = ps.panel_label(ax, "a", box=False)
|
|
assert text.get_bbox_patch() is None
|
|
finally:
|
|
plt.close(fig)
|