From a7c29a2cf5a6b01482c0740d97d2f91fc65ae4fb Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Mar 2026 05:06:32 +1000 Subject: [PATCH] Increase coverage to 85% with targeted tests --- ultraplot/colors.py | 14 +- ultraplot/internals/rcsetup.py | 8 +- ultraplot/proj.py | 8 +- ultraplot/scale.py | 5 +- .../tests/test_axes_base_colorbar_helpers.py | 299 ++++++++++++++ .../tests/test_colorbar_helpers_extra.py | 371 ++++++++++++++++++ .../tests/test_colormap_helpers_extra.py | 204 ++++++++++ ultraplot/tests/test_colors_helpers.py | 178 +++++++++ ultraplot/tests/test_config_helpers_extra.py | 236 +++++++++++ .../tests/test_constructor_helpers_extra.py | 186 +++++++++ ultraplot/tests/test_inputs_helpers.py | 196 +++++++++ ultraplot/tests/test_proj_helpers.py | 60 +++ ultraplot/tests/test_rcsetup_helpers.py | 184 +++++++++ ultraplot/tests/test_scale_helpers.py | 242 ++++++++++++ ultraplot/tests/test_text_helpers.py | 133 +++++++ 15 files changed, 2308 insertions(+), 16 deletions(-) create mode 100644 ultraplot/tests/test_axes_base_colorbar_helpers.py create mode 100644 ultraplot/tests/test_colorbar_helpers_extra.py create mode 100644 ultraplot/tests/test_colormap_helpers_extra.py create mode 100644 ultraplot/tests/test_colors_helpers.py create mode 100644 ultraplot/tests/test_config_helpers_extra.py create mode 100644 ultraplot/tests/test_constructor_helpers_extra.py create mode 100644 ultraplot/tests/test_inputs_helpers.py create mode 100644 ultraplot/tests/test_proj_helpers.py create mode 100644 ultraplot/tests/test_rcsetup_helpers.py create mode 100644 ultraplot/tests/test_scale_helpers.py create mode 100644 ultraplot/tests/test_text_helpers.py diff --git a/ultraplot/colors.py b/ultraplot/colors.py index cf5992ee5..becca6851 100644 --- a/ultraplot/colors.py +++ b/ultraplot/colors.py @@ -2934,8 +2934,14 @@ def _translate_cmap(cmap, lut=None, cyclic=None, listedthresh=None): # WARNING: Apply default 'cyclic' property to native matplotlib colormaps # based on known names. Maybe slightly dangerous but cleanest approach lut = _not_none(lut, rc["image.lut"]) - cyclic = _not_none(cyclic, cmap.name and cmap.name.lower() in CMAPS_CYCLIC) + name = getattr(cmap, "name", None) + cyclic = _not_none(cyclic, name and name.lower() in CMAPS_CYCLIC) listedthresh = _not_none(listedthresh, rc["cmap.listedthresh"]) + if not isinstance(cmap, mcolors.Colormap): + raise ValueError( + f"Invalid colormap type {type(cmap).__name__!r}. " + "Must be instance of matplotlib.colors.Colormap." + ) # Translate the colormap # WARNING: Here we ignore 'N' in order to respect ultraplotrc lut sizes @@ -2957,12 +2963,6 @@ def _translate_cmap(cmap, lut=None, cyclic=None, listedthresh=None): cmap = DiscreteColormap(colors, name) elif isinstance(cmap, mcolors.Colormap): # base class pass - else: - raise ValueError( - f"Invalid colormap type {type(cmap).__name__!r}. " - "Must be instance of matplotlib.colors.Colormap." - ) - # Apply hidden settings cmap._rgba_bad = bad cmap._rgba_under = under diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 3b94e8ec0..eedb4ae38 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -356,7 +356,7 @@ def _validate_color(value, alternative=None): def _validate_bool_or_iterable(value): if isinstance(value, bool): return _validate_bool(value) - elif np.isiterable(value): + elif np.iterable(value): return value raise ValueError(f"{value!r} is not a valid bool or iterable of node labels.") @@ -460,7 +460,7 @@ def _validate_float_or_iterable(value): try: return _validate_float(value) except Exception: - if np.isiterable(value) and not isinstance(value, (str, bytes)): + if np.iterable(value) and not isinstance(value, (str, bytes)): return tuple(_validate_float(item) for item in value) raise ValueError(f"{value!r} is not a valid float or iterable of floats.") @@ -468,7 +468,7 @@ def _validate_float_or_iterable(value): def _validate_string_or_iterable(value): if isinstance(value, str): return _validate_string(value) - if np.isiterable(value) and not isinstance(value, (str, bytes)): + if np.iterable(value) and not isinstance(value, (str, bytes)): values = tuple(value) if all(isinstance(item, str) for item in values): return values @@ -601,6 +601,8 @@ def _yaml_table(rcdict, comment=True, description=False): # Generate string string = "" + if not data: + return string keylen = len(max(rcdict, key=len)) vallen = len(max((tup[1] for tup in data), key=len)) for key, value, descrip in data: diff --git a/ultraplot/proj.py b/ultraplot/proj.py index 9b2c0567b..0b0f1ab13 100644 --- a/ultraplot/proj.py +++ b/ultraplot/proj.py @@ -81,7 +81,7 @@ def __init__( f"The {self.name!r} projection does not handle elliptical globes." ) - proj4_params = {"proj": "aitoff", "lon_0": central_longitude} + proj4_params = [("proj", "aitoff"), ("lon_0", central_longitude)] super().__init__( proj4_params, central_longitude, @@ -126,7 +126,7 @@ def __init__( f"The {self.name!r} projection does not handle elliptical globes." ) - proj4_params = {"proj": "hammer", "lon_0": central_longitude} + proj4_params = [("proj", "hammer"), ("lon_0", central_longitude)] super().__init__( proj4_params, central_longitude, @@ -172,7 +172,7 @@ def __init__( f"The {self.name!r} projection does not handle elliptical globes." ) - proj4_params = {"proj": "kav7", "lon_0": central_longitude} + proj4_params = [("proj", "kav7"), ("lon_0", central_longitude)] super().__init__( proj4_params, central_longitude, @@ -218,7 +218,7 @@ def __init__( f"The {self.name!r} projection does not handle " "elliptical globes." ) - proj4_params = {"proj": "wintri", "lon_0": central_longitude} + proj4_params = [("proj", "wintri"), ("lon_0", central_longitude)] super().__init__( proj4_params, central_longitude, diff --git a/ultraplot/scale.py b/ultraplot/scale.py index 8f137168f..21161cda5 100644 --- a/ultraplot/scale.py +++ b/ultraplot/scale.py @@ -686,7 +686,7 @@ def inverted(self): def transform_non_affine(self, a): with np.errstate(divide="ignore", invalid="ignore"): - return np.rad2deg(np.arctan2(1, np.sinh(a))) + return np.rad2deg(np.arctan(np.sinh(a))) class SineLatitudeScale(_Scale, mscale.ScaleBase): @@ -853,7 +853,8 @@ def __init__(self, threshs, scales, zero_dists=None): with np.errstate(divide="ignore", invalid="ignore"): dists = np.concatenate((threshs[:1], dists / scales[:-1])) if zero_dists is not None: - dists[scales[:-1] == 0] = zero_dists + zero_idx = np.flatnonzero(scales[:-1] == 0) + 1 + dists[zero_idx] = zero_dists self._dists = dists def inverted(self): diff --git a/ultraplot/tests/test_axes_base_colorbar_helpers.py b/ultraplot/tests/test_axes_base_colorbar_helpers.py new file mode 100644 index 000000000..3d88dec33 --- /dev/null +++ b/ultraplot/tests/test_axes_base_colorbar_helpers.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Additional branch coverage for inset colorbar helpers in axes.base.""" + +from types import SimpleNamespace + +import pytest +from matplotlib.backend_bases import ResizeEvent +from matplotlib.transforms import Bbox + +import ultraplot as uplt +from ultraplot.axes import base as pbase + + +@pytest.mark.parametrize( + ("orientation", "labelloc", "expected"), + [ + ("horizontal", "left", 90), + ("horizontal", "right", -90), + ("horizontal", "bottom", 0), + ("vertical", "right", -90), + ("vertical", "top", 0), + ], +) +def test_inset_colorbar_label_rotation_variants(orientation, labelloc, expected): + kw_label = {} + pbase._determine_label_rotation( + "auto", + labelloc=labelloc, + orientation=orientation, + kw_label=kw_label, + ) + assert kw_label["rotation"] == expected + + +@pytest.mark.parametrize( + ("orientation", "loc", "labelloc", "ticklocation"), + [ + ("vertical", "upper left", "left", "left"), + ("vertical", "upper right", "top", "right"), + ("vertical", "lower right", "top", "right"), + ("vertical", "lower left", "bottom", "left"), + ("vertical", "upper right", "bottom", "right"), + ("horizontal", "upper right", "bottom", "bottom"), + ("horizontal", "lower left", "bottom", "bottom"), + ("horizontal", "upper right", "top", "top"), + ("horizontal", "lower left", "top", "top"), + ], +) +def test_inset_colorbar_bounds_variants(orientation, loc, labelloc, ticklocation): + fig, ax = uplt.subplots() + ax = ax[0] + + bounds_inset, bounds_frame = pbase._solve_inset_colorbar_bounds( + axes=ax, + loc=loc, + orientation=orientation, + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation=ticklocation, + labelloc=labelloc, + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(bounds_inset) == 4 + assert len(bounds_frame) == 4 + + legacy_inset, legacy_frame = pbase._legacy_inset_colorbar_bounds( + axes=ax, + loc=loc, + orientation=orientation, + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation=ticklocation, + labelloc=labelloc, + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(legacy_inset) == 4 + assert len(legacy_frame) == 4 + + +def test_inset_colorbar_axis_rotation_and_long_axis_helpers(rng): + fig, ax = uplt.subplots() + ax = ax[0] + mappable = ax.imshow(rng.random((6, 6))) + colorbar = ax.colorbar(mappable, loc="ur", orientation="vertical") + + long_axis = pbase._get_colorbar_long_axis(colorbar) + assert ( + pbase._get_axis_for("left", "upper right", ax=colorbar, orientation="vertical") + is long_axis + ) + assert ( + pbase._get_axis_for("top", "upper right", ax=colorbar, orientation="vertical") + is colorbar.ax.xaxis + ) + assert ( + pbase._get_axis_for(None, "upper right", ax=colorbar, orientation="horizontal") + is long_axis + ) + + dummy = SimpleNamespace(long_axis=colorbar.ax.yaxis) + assert pbase._get_colorbar_long_axis(dummy) is colorbar.ax.yaxis + + kw_label = {} + pbase._determine_label_rotation( + "auto", + labelloc="left", + orientation="vertical", + kw_label=kw_label, + ) + assert kw_label["rotation"] == 90 + assert ( + pbase._resolve_label_rotation( + "auto", + labelloc="top", + orientation="horizontal", + ) + == 0.0 + ) + assert ( + pbase._resolve_label_rotation( + "bad", + labelloc="top", + orientation="horizontal", + ) + == 0.0 + ) + + with pytest.raises(ValueError, match="Could not determine label axis"): + pbase._get_axis_for( + "center", + "upper right", + ax=colorbar, + orientation="vertical", + ) + with pytest.raises(ValueError, match="Label rotation must be a number or 'auto'"): + pbase._determine_label_rotation( + "bad", + labelloc="left", + orientation="vertical", + kw_label={}, + ) + + +def test_inset_colorbar_measurement_helpers(): + class BrokenFigure: + dpi = 72 + + def _get_renderer(self): + raise RuntimeError("broken") + + class BrokenAxis: + def get_ticklabels(self): + raise RuntimeError("broken") + + fig, ax = uplt.subplots() + ax = ax[0] + ax.set_xticks([0, 1]) + ax.set_xticklabels(["left tick label", "right tick label"], rotation=35) + text = ax.text(-0.1, 1.05, "outside", transform=ax.transAxes) + fig.canvas.draw() + + label_extent = pbase._measure_label_points("label", 45, 12, fig) + assert label_extent is not None + assert label_extent[0] > 0 + + text_extent = pbase._measure_text_artist_points(text, fig) + assert text_extent is not None + assert text_extent[1] > 0 + + tick_extent = pbase._measure_ticklabel_extent_points(ax.xaxis, fig) + assert tick_extent is not None + assert tick_extent[0] > 0 + + text_overhang = pbase._measure_text_overhang_axes(text, ax) + assert text_overhang is not None + assert text_overhang[0] > 0 or text_overhang[3] > 0 + + tick_overhang = pbase._measure_ticklabel_overhang_axes(ax.xaxis, ax) + assert tick_overhang is not None + + assert pbase._measure_label_points("label", 0, 12, BrokenFigure()) is None + assert pbase._measure_ticklabel_extent_points(BrokenAxis(), fig) is None + + +def test_inset_colorbar_layout_solver_and_reflow_helpers(rng): + fig, ax = uplt.subplots() + ax = ax[0] + mappable = ax.imshow(rng.random((10, 10))) + colorbar = ax.colorbar( + mappable, + loc="ur", + frameon=True, + label="Inset label", + labelloc="top", + orientation="vertical", + ) + fig.canvas.draw() + + bounds_inset, bounds_frame = pbase._solve_inset_colorbar_bounds( + axes=ax, + loc="upper right", + orientation="vertical", + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation="right", + labelloc="top", + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(bounds_inset) == 4 + assert len(bounds_frame) == 4 + + legacy_inset, legacy_frame = pbase._legacy_inset_colorbar_bounds( + axes=ax, + loc="upper right", + orientation="horizontal", + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation="bottom", + labelloc="bottom", + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(legacy_inset) == 4 + assert legacy_frame[2] >= legacy_inset[2] + + frame = colorbar.ax._inset_colorbar_frame + assert frame is not None + pbase._apply_inset_colorbar_layout( + colorbar.ax, + bounds_inset=bounds_inset, + bounds_frame=bounds_frame, + frame=frame, + ) + assert colorbar.ax._inset_colorbar_bounds["inset"] == bounds_inset + + pbase._register_inset_colorbar_reflow(fig) + callback_id = fig._inset_colorbar_reflow_cid + pbase._register_inset_colorbar_reflow(fig) + assert fig._inset_colorbar_reflow_cid == callback_id + + ax._inset_colorbar_obj = colorbar + colorbar.ax._inset_colorbar_obj = colorbar + event = ResizeEvent("resize_event", fig.canvas) + fig.canvas.callbacks.process("resize_event", event) + assert getattr(ax, "_inset_colorbar_needs_reflow", False) is True + + renderer = fig.canvas.get_renderer() + labelloc = colorbar.ax._inset_colorbar_labelloc + assert not bool( + pbase._inset_colorbar_frame_needs_reflow( + colorbar, + labelloc=labelloc, + renderer=renderer, + ) + ) + + original_get_window_extent = frame.get_window_extent + frame.get_window_extent = lambda renderer=None: Bbox.from_bounds(0, 0, 1, 1) + assert pbase._inset_colorbar_frame_needs_reflow( + colorbar, + labelloc=labelloc, + renderer=renderer, + ) + frame.get_window_extent = original_get_window_extent + + pbase._reflow_inset_colorbar_frame( + colorbar, + labelloc=labelloc, + ticklen=colorbar.ax._inset_colorbar_ticklen, + renderer=renderer, + ) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + assert not bool( + pbase._inset_colorbar_frame_needs_reflow( + colorbar, + labelloc=labelloc, + renderer=renderer, + ) + ) diff --git a/ultraplot/tests/test_colorbar_helpers_extra.py b/ultraplot/tests/test_colorbar_helpers_extra.py new file mode 100644 index 000000000..fa17fa232 --- /dev/null +++ b/ultraplot/tests/test_colorbar_helpers_extra.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Additional branch coverage for colorbar helper functions.""" + +from types import SimpleNamespace + +import matplotlib.cm as mcm +import matplotlib.ticker as mticker +import pytest +from matplotlib.backend_bases import ResizeEvent +from matplotlib.transforms import Bbox + +import ultraplot as uplt +from ultraplot import colorbar as pcbar +from ultraplot import colors as pcolors +from ultraplot import ticker as pticker +from ultraplot.internals.warnings import UltraPlotWarning + + +@pytest.mark.parametrize( + ("orientation", "labelloc", "expected"), + [ + ("horizontal", "left", 90), + ("horizontal", "right", -90), + ("horizontal", "bottom", 0), + ("vertical", "right", -90), + ("vertical", "top", 0), + ], +) +def test_colorbar_label_rotation_variants(orientation, labelloc, expected): + kw_label = {} + pcbar._determine_label_rotation( + "auto", + labelloc=labelloc, + orientation=orientation, + kw_label=kw_label, + ) + assert kw_label["rotation"] == expected + + +@pytest.mark.parametrize( + ("orientation", "loc", "labelloc", "ticklocation"), + [ + ("vertical", "upper left", "left", "left"), + ("vertical", "upper right", "top", "right"), + ("vertical", "lower right", "top", "right"), + ("vertical", "lower left", "bottom", "left"), + ("vertical", "upper right", "bottom", "right"), + ("horizontal", "upper right", "bottom", "bottom"), + ("horizontal", "lower left", "bottom", "bottom"), + ("horizontal", "upper right", "top", "top"), + ("horizontal", "lower left", "top", "top"), + ], +) +def test_colorbar_bounds_variants(orientation, loc, labelloc, ticklocation): + fig, ax = uplt.subplots() + ax = ax[0] + + bounds_inset, bounds_frame = pcbar._solve_inset_colorbar_bounds( + axes=ax, + loc=loc, + orientation=orientation, + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation=ticklocation, + labelloc=labelloc, + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(bounds_inset) == 4 + assert len(bounds_frame) == 4 + + legacy_inset, legacy_frame = pcbar._legacy_inset_colorbar_bounds( + axes=ax, + loc=loc, + orientation=orientation, + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation=ticklocation, + labelloc=labelloc, + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(legacy_inset) == 4 + assert len(legacy_frame) == 4 + + +def test_colorbar_argument_resolution_helpers(rng): + fig, ax = uplt.subplots() + ax = ax[0] + mappable = ax.imshow(rng.random((4, 4))) + + text_kw = pcbar._build_label_tick_kwargs( + labelsize=12, + labelweight="bold", + labelcolor="red", + ticklabelsize=9, + ticklabelweight="normal", + ticklabelcolor="blue", + rotation=45, + ) + assert text_kw.kw_label["size"] == 12 + assert text_kw.kw_ticklabels["rotation"] == 45 + + resolved, kwargs = pcbar._resolve_mappable([mappable], None, ax, {}) + assert resolved is mappable + assert kwargs == {} + + generated, kwargs = pcbar._resolve_mappable("viridis", None, ax, {}) + assert isinstance(generated, mcm.ScalarMappable) + assert kwargs == {} + + with pytest.warns(UltraPlotWarning, match="Ignoring unused keyword arg"): + resolved, kwargs = pcbar._resolve_mappable(mappable, None, ax, {"vmin": 0}) + assert resolved is mappable + assert "vmin" not in kwargs + + extendfrac = pcbar._resolve_extendfrac( + extendsize="1em", + extendfrac=None, + cax=ax, + vertical=True, + ) + assert extendfrac > 0 + + with pytest.warns(UltraPlotWarning, match="cannot specify both"): + extendfrac = pcbar._resolve_extendfrac( + extendsize="1em", + extendfrac=0.2, + cax=ax, + vertical=False, + ) + assert extendfrac > 0 + + norm, formatter, locator, minorlocator, tickminor = pcbar._resolve_locators( + mappable=mappable, + formatter="sigfig", + formatter_kw={}, + locator=2, + locator_kw={}, + minorlocator=1, + minorlocator_kw={}, + tickminor=None, + vertical=False, + ) + assert norm is mappable.norm + assert isinstance(formatter, pticker.SigFigFormatter) + assert isinstance(locator, mticker.MultipleLocator) + assert isinstance(minorlocator, mticker.MultipleLocator) + assert tickminor is False + + discrete = mcm.ScalarMappable( + norm=pcolors.DiscreteNorm([0, 1, 2, 3]), + cmap="viridis", + ) + _, formatter, locator, minorlocator, tickminor = pcbar._resolve_locators( + mappable=discrete, + formatter=None, + formatter_kw={}, + locator=None, + locator_kw={}, + minorlocator=None, + minorlocator_kw={}, + tickminor=True, + vertical=True, + ) + assert formatter is not None + assert isinstance(locator, (mticker.FixedLocator, pticker.DiscreteLocator)) + assert isinstance(minorlocator, pticker.DiscreteLocator) + assert tickminor is True + + +def test_colorbar_measurement_and_rotation_helpers(rng): + class BrokenFigure: + dpi = 72 + + def _get_renderer(self): + raise RuntimeError("broken") + + class BrokenAxis: + def get_ticklabels(self): + raise RuntimeError("broken") + + fig, ax = uplt.subplots() + ax = ax[0] + mappable = ax.imshow(rng.random((6, 6))) + colorbar = ax.colorbar(mappable, loc="ur", orientation="vertical") + + long_axis = pcbar._get_colorbar_long_axis(colorbar) + assert ( + pcbar._get_axis_for("left", "upper right", ax=colorbar, orientation="vertical") + is long_axis + ) + assert ( + pcbar._get_axis_for("top", "upper right", ax=colorbar, orientation="vertical") + is colorbar.ax.xaxis + ) + assert ( + pcbar._get_axis_for(None, "upper right", ax=colorbar, orientation="horizontal") + is long_axis + ) + + dummy = SimpleNamespace(long_axis=colorbar.ax.yaxis) + assert pcbar._get_colorbar_long_axis(dummy) is colorbar.ax.yaxis + + kw_label = {} + pcbar._determine_label_rotation( + "auto", + labelloc="left", + orientation="vertical", + kw_label=kw_label, + ) + assert kw_label["rotation"] == 90 + assert ( + pcbar._resolve_label_rotation( + "auto", + labelloc="top", + orientation="horizontal", + ) + == 0.0 + ) + assert ( + pcbar._resolve_label_rotation( + "bad", + labelloc="top", + orientation="horizontal", + ) + == 0.0 + ) + + with pytest.raises(ValueError, match="Could not determine label axis"): + pcbar._get_axis_for( + "center", + "upper right", + ax=colorbar, + orientation="vertical", + ) + with pytest.raises(ValueError, match="Label rotation must be a number or 'auto'"): + pcbar._determine_label_rotation( + "bad", + labelloc="left", + orientation="vertical", + kw_label={}, + ) + + ax.set_xticks([0, 1]) + ax.set_xticklabels(["left tick label", "right tick label"], rotation=35) + text = ax.text(-0.1, 1.05, "outside", transform=ax.transAxes) + fig.canvas.draw() + + label_extent = pcbar._measure_label_points("label", 45, 12, fig) + assert label_extent is not None + assert label_extent[0] > 0 + + text_extent = pcbar._measure_text_artist_points(text, fig) + assert text_extent is not None + assert text_extent[1] > 0 + + tick_extent = pcbar._measure_ticklabel_extent_points(ax.xaxis, fig) + assert tick_extent is not None + assert tick_extent[0] > 0 + + text_overhang = pcbar._measure_text_overhang_axes(text, ax) + assert text_overhang is not None + assert text_overhang[0] > 0 or text_overhang[3] > 0 + + tick_overhang = pcbar._measure_ticklabel_overhang_axes(ax.xaxis, ax) + assert tick_overhang is not None + + assert pcbar._measure_label_points("label", 0, 12, BrokenFigure()) is None + assert pcbar._measure_ticklabel_extent_points(BrokenAxis(), fig) is None + + +def test_colorbar_layout_and_reflow_helpers(rng): + fig, ax = uplt.subplots() + ax = ax[0] + mappable = ax.imshow(rng.random((10, 10))) + colorbar = ax.colorbar( + mappable, + loc="ur", + frameon=True, + label="Inset label", + labelloc="top", + orientation="vertical", + ) + fig.canvas.draw() + + bounds_inset, bounds_frame = pcbar._solve_inset_colorbar_bounds( + axes=ax, + loc="upper right", + orientation="vertical", + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation="right", + labelloc="top", + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(bounds_inset) == 4 + assert len(bounds_frame) == 4 + + legacy_inset, legacy_frame = pcbar._legacy_inset_colorbar_bounds( + axes=ax, + loc="upper right", + orientation="horizontal", + length=0.4, + width=0.08, + xpad=0.02, + ypad=0.02, + ticklocation="bottom", + labelloc="bottom", + label="Inset label", + labelrotation="auto", + tick_fontsize=10, + label_fontsize=12, + ) + assert len(legacy_inset) == 4 + assert legacy_frame[2] >= legacy_inset[2] + + frame = colorbar.ax._inset_colorbar_frame + assert frame is not None + pcbar._apply_inset_colorbar_layout( + colorbar.ax, + bounds_inset=bounds_inset, + bounds_frame=bounds_frame, + frame=frame, + ) + assert colorbar.ax._inset_colorbar_bounds["inset"] == bounds_inset + + pcbar._register_inset_colorbar_reflow(fig) + callback_id = fig._inset_colorbar_reflow_cid + pcbar._register_inset_colorbar_reflow(fig) + assert fig._inset_colorbar_reflow_cid == callback_id + + ax._inset_colorbar_obj = colorbar + colorbar.ax._inset_colorbar_obj = colorbar + event = ResizeEvent("resize_event", fig.canvas) + fig.canvas.callbacks.process("resize_event", event) + assert getattr(ax, "_inset_colorbar_needs_reflow", False) is True + + renderer = fig.canvas.get_renderer() + labelloc = colorbar.ax._inset_colorbar_labelloc + original_get_window_extent = frame.get_window_extent + frame.get_window_extent = lambda renderer=None: Bbox.from_bounds(0, 0, 1, 1) + pcbar._reflow_inset_colorbar_frame( + colorbar, + labelloc=labelloc, + ticklen=colorbar.ax._inset_colorbar_ticklen, + renderer=renderer, + ) + frame.get_window_extent = original_get_window_extent + + pcbar._reflow_inset_colorbar_frame( + colorbar, + labelloc=labelloc, + ticklen=colorbar.ax._inset_colorbar_ticklen, + renderer=renderer, + ) + fig.canvas.draw() + assert frame.get_window_extent(renderer=fig.canvas.get_renderer()).width > 0 diff --git a/ultraplot/tests/test_colormap_helpers_extra.py b/ultraplot/tests/test_colormap_helpers_extra.py new file mode 100644 index 000000000..cabc5aed4 --- /dev/null +++ b/ultraplot/tests/test_colormap_helpers_extra.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Additional branch coverage for colormap helpers and registries.""" + +import matplotlib.colors as mcolors +import pytest + +from ultraplot import colors as pcolors +from ultraplot.internals.warnings import UltraPlotWarning + + +def _make_continuous() -> pcolors.ContinuousColormap: + return pcolors.ContinuousColormap.from_list("helper_map", ["red", "blue"]) + + +def test_colormap_utility_and_roundtrip_helpers(tmp_path, capsys): + cmap = _make_continuous() + + assert cmap._make_name() == "_helper_map_copy" + parsed = cmap._parse_path(str(tmp_path), ext="json", subfolder="cmaps") + assert parsed.endswith("helper_map.json") + assert "#" in cmap._get_data("hex") + + with pytest.raises(ValueError, match="Invalid extension"): + cmap._get_data("bad") + + json_path = tmp_path / "helper_map.json" + rgb_path = tmp_path / "helper_map.rgb" + hex_path = tmp_path / "helper_cycle.hex" + + cmap.save(str(json_path)) + cmap.save(str(rgb_path)) + cycle = cmap.to_discrete(3, name="helper_cycle") + cycle.save(str(hex_path)) + + assert isinstance( + pcolors.ContinuousColormap.from_file(str(json_path)), + pcolors.ContinuousColormap, + ) + assert isinstance( + pcolors.ContinuousColormap.from_file(str(rgb_path)), + pcolors.ContinuousColormap, + ) + assert isinstance( + pcolors.DiscreteColormap.from_file(str(hex_path)), + pcolors.DiscreteColormap, + ) + + assert "Saved colormap" in capsys.readouterr().out + + +def test_colormap_from_file_error_paths(tmp_path): + missing = tmp_path / "missing.json" + with pytest.raises(FileNotFoundError): + pcolors.ContinuousColormap.from_file(str(missing)) + + bad_json = tmp_path / "broken.json" + bad_json.write_text("{broken") + with pytest.warns(UltraPlotWarning, match="JSON decoding error"): + assert ( + pcolors.ContinuousColormap.from_file(str(bad_json), warn_on_failure=True) + is None + ) + + bad_rgb = tmp_path / "broken.rgb" + bad_rgb.write_text("1 2\n3 4\n") + with pytest.warns(UltraPlotWarning, match="Expected 3 or 4 columns"): + assert ( + pcolors.ContinuousColormap.from_file(str(bad_rgb), warn_on_failure=True) + is None + ) + + bad_hex = tmp_path / "broken.hex" + bad_hex.write_text("not a hex string") + with pytest.warns(UltraPlotWarning, match="HEX strings"): + assert ( + pcolors.DiscreteColormap.from_file(str(bad_hex), warn_on_failure=True) + is None + ) + + bad_xml = tmp_path / "broken.xml" + bad_xml.write_text("") + with pytest.warns(UltraPlotWarning, match="XML parsing error"): + assert ( + pcolors.ContinuousColormap.from_file(str(bad_xml), warn_on_failure=True) + is None + ) + + unknown = tmp_path / "broken.foo" + unknown.write_text("noop") + with pytest.warns(UltraPlotWarning, match="Unknown colormap file extension"): + assert ( + pcolors.ContinuousColormap.from_file(str(unknown), warn_on_failure=True) + is None + ) + + +def test_continuous_discrete_and_perceptual_colormap_methods(): + cmap = _make_continuous() + assert cmap.append() is cmap + + with pytest.raises(TypeError, match="LinearSegmentedColormaps"): + cmap.append("bad") + + assert isinstance(cmap.cut(-0.2), pcolors.ContinuousColormap) + with pytest.raises(ValueError, match="Invalid cut"): + cmap.cut(0.8, left=0.4, right=0.6) + + assert cmap.shifted(0) is cmap + assert cmap.truncate(0, 1) is cmap + assert isinstance(cmap.truncate(0.2, 0.8), pcolors.ContinuousColormap) + assert isinstance(cmap.to_discrete(3), pcolors.DiscreteColormap) + + with pytest.raises(TypeError, match="Samples must be integer or iterable"): + cmap.to_discrete(1.5) + with pytest.raises(TypeError, match="Colors must be iterable"): + pcolors.ContinuousColormap.from_list("bad", 1.0) + + cycle = pcolors.DiscreteColormap(["red", "red"], name="mono") + assert cycle.monochrome is True + assert cycle.append() is cycle + + with pytest.raises(TypeError, match="Arguments .* must be DiscreteColormap"): + cycle.append("bad") + + assert cycle.shifted(0) is cycle + assert cycle.truncate() is cycle + assert cycle.reversed().name.endswith("_r") + assert cycle.shifted(1).name.endswith("_s") + + pmap = pcolors.PerceptualColormap.from_list( + ["blue", "white", "red"], adjust_grays=True + ) + assert isinstance(pmap, pcolors.PerceptualColormap) + pmap.set_gamma(2) + assert isinstance(pmap.copy(gamma=1.5, space="hcl"), pcolors.PerceptualColormap) + assert isinstance(pmap.to_continuous(), pcolors.ContinuousColormap) + + with pytest.raises(TypeError, match="unexpected keyword argument 'hue'"): + pcolors.PerceptualColormap.from_color("red", hue=10) + with pytest.raises(ValueError, match="Unknown colorspace"): + pcolors.PerceptualColormap.from_hsl(space="bad") + with pytest.raises(ValueError, match="Colors must be iterable"): + pcolors.PerceptualColormap.from_list("bad", 1.0) + + +def test_color_and_colormap_database_helpers(tmp_path): + color_db = pcolors.ColorDatabase( + {"greything": "#010203", "kelley green": "#00ff00"} + ) + assert color_db["graything"] == "#010203" + assert color_db["kelly green"] == "#00ff00" + + with pytest.raises(ValueError, match="Must be string"): + color_db._parse_key(1) + + helper_cycle = pcolors.DiscreteColormap(["red", "blue"], name="helper_cycle_db") + helper_map = pcolors.ContinuousColormap.from_list( + "helper_map_db", ["black", "white"] + ) + pcolors._cmap_database.register(helper_cycle, name="helper_cycle_db", force=True) + pcolors._cmap_database.register(helper_map, name="helper_map_db", force=True) + + rgba_cycle = color_db.cache._get_rgba(("helper_cycle_db", 1), None) + assert rgba_cycle[:3] == pytest.approx(mcolors.to_rgba("blue")[:3]) + + rgba_map = color_db.cache._get_rgba(("helper_map_db", 0.5), 0.4) + assert rgba_map[3] == pytest.approx(0.4) + + with pytest.raises(ValueError, match="between 0 and 1"): + color_db.cache._get_rgba(("helper_map_db", 2), None) + with pytest.raises(ValueError, match="between 0 and 1"): + color_db.cache._get_rgba(("helper_cycle_db", 5), None) + + assert isinstance( + pcolors._get_cmap_subtype("helper_cycle_db", "discrete"), + pcolors.DiscreteColormap, + ) + with pytest.raises(RuntimeError, match="Invalid subtype"): + pcolors._get_cmap_subtype("helper_cycle_db", "bad") + with pytest.raises(ValueError, match="Invalid perceptual colormap name"): + pcolors._get_cmap_subtype("helper_cycle_db", "perceptual") + + listed = mcolors.ListedColormap(["red", "green", "blue"], name="listed_db") + assert isinstance( + pcolors._translate_cmap(listed, listedthresh=2), + pcolors.ContinuousColormap, + ) + small_listed = mcolors.ListedColormap(["red", "blue"], name="small_listed_db") + assert isinstance( + pcolors._translate_cmap(small_listed, listedthresh=10), + pcolors.DiscreteColormap, + ) + + base = mcolors.Colormap("base_db") + assert pcolors._translate_cmap(base) is base + + lazy_hex = tmp_path / "lazy.hex" + lazy_hex.write_text("#ff0000, #00ff00") + lazy_db = pcolors.ColormapDatabase({}) + lazy_db.register_lazy("lazycycle", str(lazy_hex), "discrete") + assert isinstance(lazy_db["lazycycle"], pcolors.DiscreteColormap) + + with pytest.raises(KeyError, match="Key must be a string"): + lazy_db[1] diff --git a/ultraplot/tests/test_colors_helpers.py b/ultraplot/tests/test_colors_helpers.py new file mode 100644 index 000000000..74ef040fb --- /dev/null +++ b/ultraplot/tests/test_colors_helpers.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Focused tests for colormap and normalization helpers. +""" + +from __future__ import annotations + +import numpy as np +import numpy.ma as ma +import pytest +import matplotlib as mpl +import matplotlib.cm as mcm +import matplotlib.colors as mcolors + +from ultraplot import colors as pcolors +from ultraplot import config +from ultraplot.internals.warnings import UltraPlotWarning + + +@pytest.fixture(autouse=True) +def reset_color_databases(): + pcolors._cmap_database = pcolors._init_cmap_database() + config.register_cmaps(default=True) + config.register_cycles(default=True) + yield + + +def test_clip_colors_and_channels_warn_and_offset(): + colors = np.array([[-0.1, 0.5, 1.2], [0.1, 1.5, 0.2]]) + clipped = pcolors._clip_colors(colors.copy(), clip=True) + assert np.all((0 <= clipped) & (clipped <= 1)) + + grayed = pcolors._clip_colors(colors.copy(), clip=False, gray=0.3) + assert np.isclose(grayed[0, 0], 0.3) + assert np.isclose(grayed[0, 2], 0.3) + + with pytest.warns(UltraPlotWarning, match="channel"): + pcolors._clip_colors(colors.copy(), clip=True, warn=True) + + assert pcolors._get_channel(lambda value: value, "hue") is not None + assert pcolors._get_channel(0.5, "hue") == 0.5 + assert pcolors._get_channel("red+0.2", "luminance") == pytest.approx( + pcolors.to_xyz("red", "hcl")[2] + 0.2 + ) + with pytest.raises(ValueError, match="Unknown channel"): + pcolors._get_channel("red", "bad") + + +def test_make_segment_data_lookup_tables_and_sanitize_levels(): + callable_data = pcolors._make_segment_data(lambda x: x) + assert callable(callable_data) + + assert pcolors._make_segment_data([0.2]) == [(0, 0.2, 0.2), (1, 0.2, 0.2)] + assert pcolors._make_segment_data([0.0, 1.0], ratios=[2]) == [ + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + ] + with pytest.warns(UltraPlotWarning, match="ignoring ratios"): + data = pcolors._make_segment_data([0.0, 1.0], coords=[0, 1], ratios=[1]) + assert data == [(0, 0.0, 0.0), (1, 1.0, 1.0)] + with pytest.raises(ValueError, match="Coordinates must range from 0 to 1"): + pcolors._make_segment_data([0.0, 1.0], coords=[0.1, 1.0]) + with pytest.raises(ValueError, match="ratios"): + pcolors._make_segment_data([0.0, 0.5, 1.0], ratios=[1]) + + lookup = pcolors._make_lookup_table(5, [(0, 0, 0), (1, 1, 1)], gamma=2) + assert lookup.shape == (5,) + assert lookup[0] == pytest.approx(0) + assert lookup[-1] == pytest.approx(1) + + inverse_lookup = pcolors._make_lookup_table( + 5, [(0, 0, 0), (1, 1, 1)], gamma=2, inverse=True + ) + assert inverse_lookup.shape == (5,) + + functional_lookup = pcolors._make_lookup_table(4, lambda values: values**2, gamma=1) + assert np.allclose(functional_lookup, np.linspace(0, 1, 4) ** 2) + + with pytest.raises(ValueError, match="Gamma can only be in range"): + pcolors._make_lookup_table(4, [(0, 0, 0), (1, 1, 1)], gamma=0.001) + with pytest.raises(ValueError, match="Only one gamma allowed"): + pcolors._make_lookup_table(4, lambda values: values, gamma=[1, 2]) + + ascending, descending = pcolors._sanitize_levels([1, 2, 3]) + assert np.array_equal(ascending, np.array([1, 2, 3])) + assert descending is False + reversed_levels, descending_flag = pcolors._sanitize_levels([3, 2, 1]) + assert np.array_equal(reversed_levels, np.array([1, 2, 3])) + assert descending_flag is True + with pytest.raises(ValueError, match="size >= 2"): + pcolors._sanitize_levels([1]) + with pytest.raises(ValueError, match="must be monotonic"): + pcolors._sanitize_levels([1, 3, 2]) + + +def test_interpolation_and_norm_helpers_cover_edge_cases(): + assert pcolors._interpolate_scalar(0.5, 0, 1, 10, 20) == pytest.approx(15) + + xq = ma.masked_array([-1.0, 0.5, 2.0], mask=[False, False, True]) + yq = pcolors._interpolate_extrapolate_vector(xq, [0, 1], [10, 20]) + assert np.allclose(yq[:2], [0, 15]) + assert yq.mask.tolist() == [False, False, True] + + norm = pcolors.DiscreteNorm([3, 2, 1], unique="both", step=0.5, clip=True) + values = norm(np.array([1.0, 2.0, 3.0])) + assert float(np.min(values)) >= 0.0 + assert float(np.max(values)) <= 1.0 + 1e-9 + assert norm.descending is True + with pytest.raises(ValueError, match="not invertible"): + norm.inverse([0.5]) + with pytest.raises(ValueError, match="BoundaryNorm"): + pcolors.DiscreteNorm([1, 2, 3], norm=mcolors.BoundaryNorm([1, 2, 3], 2)) + with pytest.raises(ValueError, match="Normalize"): + pcolors.DiscreteNorm([1, 2, 3], norm="bad") + with pytest.raises(ValueError, match="Unknown unique setting"): + pcolors.DiscreteNorm([1, 2, 3], unique="bad") + + segmented = pcolors.SegmentedNorm([1, 2, 4], clip=True) + transformed = segmented(np.array([1.0, 2.0, 4.0])) + assert np.allclose(transformed, [0.0, 0.5, 1.0]) + assert np.allclose(segmented.inverse(transformed), [1.0, 2.0, 4.0]) + + diverging = pcolors.DivergingNorm(vcenter=0, vmin=-2, vmax=4, fair=False) + assert np.isclose(diverging(-2), 0.0) + assert np.isclose(diverging(0), 0.5) + assert np.isclose(diverging(4), 1.0) + autoscaled = pcolors.DivergingNorm(vcenter=0) + autoscaled.autoscale_None(np.array([2.0, 3.0])) + assert autoscaled.vmin == 0 + assert autoscaled.vmax == 3 + adjusted = pcolors.DivergingNorm(vcenter=0, vmin=2, vmax=1) + assert np.isfinite(adjusted(0.5)) + assert adjusted.vmin == 0 + assert adjusted.vmax == 1 + + +def test_cmap_translation_type_checks_and_color_cache_helpers(): + with pytest.raises(RuntimeError, match="Invalid subtype"): + pcolors._get_cmap_subtype("viridis", "bad") + with pytest.raises(ValueError, match="Invalid discrete colormap name"): + pcolors._get_cmap_subtype("viridis", "discrete") + + listed = mcolors.ListedColormap(["red", "blue"], name="listed_small") + translated_listed = pcolors._translate_cmap(listed, listedthresh=10) + assert isinstance(translated_listed, pcolors.DiscreteColormap) + + dense = mcolors.ListedColormap( + np.linspace(0, 1, 20)[:, None].repeat(3, axis=1), name="listed_dense" + ) + translated_dense = pcolors._translate_cmap(dense, listedthresh=5) + assert isinstance(translated_dense, pcolors.ContinuousColormap) + + segment_data = { + "red": [(0, 0, 0), (1, 1, 1)], + "green": [(0, 0, 0), (1, 1, 1)], + "blue": [(0, 0, 0), (1, 1, 1)], + } + translated_segmented = pcolors._translate_cmap( + mcolors.LinearSegmentedColormap("seg", segment_data) + ) + assert isinstance(translated_segmented, pcolors.ContinuousColormap) + + base = mcolors.Colormap("base") + assert pcolors._translate_cmap(base) is base + with pytest.raises(ValueError, match="Invalid colormap type"): + pcolors._translate_cmap("bad") + + discrete = pcolors.DiscreteColormap(["red", "blue"], name="helper_cycle") + pcolors._cmap_database.register(discrete) + cache = pcolors._ColorCache() + cycle_rgba = cache._get_rgba(("helper_cycle", 1), None) + assert cycle_rgba[-1] == 1 + with pytest.raises(ValueError, match="must be between 0 and 1"): + cache._get_rgba(("viridis", 2), None) + with pytest.raises(ValueError, match="must be between 0 and 1"): + cache._get_rgba(("helper_cycle", 3), None) + with pytest.raises(KeyError): + cache._get_rgba(("not-a-cmap", 0.2), None) diff --git a/ultraplot/tests/test_config_helpers_extra.py b/ultraplot/tests/test_config_helpers_extra.py new file mode 100644 index 000000000..eef60df0b --- /dev/null +++ b/ultraplot/tests/test_config_helpers_extra.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Additional branch coverage for configuration helpers.""" + +import numpy as np +import pytest + +from ultraplot import config +from ultraplot.internals.warnings import UltraPlotWarning + + +def _fresh_config() -> config.Configurator: + return config.Configurator(local=False, user=False, default=True) + + +def test_style_dict_and_inference_helpers(): + with pytest.warns(UltraPlotWarning, match="not related to style"): + filtered = config._filter_style_dict( + {"backend": "agg", "axes.facecolor": "white"} + ) + assert filtered == {"axes.facecolor": "white"} + + alias_style = config._get_style_dict("538") + assert "axes.facecolor" in alias_style + + inline_style = config._get_style_dict({"axes.facecolor": "black"}) + assert inline_style["axes.facecolor"] == "black" + + combined = {"xtick.labelsize": 9, "axes.titlesize": 14, "text.color": "red"} + inferred = config._infer_ultraplot_dict(combined) + assert inferred["tick.labelsize"] == 9 + assert inferred["title.size"] == 14 + assert inferred["grid.labelcolor"] == "red" + + with pytest.raises(TypeError): + config._get_style_dict(1) + with pytest.raises(IOError, match="not found in the style library"): + config._get_style_dict("definitely-not-a-style") + + +def test_configurator_validation_item_dicts_and_context(tmp_path): + cfg = _fresh_config() + + with pytest.raises(KeyError, match="Must be string"): + cfg._validate_key(1) + + key, value = cfg._validate_key("ticklen") + assert key == "tick.len" + assert value is None + assert cfg._validate_value("tick.len", np.array(4.0)) == 4.0 + + kw_ultraplot, kw_matplotlib = cfg._get_item_dicts("tick.len", 4) + assert kw_matplotlib["xtick.minor.size"] == pytest.approx(4 * cfg["tick.lenratio"]) + assert kw_matplotlib["ytick.minor.size"] == pytest.approx(4 * cfg["tick.lenratio"]) + + kw_ultraplot, kw_matplotlib = cfg._get_item_dicts("grid", True) + assert kw_matplotlib["axes.grid"] is True + assert kw_matplotlib["axes.grid.which"] in ("major", "minor", "both") + + kw_ultraplot, _ = cfg._get_item_dicts("abc.bbox", True) + assert kw_ultraplot["abc.border"] is False + + style_path = tmp_path / "custom.mplstyle" + style_path.write_text( + "\n".join( + ( + "xtick.labelsize: 11", + "axes.titlesize: 14", + "text.color: red", + ) + ) + ) + kw_ultraplot, kw_matplotlib = cfg._get_item_dicts("style", str(style_path)) + assert kw_matplotlib["xtick.labelsize"] == 11 + assert "tick.labelsize" in kw_ultraplot + assert kw_ultraplot["title.size"] == pytest.approx(14) + assert kw_ultraplot["grid.labelcolor"] == "red" + + kw_ultraplot, kw_matplotlib = cfg._get_item_dicts("font.size", 12) + assert "abc.size" in kw_ultraplot + assert kw_matplotlib["font.size"] == 12 + + with pytest.raises(ValueError, match="Invalid caching mode"): + cfg._get_item_context("tick.len", mode=99) + + with cfg.context({"ticklen": 6}, mode=2): + assert cfg.find("tick.len", context=True) == 6 + assert cfg.find("axes.facecolor", context=True) is None + assert cfg._context_mode == 2 + assert cfg._context_mode == 0 + + with pytest.raises(ValueError, match="Non-dictionary argument"): + cfg.context(1) + with pytest.raises(ValueError, match="Invalid mode"): + cfg.context(mode=3) + + cfg.update("axes", labelsize=13) + assert cfg["axes.labelsize"] == 13 + assert "labelsize" in cfg.category("axes") + assert cfg.fill({"face": "axes.facecolor"})["face"] == cfg["axes.facecolor"] + + with pytest.raises(ValueError, match="Invalid rc category"): + cfg.category("not-a-category") + with pytest.raises(ValueError, match="Invalid arguments"): + cfg.update("axes", {"labelsize": 1}, {"titlesize": 2}) + + +def test_configurator_background_and_grid_helpers(): + cfg = _fresh_config() + cfg["axes.grid"] = True + cfg["axes.grid.which"] = "both" + cfg["axes.grid.axis"] = "x" + cfg["axes.axisbelow"] = "line" + cfg["axes.facecolor"] = "white" + cfg["axes.edgecolor"] = "black" + cfg["axes.linewidth"] = 1.5 + + with pytest.warns(UltraPlotWarning, match="patch_kw"): + kw_face, kw_edge = cfg._get_background_props( + patch_kw={"linewidth": 2}, + color="red", + facecolor="blue", + ) + assert kw_face["facecolor"] == "blue" + assert kw_edge["edgecolor"] == "red" + assert kw_edge["capstyle"] == "projecting" + + with pytest.raises(TypeError, match="Unexpected keyword"): + cfg._get_background_props(unexpected=True) + + assert cfg._get_gridline_bool(axis="x", which="major") is True + assert cfg._get_gridline_bool(axis="x", which="minor") is True + assert cfg._get_gridline_bool(axis="y", which="major") is False + + props = cfg._get_gridline_props(which="major", native=False) + assert props["zorder"] == pytest.approx(1.5) + + label_props = cfg._get_label_props(color="red") + assert label_props["color"] == "red" + + with cfg.context({"xtick.top": True, "xtick.bottom": False}, mode=2): + assert cfg._get_loc_string("xtick", axis="x") == "top" + + tick_props = cfg._get_tickline_props(axis="x", which="major") + assert "size" in tick_props + assert "color" in tick_props + + ticklabel_props = cfg._get_ticklabel_props(axis="x") + assert "size" in ticklabel_props + assert "color" in ticklabel_props + + assert cfg._get_axisbelow_zorder(True) == 0.5 + assert cfg._get_axisbelow_zorder(False) == 2.5 + assert cfg._get_axisbelow_zorder("line") == 1.5 + with pytest.raises(ValueError, match="Unexpected axisbelow value"): + cfg._get_axisbelow_zorder("bad") + + +def test_configurator_path_resolution_and_file_io(tmp_path, monkeypatch): + home = tmp_path / "home" + xdg = tmp_path / "xdg" + home.mkdir() + xdg.mkdir() + + universal_dir = home / ".ultraplot" + xdg_dir = xdg / "ultraplot" + universal_dir.mkdir() + xdg_dir.mkdir() + + loose_file = home / ".ultraplotrc" + folder_file = universal_dir / "ultraplotrc" + loose_file.write_text("tick.len: 5\n") + folder_file.write_text("tick.len: 6\n") + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + monkeypatch.setattr(config.sys, "platform", "linux") + + assert config.Configurator._config_folder() == str(xdg_dir) + with pytest.warns( + UltraPlotWarning, match="conflicting default user ultraplot folders" + ): + assert config.Configurator.user_folder() == str(universal_dir) + with pytest.warns( + UltraPlotWarning, match="conflicting default user ultraplotrc files" + ): + assert config.Configurator.user_file() == str(loose_file) + + data_dir = tmp_path / "data" + data_dir.mkdir() + visible = data_dir / "colors.txt" + visible.write_text("blue : #0000ff\n") + (data_dir / ".hidden.txt").write_text("hidden") + + monkeypatch.setattr( + config, + "_get_data_folders", + lambda folder, **kwargs: [str(data_dir)], + ) + assert list( + config._iter_data_objects("colors", user=False, local=False, default=False) + ) == [(0, str(visible))] + + with pytest.raises(FileNotFoundError): + list( + config._iter_data_objects( + "colors", + str(tmp_path / "missing.txt"), + user=False, + local=False, + default=False, + ) + ) + + cfg = _fresh_config() + rc_file = tmp_path / "sample.rc" + rc_file.write_text( + "\n".join( + ( + "tick.len: 4", + "illegal line", + "unknown.key: 1", + "tick.len: 5", + ) + ) + ) + with pytest.warns(UltraPlotWarning): + loaded = cfg._load_file(str(rc_file)) + assert loaded["tick.len"] == pytest.approx(5) + + save_path = tmp_path / "ultraplotrc" + save_path.write_text("old config") + cfg["tick.len"] = 7 + with pytest.warns(UltraPlotWarning, match="was moved to"): + cfg.save(str(save_path), backup=True) + assert save_path.exists() + assert (tmp_path / "ultraplotrc.bak").exists() diff --git a/ultraplot/tests/test_constructor_helpers_extra.py b/ultraplot/tests/test_constructor_helpers_extra.py new file mode 100644 index 000000000..08659cad5 --- /dev/null +++ b/ultraplot/tests/test_constructor_helpers_extra.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Additional branch coverage for constructor helpers.""" + +import cycler +import matplotlib.colors as mcolors +import matplotlib.dates as mdates +import matplotlib.ticker as mticker +import numpy as np +import pytest + +import ultraplot as uplt +from ultraplot import colors as pcolors +from ultraplot import constructor +from ultraplot import scale as pscale +from ultraplot import ticker as pticker +from ultraplot.internals.warnings import UltraPlotWarning + + +def test_colormap_constructor_branches(tmp_path, monkeypatch): + hex_path = tmp_path / "cycle.hex" + hex_path.write_text("#ff0000, #00ff00, #0000ff") + + saved = {} + + def fake_save(self, **kwargs): + saved.update(kwargs) + + monkeypatch.setattr(pcolors.DiscreteColormap, "save", fake_save) + + with pytest.warns(UltraPlotWarning, match="listmode='discrete'"): + deprecated = constructor.Colormap(["red", "blue"], listmode="listed") + assert isinstance(deprecated, pcolors.DiscreteColormap) + + cmap = constructor.Colormap( + str(hex_path), + filemode="discrete", + samples=2, + name="saved_cycle", + save=True, + save_kw={"path": str(tmp_path / "saved.hex")}, + ) + assert isinstance(cmap, pcolors.DiscreteColormap) + assert cmap.name == "saved_cycle" + assert saved["path"].endswith("saved.hex") + + perceptual = constructor.Colormap( + hue=(0, 240), + saturation=(100, 100), + luminance=(100, 40), + alpha=(0.25, 1.0), + ) + assert isinstance(perceptual, pcolors.PerceptualColormap) + assert "alpha" in perceptual._segmentdata + + reversed_color = constructor.Colormap("red_r") + assert isinstance(reversed_color, pcolors.PerceptualColormap) + + with pytest.raises(ValueError, match="requires either positional arguments"): + constructor.Colormap() + with pytest.raises(ValueError, match="Invalid listmode"): + constructor.Colormap(["red"], listmode="bad") + with pytest.raises(ValueError, match="Got 2 colormap-specs but 3 values"): + constructor.Colormap("Reds", "Blues", reverse=[True, False, True]) + with pytest.raises(ValueError, match="The colormap name must be a string"): + constructor.Colormap(["red"], name=1) + with pytest.raises(ValueError, match="Invalid colormap, color cycle, or color"): + constructor.Colormap(object()) + + +def test_cycle_constructor_branches(): + base = cycler.cycler(color=["red", "blue"]) + + merged = constructor.Cycle(base, marker=["o"]) + assert merged.get_next() == {"color": "red", "marker": "o"} + assert merged == constructor.Cycle(base, marker=["o"]) + + sampled = constructor.Cycle("Blues", 3, marker=["x"]) + props = [sampled.get_next() for _ in range(3)] + assert all(prop["marker"] == "x" for prop in props) + + with pytest.warns(UltraPlotWarning, match="Ignoring Cycle"): + defaulted = constructor.Cycle(right=0.5) + assert defaulted.get_next() == {"color": "black"} + + with pytest.warns(UltraPlotWarning, match="Ignoring Cycle"): + ignored = constructor.Cycle(base, left=0.25) + assert ignored.get_next()["color"] == "red" + + +def test_norm_locator_formatter_and_scale_branches(): + copied_norm = constructor.Norm(mcolors.Normalize(vmin=0, vmax=1)) + assert isinstance(copied_norm, mcolors.Normalize) + assert copied_norm is not constructor.Norm(copied_norm) + + symlog = constructor.Norm(("symlog",), vmin=-1, vmax=1) + assert symlog.linthresh == 1 + assert isinstance(constructor.Norm(("power", 2), vmin=0, vmax=1), mcolors.PowerNorm) + + with pytest.raises(ValueError, match="Invalid norm name"): + constructor.Norm(object()) + with pytest.raises(ValueError, match="Unknown normalizer"): + constructor.Norm("badnorm") + + copied_locator = constructor.Locator(mticker.MaxNLocator(4)) + assert isinstance(copied_locator, mticker.MaxNLocator) + index_locator = constructor.Locator("index") + assert index_locator._base == 1 + assert index_locator._offset == 0 + assert isinstance(constructor.Locator("logminor"), mticker.LogLocator) + assert isinstance(constructor.Locator("logitminor"), mticker.LogitLocator) + assert isinstance( + constructor.Locator("symlogminor", base=10, linthresh=1), + mticker.SymmetricalLogLocator, + ) + assert isinstance(constructor.Locator(True), mticker.AutoLocator) + assert isinstance(constructor.Locator(False), mticker.NullLocator) + assert isinstance(constructor.Locator(2), mticker.MultipleLocator) + assert isinstance(constructor.Locator([1, 2, 3]), mticker.FixedLocator) + assert isinstance( + constructor.Locator([1, 2, 3], discrete=True), pticker.DiscreteLocator + ) + + with pytest.raises(ValueError, match="Unknown locator"): + constructor.Locator("not-a-locator") + with pytest.raises(ValueError, match="Invalid locator"): + constructor.Locator(object()) + + copied_formatter = constructor.Formatter(mticker.ScalarFormatter()) + assert isinstance(copied_formatter, mticker.ScalarFormatter) + assert isinstance(constructor.Formatter("{x:.1f}"), mticker.StrMethodFormatter) + assert isinstance( + constructor.Formatter("%0.1f", tickrange=(0, 1)), + mticker.FormatStrFormatter, + ) + assert isinstance(constructor.Formatter("%Y-%m", date=True), mdates.DateFormatter) + assert isinstance(constructor.Formatter(("sigfig", 3)), pticker.SigFigFormatter) + assert isinstance(constructor.Formatter(True), pticker.AutoFormatter) + assert isinstance(constructor.Formatter(False), mticker.NullFormatter) + assert isinstance( + constructor.Formatter(["a", "b"], index=True), pticker.IndexFormatter + ) + assert isinstance( + constructor.Formatter(lambda value, pos=None: str(value)), + mticker.FuncFormatter, + ) + + with pytest.raises(ValueError, match="Unknown formatter"): + constructor.Formatter("not-a-formatter") + with pytest.raises(ValueError, match="Invalid formatter"): + constructor.Formatter(object()) + + copied_scale = constructor.Scale(pscale.LinearScale()) + assert isinstance(copied_scale, pscale.LinearScale) + + tuple_scale = constructor.Scale(("power", 3)) + transformed = tuple_scale.get_transform().transform_non_affine(np.array([2.0])) + assert transformed[0] == pytest.approx(8.0) + + with pytest.warns(UltraPlotWarning, match="scale \\*preset\\*"): + quadratic = constructor.Scale("quadratic", 99) + quadratic_values = quadratic.get_transform().transform_non_affine(np.array([3.0])) + assert quadratic_values[0] == pytest.approx(9.0) + + with pytest.raises(ValueError, match="Unknown scale or preset"): + constructor.Scale("not-a-scale") + with pytest.raises(ValueError, match="Invalid scale name"): + constructor.Scale(object()) + + +def test_proj_constructor_branches(): + ccrs = pytest.importorskip("cartopy.crs") + + proj = ccrs.PlateCarree() + with pytest.warns(UltraPlotWarning, match="Ignoring Proj\\(\\) keyword"): + same_proj = constructor.Proj(proj, backend="cartopy", lon0=10) + assert same_proj is proj + assert same_proj._proj_backend == "cartopy" + + with pytest.raises(ValueError, match="Invalid backend"): + constructor.Proj("merc", backend="bad") + with pytest.raises(ValueError, match="Unexpected projection"): + constructor.Proj(10) + with pytest.raises(ValueError, match="Must be passed to GeoAxes.format"): + constructor.Proj("merc", backend="cartopy", round=True) + with pytest.raises(ValueError, match="unknown cartopy projection class"): + constructor.Proj("not-a-proj", backend="cartopy") diff --git a/ultraplot/tests/test_inputs_helpers.py b/ultraplot/tests/test_inputs_helpers.py new file mode 100644 index 000000000..2288e07a0 --- /dev/null +++ b/ultraplot/tests/test_inputs_helpers.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Focused tests for plotting input helpers. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import matplotlib.tri as mtri + +from ultraplot.internals import inputs +from ultraplot.internals.warnings import UltraPlotWarning + + +def test_basic_type_and_array_helpers(): + assert inputs._is_numeric([1, 2, 3]) is True + assert inputs._is_numeric(["a", "b"]) is False + assert inputs._is_categorical(["a", "b"]) is True + assert inputs._is_categorical([1, 2]) is False + assert inputs._is_descending(np.array([3, 2, 1])) is True + assert inputs._is_descending(np.array([[3, 2], [1, 0]])) is False + + with pytest.raises(ValueError, match="Invalid data None"): + inputs._to_duck_array(None) + + masked, units = inputs._to_masked_array(np.array([1, np.nan, 3])) + assert units is None + assert np.ma.isMaskedArray(masked) + assert masked.mask.tolist() == [False, True, False] + + masked_ints, _ = inputs._to_masked_array(np.array([1, 2, 3], dtype=int)) + assert masked_ints.dtype == np.float64 + + +def test_coordinate_conversion_helpers(): + x = np.array([0.0, 1.0, 2.0]) + y = np.array([0.0, 1.0, 2.0]) + z = np.arange(9.0).reshape(3, 3) + x_edges, y_edges = inputs._to_edges(x, y, z) + assert x_edges.shape == (4,) + assert y_edges.shape == (4,) + + z_small = np.arange(4.0).reshape(2, 2) + x_centers, y_centers = inputs._to_centers(x, y, z_small) + assert x_centers.shape == (2,) + assert y_centers.shape == (2,) + + x2 = np.array([[0.0, 1.0], [0.0, 1.0]]) + y2 = np.array([[0.0, 0.0], [1.0, 1.0]]) + z2 = np.arange(4.0).reshape(2, 2) + x2_edges, y2_edges = inputs._to_edges(x2, y2, z2) + assert x2_edges.shape == (3, 3) + assert y2_edges.shape == (3, 3) + + with pytest.raises(ValueError, match="must match array centers"): + inputs._to_edges(np.array([0.0, 1.0]), np.array([0.0, 1.0]), np.ones((3, 3))) + with pytest.raises(ValueError, match="must match z centers"): + inputs._to_centers(np.array([0.0, 1.0]), np.array([0.0, 1.0]), np.ones((3, 3))) + + +def test_from_data_and_triangulation_helpers(): + data = {"x": np.array([1, 2, 3]), "y": np.array([4, 5, 6])} + converted = inputs._from_data(data, "x", "missing", "y") + assert np.array_equal(converted[0], data["x"]) + assert converted[1] == "missing" + assert np.array_equal(converted[2], data["y"]) + assert inputs._from_data(data, "missing") == "missing" + assert inputs._from_data(None, "x") is None + + triangulation = mtri.Triangulation([0, 1, 0], [0, 0, 1]) + tri, z, args, kwargs = inputs._parse_triangulation_inputs(triangulation, [1, 2, 3]) + assert tri is triangulation + assert z == [1, 2, 3] + assert args == [] + assert kwargs == {} + + with pytest.raises(ValueError, match="No z values provided"): + inputs._parse_triangulation_inputs(triangulation) + + +def test_distribution_helpers_cover_clean_reduce_and_ranges(): + object_array = np.array([[1, 2], [3]], dtype=object) + cleaned = inputs._dist_clean(object_array) + assert len(cleaned) == 2 + assert np.allclose(cleaned[0], [1.0, 2.0]) + + numeric_cleaned = inputs._dist_clean(np.array([[1.0, np.nan], [2.0, 3.0]])) + assert len(numeric_cleaned) == 2 + assert np.allclose(numeric_cleaned[0], [1.0, 2.0]) + + list_cleaned = inputs._dist_clean([[1, 2], [], [3]]) + assert len(list_cleaned) == 2 + with pytest.raises(ValueError, match="numpy array or a list of lists"): + inputs._dist_clean("bad") + + data = np.array([[1.0, 3.0], [2.0, 4.0]]) + with pytest.warns( + UltraPlotWarning, match="Cannot have both means=True and medians=True" + ): + reduced, kwargs = inputs._dist_reduce(data, means=True, medians=True) + assert np.allclose(reduced, [1.5, 3.5]) + assert "distribution" in kwargs + + with pytest.raises(ValueError, match="Expected 2D array"): + inputs._dist_reduce(np.array([1.0, 2.0]), means=True) + + distribution = np.array([[1.0, 2.0], [3.0, 4.0]]) + err, label = inputs._dist_range( + np.array([2.0, 3.0]), + distribution, + stds=[-1, 1], + pctiles=[10, 90], + label=True, + ) + assert err.shape == (2, 2) + assert label == "1$\\sigma$ range" + + err_abs, label_abs = inputs._dist_range( + np.array([2.0, 3.0]), + None, + errdata=np.array([0.5, 0.25]), + absolute=True, + label=True, + ) + assert np.allclose(err_abs[0], [1.5, 2.75]) + assert label_abs == "uncertainty" + + with pytest.raises(ValueError, match="must pass means=True or medians=True"): + inputs._dist_range(np.array([1.0]), None, stds=1) + with pytest.raises( + ValueError, match="Passing both 2D data coordinates and 'errdata'" + ): + inputs._dist_range(np.ones((2, 2)), None, errdata=np.ones(2)) + + +def test_mask_range_and_metadata_helpers(): + masked = inputs._safe_mask(np.array([True, False, True]), np.array([1.0, 2.0, 3.0])) + assert np.isnan(masked[1]) + with pytest.raises(ValueError, match="incompatible with array shape"): + inputs._safe_mask(np.array([True, False]), np.array([1.0, 2.0, 3.0])) + + lo, hi = inputs._safe_range(np.array([1.0, np.nan, 5.0]), lo=0, hi=100) + assert lo == 1.0 + assert hi == 5.0 + + coords, kwargs = inputs._meta_coords(np.array(["a", "b"]), which="x") + assert np.array_equal(coords, np.array([0, 1])) + assert {"xlocator", "xformatter", "xminorlocator"} <= set(kwargs) + numeric_coords, kwargs_numeric = inputs._meta_coords( + np.array([1.0, 2.0]), which="y" + ) + assert np.array_equal(numeric_coords, np.array([1.0, 2.0])) + assert kwargs_numeric == {} + with pytest.raises(ValueError, match="Non-1D string coordinate input"): + inputs._meta_coords(np.array([["a", "b"]]), which="x") + + assert np.array_equal( + inputs._meta_labels(np.array([1, 2, 3]), axis=0), np.array([0, 1, 2]) + ) + assert np.array_equal( + inputs._meta_labels(np.array([1, 2, 3]), axis=1), np.array([0]) + ) + assert inputs._meta_labels(np.array([1, 2, 3]), axis=2, always=False) is None + with pytest.raises(ValueError, match="Invalid axis"): + inputs._meta_labels(np.array([1, 2, 3]), axis=3) + + assert inputs._meta_title(np.array([1, 2, 3])) is None + assert inputs._meta_units(np.array([1, 2, 3])) is None + + +def test_geographic_helpers_cover_clipping_bounds_and_globes(): + clipped = inputs._geo_clip(np.array([-100.0, 0.0, 100.0])) + assert np.allclose(clipped, [-90.0, 0.0, 90.0]) + + x = np.array([0.0, 180.0, 540.0]) + y = np.array([1.0, 2.0, 3.0]) + rolled_x, rolled_y = inputs._geo_inbounds(x, y, xmin=-180, xmax=180) + assert np.array_equal(rolled_x, np.array([180.0, 0.0, 180.0])) + assert np.array_equal(rolled_y, np.array([3.0, 1.0, 2.0])) + + xg = np.array([0.0, 180.0]) + yg = np.array([-45.0, 45.0]) + zg = np.array([[1.0, 2.0], [3.0, 4.0]]) + globe_x, globe_y, globe_z = inputs._geo_globe(xg, yg, zg, modulo=True) + assert globe_x.shape[0] == 3 + assert globe_y.shape[0] == 4 + assert globe_z.shape == (4, 3) + + seam_x, seam_y, seam_z = inputs._geo_globe(xg, yg, zg, xmin=-180, modulo=False) + assert seam_x.shape[0] == 4 + assert seam_y.shape[0] == 4 + assert seam_z.shape == (4, 4) + + with pytest.raises(ValueError, match="Unexpected shapes"): + inputs._geo_globe(np.array([0.0, 1.0, 2.0, 3.0]), yg, zg, modulo=False) diff --git a/ultraplot/tests/test_proj_helpers.py b/ultraplot/tests/test_proj_helpers.py new file mode 100644 index 000000000..e42b5bb2a --- /dev/null +++ b/ultraplot/tests/test_proj_helpers.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Focused tests for custom projection helpers. +""" + +from __future__ import annotations + +import pytest + +from cartopy.crs import Globe + +from ultraplot import proj + + +@pytest.mark.parametrize( + ("cls", "proj_name"), + [ + (proj.Aitoff, "aitoff"), + (proj.Hammer, "hammer"), + (proj.KavrayskiyVII, "kav7"), + (proj.WinkelTripel, "wintri"), + ], +) +def test_warped_projection_defaults_and_threshold(cls, proj_name): + projection = cls(central_longitude=45, false_easting=1, false_northing=2) + + assert projection.proj4_params["proj"] == proj_name + assert projection.proj4_params["lon_0"] == 45 + assert projection.proj4_params["x_0"] == 1 + assert projection.proj4_params["y_0"] == 2 + assert projection.threshold == pytest.approx(1e5) + + +@pytest.mark.parametrize( + "cls", + [proj.Aitoff, proj.Hammer, proj.KavrayskiyVII, proj.WinkelTripel], +) +def test_warped_projection_warns_for_elliptical_globes(cls): + globe = Globe(semimajor_axis=10, semiminor_axis=9, ellipse=None) + + with pytest.warns(UserWarning, match="does not handle elliptical globes"): + cls(globe=globe) + + +@pytest.mark.parametrize( + ("cls", "central_latitude"), + [ + (proj.NorthPolarAzimuthalEquidistant, 90), + (proj.SouthPolarAzimuthalEquidistant, -90), + (proj.NorthPolarLambertAzimuthalEqualArea, 90), + (proj.SouthPolarLambertAzimuthalEqualArea, -90), + (proj.NorthPolarGnomonic, 90), + (proj.SouthPolarGnomonic, -90), + ], +) +def test_polar_projection_sets_expected_central_latitude(cls, central_latitude): + projection = cls(central_longitude=30) + + assert projection.proj4_params["lat_0"] == central_latitude + assert projection.proj4_params["lon_0"] == 30 diff --git a/ultraplot/tests/test_rcsetup_helpers.py b/ultraplot/tests/test_rcsetup_helpers.py new file mode 100644 index 000000000..366c57333 --- /dev/null +++ b/ultraplot/tests/test_rcsetup_helpers.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Focused tests for rc setup validators and helpers. +""" + +from __future__ import annotations + +from cycler import cycler +import numpy as np +import pytest +import matplotlib.colors as mcolors + +from ultraplot import colors as pcolors +from ultraplot.internals import rcsetup +from ultraplot.internals.warnings import UltraPlotWarning + + +def test_get_default_param_and_membership_validators(): + assert rcsetup._get_default_param("axes.edgecolor") is not None + with pytest.raises(KeyError, match="Invalid key"): + rcsetup._get_default_param("not-a-real-key") + + validator = rcsetup._validate_belongs("solid", True, None, 3) + assert validator("SOLID") == "solid" + assert validator(True) is True + assert validator(None) is None + assert validator(3) == 3 + with pytest.raises(ValueError, match="Options are"): + validator("missing") + + +def test_misc_validators_cover_success_and_failure_paths(): + assert rcsetup._validate_abc([True, False]) is False + assert rcsetup._validate_abc("abc") == "abc" + assert rcsetup._validate_abc(("a", "b")) == ("a", "b") + with pytest.raises(TypeError): + rcsetup._validate_abc(3.5) + + original = rcsetup._rc_ultraplot_default["cftime.time_resolution_format"].copy() + try: + result = rcsetup._validate_cftime_resolution_format({"DAILY": "%Y"}) + assert result["DAILY"] == "%Y" + finally: + rcsetup._rc_ultraplot_default["cftime.time_resolution_format"] = original + + with pytest.raises(ValueError, match="expects a dict"): + rcsetup._validate_cftime_resolution_format("bad") + assert rcsetup._validate_cftime_resolution("DAILY") == "DAILY" + with pytest.raises(TypeError, match="expecting str"): + rcsetup._validate_cftime_resolution(1) + with pytest.raises(ValueError, match="Unit not understood"): + rcsetup._validate_cftime_resolution("weekly") + + assert rcsetup._validate_bool_or_iterable(True) is True + assert rcsetup._validate_bool_or_iterable([1, 2]) == [1, 2] + with pytest.raises(ValueError, match="bool or iterable"): + rcsetup._validate_bool_or_iterable(object()) + + assert rcsetup._validate_bool_or_string("name") == "name" + with pytest.raises(ValueError, match="bool or string"): + rcsetup._validate_bool_or_string(1.5) + + assert rcsetup._validate_fontprops("regular") == "regular" + assert rcsetup._validate_fontsize("med-large") == "med-large" + assert rcsetup._validate_fontsize(12) == 12 + with pytest.raises(ValueError, match="Invalid font size"): + rcsetup._validate_fontsize("gigantic") + + +def test_cmap_color_and_label_validators(): + validator = rcsetup._validate_cmap("continuous") + assert validator("viridis") == "viridis" + + cmap = mcolors.ListedColormap(["red", "blue"], name="helper_listed") + assert validator(cmap) == "helper_listed" + + cycle_validator = rcsetup._validate_cmap("continuous", cycle=True) + from_cycler = cycle_validator(cycler(color=["red", "blue"])) + assert hasattr(from_cycler, "by_key") + from_iterable = cycle_validator(["red", "blue"]) + assert hasattr(from_iterable, "by_key") + with pytest.raises(ValueError, match="Invalid colormap"): + validator(object()) + + assert rcsetup._validate_color("auto", alternative="auto") == "auto" + assert rcsetup._validate_color("red") == "red" + with pytest.raises(ValueError, match="not a valid color arg"): + rcsetup._validate_color("not-a-color") + + assert rcsetup._validate_labels("lr", lon=True) == [True, True, False, False] + assert rcsetup._validate_labels(("left", "top"), lon=True) == [ + True, + False, + False, + True, + ] + assert rcsetup._validate_labels([True, False], lon=False) == [ + True, + False, + False, + False, + ] + with pytest.raises(ValueError, match="Invalid lonlabel string"): + rcsetup._validate_labels("bad", lon=True) + with pytest.raises(ValueError, match="Invalid latlabel string"): + rcsetup._validate_labels([True, "bad"], lon=False) + + +def test_remaining_scalar_and_sequence_validators(): + validator = rcsetup._validate_or_none(rcsetup._validate_float) + assert validator(None) is None + assert validator("none") is None + assert validator(2) == 2.0 + + assert rcsetup._validate_float_or_iterable([1, 2.5]) == (1.0, 2.5) + with pytest.raises(ValueError, match="float or iterable"): + rcsetup._validate_float_or_iterable("bad") + + assert rcsetup._validate_string_or_iterable(("a", "b")) == ("a", "b") + with pytest.raises(ValueError, match="string or iterable"): + rcsetup._validate_string_or_iterable([1, 2]) + + assert rcsetup._validate_rotation("vertical") == "vertical" + assert rcsetup._validate_rotation(45) == 45.0 + + unit_validator = rcsetup._validate_units("pt") + assert unit_validator("12pt") == pytest.approx(12.0) + assert rcsetup._validate_float_or_auto("auto") == "auto" + assert rcsetup._validate_float_or_auto("1.5") == 1.5 + with pytest.raises(ValueError, match="float or 'auto'"): + rcsetup._validate_float_or_auto("bad") + + assert rcsetup._validate_tuple_int_2(np.array([1, 2])) == (1, 2) + assert rcsetup._validate_tuple_float_2([1, 2.5]) == (1.0, 2.5) + with pytest.raises(ValueError, match="2 ints"): + rcsetup._validate_tuple_int_2([1, 2, 3]) + with pytest.raises(ValueError, match="2 floats"): + rcsetup._validate_tuple_float_2([1]) + + +def test_rst_yaml_and_string_helpers_emit_expected_content(): + table = rcsetup._rst_table() + assert "Key" in table + assert "Description" in table + + assert rcsetup._to_string("#aabbcc") == "aabbcc" + assert rcsetup._to_string(1.23456789) == "1.234568" + assert rcsetup._to_string([1, 2]) == "1, 2" + assert rcsetup._to_string({"k": 1}) == "{k: 1}" + + yaml_table = rcsetup._yaml_table( + {"axes.alpha": (0.5, rcsetup._validate_float, "alpha value")}, + description=True, + ) + assert "axes.alpha" in yaml_table + assert "alpha value" in yaml_table + + with pytest.warns(UltraPlotWarning, match="Failed to write rc setting"): + assert ( + rcsetup._yaml_table({"bad": (object(), rcsetup._validate_string, "desc")}) + == "" + ) + + +def test_rcparams_handles_renamed_removed_and_copy(): + params = rcsetup._RcParams({"axes.labelsize": "med-large"}, rcsetup._validate) + assert params["axes.labelsize"] == "med-large" + copied = params.copy() + assert copied["axes.labelsize"] == "med-large" + + key_new, _ = rcsetup._rc_renamed["basemap"] + with pytest.warns(UltraPlotWarning, match="deprecated"): + checked_key, checked_value = rcsetup._RcParams._check_key("basemap", True) + assert checked_key == key_new + assert checked_value == "basemap" + + removed_key = next(iter(rcsetup._rc_removed)) + with pytest.raises(KeyError, match="was removed"): + rcsetup._RcParams._check_key(removed_key) + + with pytest.raises(KeyError, match="Invalid rc key"): + params["not-a-real-key"] = 1 + with pytest.raises(ValueError, match="Key axes.labelsize"): + params["axes.labelsize"] = object() diff --git a/ultraplot/tests/test_scale_helpers.py b/ultraplot/tests/test_scale_helpers.py new file mode 100644 index 000000000..d12e8f12d --- /dev/null +++ b/ultraplot/tests/test_scale_helpers.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Focused tests for scale and transform helpers. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import matplotlib.scale as mscale +import matplotlib.ticker as mticker + +import ultraplot as uplt +from ultraplot import scale as pscale +from ultraplot.internals.warnings import UltraPlotWarning + + +class DummyAxis: + axis_name = "x" + + def __init__(self) -> None: + self.isDefault_majloc = True + self.isDefault_minloc = True + self.isDefault_majfmt = True + self.isDefault_minfmt = True + self.major_locator = None + self.minor_locator = None + self.major_formatter = None + self.minor_formatter = None + + def set_major_locator(self, locator) -> None: + self.major_locator = locator + + def set_minor_locator(self, locator) -> None: + self.minor_locator = locator + + def set_major_formatter(self, formatter) -> None: + self.major_formatter = formatter + + def set_minor_formatter(self, formatter) -> None: + self.minor_formatter = formatter + + +def test_parse_logscale_args_applies_defaults_and_eps(): + kwargs = pscale._parse_logscale_args("subs", "linthresh", subs=None, linthresh=1) + + assert np.array_equal(kwargs["subs"], np.arange(1, 10)) + assert kwargs["linthresh"] > 1 + + +def test_scale_sets_default_locators_and_formatters(): + axis = DummyAxis() + scale = pscale.LinearScale() + + with uplt.rc.context({"xtick.minor.visible": False}): + scale.set_default_locators_and_formatters(axis) + + assert isinstance(axis.major_locator, mticker.AutoLocator) + assert isinstance(axis.minor_locator, mticker.NullLocator) + assert axis.major_formatter is not None + assert isinstance(axis.minor_formatter, mticker.NullFormatter) + + +def test_scale_respects_only_if_default(): + axis = DummyAxis() + axis.isDefault_majloc = False + axis.isDefault_minloc = False + axis.isDefault_majfmt = False + axis.isDefault_minfmt = False + sentinel = object() + axis.major_locator = sentinel + axis.minor_locator = sentinel + axis.major_formatter = sentinel + axis.minor_formatter = sentinel + + pscale.LinearScale().set_default_locators_and_formatters(axis, only_if_default=True) + + assert axis.major_locator is sentinel + assert axis.minor_locator is sentinel + assert axis.major_formatter is sentinel + assert axis.minor_formatter is sentinel + + +def test_func_transform_roundtrip_and_validation(): + transform = pscale.FuncTransform( + lambda values: values + 1, lambda values: values - 1 + ) + values = np.array([1.0, 2.0, 3.0]) + + assert np.allclose(transform.transform_non_affine(values), values + 1) + assert np.allclose(transform.inverted().transform_non_affine(values), values - 1) + + with pytest.raises(ValueError, match="must be functions"): + pscale.FuncTransform("bad", lambda values: values) + + +def test_func_scale_accepts_callable_tuple_and_scale_specs(): + direct = pscale.FuncScale(transform=lambda values: values + 2) + assert np.allclose(direct.get_transform().transform([1.0]), [3.0]) + + swapped = pscale.FuncScale( + transform=(lambda values: values * 2, lambda values: values / 2), + invert=True, + ) + assert np.allclose(swapped.get_transform().transform([4.0]), [2.0]) + + inherited = pscale.FuncScale(transform="inverse") + assert np.isclose(inherited.get_transform().transform([4.0])[0], 0.25) + + +def test_func_scale_rewrites_parent_scales_and_validates_inputs(): + cutoff_parent = pscale.CutoffScale(10, 2, 20) + func_scale = pscale.FuncScale( + transform=(lambda values: values + 1, lambda values: values - 1), + parent_scale=cutoff_parent, + ) + assert func_scale.get_transform() is not None + + symlog_parent = pscale.SymmetricalLogScale(linthresh=1) + transformed = pscale.FuncScale( + transform=(lambda values: values + 1, lambda values: values - 1), + parent_scale=symlog_parent, + ) + assert transformed.get_transform() is not None + + with pytest.raises(ValueError, match="Expected a function"): + pscale.FuncScale(transform="unknown-scale") + with pytest.raises(ValueError, match="Parent scale must be ScaleBase"): + pscale.FuncScale(transform=lambda values: values, parent_scale="bad") + with pytest.raises(TypeError, match="unexpected arguments"): + pscale.FuncScale(transform=lambda values: values, unexpected=True) + + +@pytest.mark.parametrize( + ("scale", "values", "expected"), + [ + (pscale.PowerScale(power=2), np.array([1.0, 2.0]), np.array([1.0, 4.0])), + (pscale.ExpScale(a=2, b=2, c=3), np.array([0.0, 1.0]), np.array([3.0, 12.0])), + (pscale.InverseScale(), np.array([2.0, 4.0]), np.array([0.5, 0.25])), + ], +) +def test_basic_scale_transforms(scale, values, expected): + assert np.allclose(scale.get_transform().transform(values), expected) + assert scale.get_transform().inverted() is not None + + +@pytest.mark.parametrize( + "scale", + [pscale.PowerScale(power=2), pscale.ExpScale(a=2, b=1, c=1), pscale.InverseScale()], +) +def test_positive_only_scales_limit_ranges(scale): + lo, hi = scale.limit_range_for_scale(-2, 5, np.nan) + assert lo > 0 + assert hi == 5 + + +def test_mercator_scale_validates_threshold_and_masks_invalid_values(): + with pytest.raises(ValueError, match="must be <= 90"): + pscale.MercatorLatitudeScale(thresh=90) + + transform = pscale.MercatorLatitudeScale(thresh=80).get_transform() + masked = transform.transform_non_affine(np.array([-95.0, -45.0, 0.0, 45.0, 95.0])) + assert np.ma.isMaskedArray(masked) + assert masked.mask[0] + assert masked.mask[-1] + assert np.allclose( + transform.inverted().transform_non_affine( + transform.transform_non_affine(np.array([0.0, 30.0])) + ), + [0.0, 30.0], + ) + + +def test_sine_scale_masks_invalid_values_and_roundtrips(): + transform = pscale.SineLatitudeScale().get_transform() + masked = transform.transform_non_affine(np.array([-95.0, -45.0, 0.0, 45.0, 95.0])) + assert np.ma.isMaskedArray(masked) + assert masked.mask[0] + assert masked.mask[-1] + assert np.allclose( + transform.inverted().transform_non_affine( + transform.transform_non_affine(np.array([-60.0, 30.0])) + ), + [-60.0, 30.0], + ) + + +def test_cutoff_transform_roundtrip_and_validation(): + transform = pscale.CutoffTransform([10, 20], [2, 1]) + values = np.array([0.0, 10.0, 15.0, 25.0]) + roundtrip = transform.inverted().transform_non_affine( + transform.transform_non_affine(values) + ) + assert np.allclose(roundtrip, values) + + with pytest.raises(ValueError, match="Got 2 but 1 scales"): + pscale.CutoffTransform([10, 20], [1]) + with pytest.raises(ValueError, match="non negative"): + pscale.CutoffTransform([10, 20], [-1, 1]) + with pytest.raises(ValueError, match="Final scale must be finite"): + pscale.CutoffTransform([10], [0]) + with pytest.raises(ValueError, match="monotonically increasing"): + pscale.CutoffTransform([20, 10], [1, 1]) + with pytest.raises(ValueError, match="zero_dists is required"): + pscale.CutoffTransform([10, 10], [0, 1]) + with pytest.raises(ValueError, match="disagree with discrete step locations"): + pscale.CutoffTransform([10, 10], [1, 1], zero_dists=[1]) + + +def test_scale_factory_handles_instances_mpl_scales_and_unknown_names(monkeypatch): + linear = pscale.LinearScale() + with pytest.warns(UltraPlotWarning, match="Ignoring args"): + assert pscale._scale_factory(linear, object(), 1, foo=2) is linear + + class DummyMplScale(mscale.ScaleBase): + name = "dummy_mpl" + + def __init__(self, axis, *args, **kwargs): + super().__init__(axis) + self.axis = axis + self.args = args + self.kwargs = kwargs + + def get_transform(self): + return pscale.LinearScale().get_transform() + + def set_default_locators_and_formatters(self, axis): + return None + + def limit_range_for_scale(self, vmin, vmax, minpos): + return vmin, vmax + + monkeypatch.setitem(mscale._scale_mapping, "dummy_mpl", DummyMplScale) + axis = object() + dummy = pscale._scale_factory("dummy_mpl", axis, 1, color="red") + assert isinstance(dummy, DummyMplScale) + assert dummy.axis is axis + assert dummy.args == (1,) + assert dummy.kwargs == {"color": "red"} + + with pytest.raises(ValueError, match="Unknown axis scale"): + pscale._scale_factory("unknown", axis) diff --git a/ultraplot/tests/test_text_helpers.py b/ultraplot/tests/test_text_helpers.py new file mode 100644 index 000000000..a2151a947 --- /dev/null +++ b/ultraplot/tests/test_text_helpers.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Focused tests for curved text helper behavior. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ultraplot as uplt +from ultraplot.text import CurvedText + + +def _make_curve(): + x = np.linspace(0, 1, 50) + y = np.sin(2 * np.pi * x) * 0.1 + 0.5 + return x, y + + +def test_curved_text_validates_inputs(): + fig, ax = uplt.subplots() + x, y = _make_curve() + + with pytest.raises(ValueError, match="'axes' is required"): + CurvedText(x, y, "text", None) + with pytest.raises(ValueError, match="same length"): + CurvedText(x, y[:-1], "text", ax) + with pytest.raises(ValueError, match="at least two points"): + CurvedText([0], [0], "text", ax) + + +def test_curved_text_curve_accessors_and_zorder(): + fig, ax = uplt.subplots() + x, y = _make_curve() + text = CurvedText(x, y, "abc", ax) + + curve_x, curve_y = text.get_curve() + curve_x[0] = -1 + curve_y[0] = -1 + check_x, check_y = text.get_curve() + assert check_x[0] != -1 + assert check_y[0] != -1 + + text.set_curve(x[::-1], y[::-1]) + new_x, new_y = text.get_curve() + assert np.array_equal(new_x, x[::-1]) + assert np.array_equal(new_y, y[::-1]) + + text.set_zorder(10) + assert all(artist.get_zorder() == 11 for _, artist in text._characters) + + with pytest.raises(ValueError, match="same length"): + text.set_curve(x, y[:-1]) + with pytest.raises(ValueError, match="at least two points"): + text.set_curve([0], [0]) + + +def test_curved_text_update_positions_handles_noninvertible_transform(monkeypatch): + fig, ax = uplt.subplots() + x, y = _make_curve() + text = CurvedText(x, y, "abc", ax) + + class BadTransform: + def inverted(self): + raise RuntimeError("no inverse") + + monkeypatch.setattr(text, "get_transform", lambda: BadTransform()) + renderer = fig.canvas.get_renderer() + text.update_positions(renderer) + + assert [artist.get_text() for _, artist in text._characters] == list("abc") + + +def test_curved_text_hides_zero_length_segments(): + fig, ax = uplt.subplots() + text = CurvedText([0, 0], [0, 0], "abc", ax) + fig.canvas.draw() + + assert all(artist.get_alpha() == 0.0 for _, artist in text._characters) + + +def test_curved_text_applies_label_properties(): + fig, ax = uplt.subplots() + x, y = _make_curve() + text = CurvedText(x, y, "abc", ax) + + text._apply_label_props({"color": "red", "fontweight": "bold"}) + + for _, artist in text._characters: + assert artist.get_color() == "red" + assert artist.get_fontweight() == "bold" + + +def test_curved_text_supports_ellipsis_and_text_updates(): + fig, ax = uplt.subplots() + x = np.linspace(0, 0.05, 20) + y = np.linspace(0, 0.05, 20) + text = CurvedText(x, y, "abcdefghij", ax, ellipsis=True) + fig.canvas.draw() + + visible = [artist for _, artist in text._characters if artist.get_alpha()] + assert visible + assert [artist.get_text() for artist in visible][-1] == "." + + text.set_text("xy") + fig.canvas.draw() + assert text.get_text() == "xy" + assert [artist.get_text() for _, artist in text._characters] == ["x", "y"] + + +def test_curved_text_reverses_curve_to_keep_text_upright(): + fig, ax = uplt.subplots() + x = np.linspace(1, 0, 50) + y = np.full_like(x, 0.5) + text = CurvedText(x, y, "abc", ax, upright=True) + fig.canvas.draw() + + rotations = [ + artist.get_rotation() for _, artist in text._characters if artist.get_alpha() + ] + assert rotations + assert all(-90 <= rotation <= 90 for rotation in rotations) + + +def test_curved_text_draw_is_noop_for_empty_character_list(): + fig, ax = uplt.subplots() + x, y = _make_curve() + text = CurvedText(x, y, "abc", ax) + text._characters = [] + + renderer = fig.canvas.get_renderer() + text.draw(renderer)