leanplot 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
leanplot/core/mixin.py ADDED
@@ -0,0 +1,187 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2026 Joel Kimmich
3
+ # ------------------------------------------------------------------------------
4
+ # License
5
+ # This file is part of LeanPlot, distributed under MIT license.
6
+ # ------------------------------------------------------------------------------
7
+ #
8
+ from datetime import datetime
9
+
10
+ import matplotlib.ticker as ticker
11
+ from matplotlib import pyplot as plt
12
+
13
+ from leanplot.core.colors.categorical import ColorPalette
14
+ from leanplot.core.functions import cm_to_inch, custom_numeric_ticks_formatter, format_date_x_axis
15
+
16
+
17
+ class PlotMixin(object):
18
+ def __init__(
19
+ self,
20
+ x,
21
+ ys,
22
+ y_title="Title / Unit",
23
+ marker_size=6,
24
+ x_title=None,
25
+ width_mm=160,
26
+ height_mm=90,
27
+ iter_color_and_style_together: bool = False,
28
+ iter_color_first: bool = True,
29
+ color_palette: ColorPalette | str | None = None,
30
+ ):
31
+ self.fig, self.ax = plt.subplots(layout="constrained")
32
+ self.fig.set_size_inches(cm_to_inch(width_mm / 10), cm_to_inch(height_mm / 10))
33
+
34
+ if color_palette is None:
35
+ color_palette = ColorPalette()
36
+ elif isinstance(color_palette, str):
37
+ color_palette = ColorPalette(color_palette)
38
+ else:
39
+ assert isinstance(color_palette, ColorPalette)
40
+ self.colors = color_palette.colors
41
+ self.styles = ["-", "--", ":", "-."]
42
+ self.markers = ["o", "s", "^", "v", "<", ">", "d", "p", "*", "h"]
43
+
44
+ self.x = x
45
+ self.ys = ys
46
+
47
+ if isinstance(self.x[0], datetime):
48
+ self.x_is_datetime = True
49
+ else:
50
+ self.x_is_datetime = False
51
+
52
+ self.y_title = y_title
53
+ self.x_title = x_title
54
+ self.marker_size = marker_size
55
+
56
+ self.lines = None
57
+ self.labels = []
58
+
59
+ self.iter_color_and_style_together = iter_color_and_style_together
60
+ self.iter_color_first = iter_color_first
61
+
62
+ self.next_color = self.color_iter()
63
+ self.next_line_style = self.line_style_iter()
64
+ self.next_marker = self.marker_iter()
65
+
66
+ def __del__(self):
67
+ try:
68
+ if self.fig is not None:
69
+ plt.close(self.fig)
70
+ except AttributeError:
71
+ pass
72
+
73
+ def marker_iter(self):
74
+ markers = self.markers
75
+ n_markers = len(markers)
76
+ n = len(self.colors)
77
+ i = 0
78
+ if self.iter_color_and_style_together or not self.iter_color_first:
79
+ while True:
80
+ yield markers[i]
81
+ i = (i + 1) % n_markers
82
+ else:
83
+ while True:
84
+ yield markers[i // n]
85
+ i += 1
86
+ if i >= n * n_markers:
87
+ i = 0
88
+
89
+ def line_style_iter(self):
90
+ n = len(self.colors)
91
+ styles = self.styles
92
+ n_styles = len(styles)
93
+ i = 0
94
+ if self.iter_color_and_style_together or not self.iter_color_first:
95
+ while True:
96
+ yield styles[i]
97
+ i = (i + 1) % n_styles
98
+ else:
99
+ while True:
100
+ yield styles[i // n]
101
+ i += 1
102
+ if i >= n * n_styles:
103
+ i = 0
104
+
105
+ def color_iter(self):
106
+ colors = self.colors
107
+ i = 0
108
+ n = len(colors)
109
+ n_styles = len(self.styles)
110
+ if self.iter_color_and_style_together or self.iter_color_first:
111
+ while True:
112
+ yield tuple(colors[i])
113
+ i = (i + 1) % n
114
+ else:
115
+ while True:
116
+ yield tuple(colors[i // n_styles])
117
+ i += 1
118
+ if i >= n_styles * n:
119
+ i = 0
120
+
121
+ def _add_line(self, line):
122
+ if self.lines is None:
123
+ self.lines = line
124
+ else:
125
+ self.lines = self.lines + line
126
+
127
+ def _add_line_and_label(self, line, label=None):
128
+ if self.lines is None:
129
+ self.lines = line
130
+ else:
131
+ self.lines = self.lines + line
132
+ self.labels.append(label)
133
+
134
+ def format(self):
135
+ if self.x_title is not None:
136
+ self.ax.set_xlabel(self.x_title)
137
+ if self.y_title is not None:
138
+ self.ax.set_ylabel(self.y_title)
139
+ self.ax.grid(True)
140
+ self.ax.set_xlim(left=self.x[0], right=self.x[-1])
141
+ if self.x_is_datetime:
142
+ self.formate_x_datetime()
143
+
144
+ def formate_x_datetime(self, start=None, end=None):
145
+ if start is None:
146
+ start = self.x[0]
147
+ if end is None:
148
+ end = self.x[-1]
149
+ format_date_x_axis(start, end, self.ax, return_version=False)
150
+
151
+ def format_numeric_ticks(self):
152
+ formatter = ticker.FuncFormatter(custom_numeric_ticks_formatter)
153
+
154
+ for ax in self.fig.get_axes():
155
+ # Check if y-axis has numeric data
156
+ try:
157
+ current_formatter = ax.yaxis.get_major_formatter()
158
+ if not isinstance(current_formatter, plt.matplotlib.dates.DateFormatter):
159
+ ax.yaxis.set_major_formatter(formatter)
160
+ except:
161
+ pass
162
+
163
+ # Check x-axis
164
+ if not self.x_is_datetime and not isinstance(self.x[0], str):
165
+ try:
166
+ current_formatter = ax.xaxis.get_major_formatter()
167
+ if not isinstance(current_formatter, plt.matplotlib.dates.DateFormatter):
168
+ ax.xaxis.set_major_formatter(formatter)
169
+ except:
170
+ pass
171
+
172
+ def show_legend(self, **kwargs):
173
+ initial_kwargs = {"loc": "outside upper right", "ncols": min(4, len(self.labels))}
174
+ initial_kwargs.update(kwargs)
175
+ self.fig.legend(handles=self.lines, labels=self.labels, **initial_kwargs)
176
+
177
+ def show(self):
178
+ self.format_numeric_ticks()
179
+ plt.show()
180
+
181
+ def save(self, path):
182
+ self.format_numeric_ticks()
183
+ self.fig.savefig(path, dpi=600)
184
+
185
+ def close(self):
186
+ if self.fig is not None:
187
+ plt.close(self.fig)
@@ -0,0 +1,8 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2026 Joel Kimmich
3
+ # ------------------------------------------------------------------------------
4
+ # License
5
+ # This file is part of LeanPlot, distributed under MIT license.
6
+ # ------------------------------------------------------------------------------
7
+ #
8
+
@@ -0,0 +1,64 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2026 Joel Kimmich
3
+ # ------------------------------------------------------------------------------
4
+ # License
5
+ # This file is part of LeanPlot, distributed under MIT license.
6
+ # ------------------------------------------------------------------------------
7
+ #
8
+ from unittest import TestCase
9
+
10
+ import cmcrameri.cm as cmc
11
+
12
+ from leanplot.core.colors.categorical import ColorPalette
13
+
14
+
15
+ class TestColorPalette(TestCase):
16
+ def test_default_initialization_uses_batlow_with_ten_colors(self):
17
+ """Verify that default parameters select the batlow colormap with ten colors."""
18
+ color_palette = ColorPalette()
19
+ self.assertEqual(color_palette.colormap_name, "oslo")
20
+ self.assertEqual(color_palette.number_of_colors, 5)
21
+
22
+ def test_colors_returns_requested_number_of_colors(self):
23
+ """Verify that the colors property returns exactly number_of_colors entries."""
24
+ color_palette = ColorPalette(colormap_name="batlow", number_of_colors=6)
25
+ self.assertEqual(len(color_palette.colors), 6)
26
+
27
+ def test_colors_returns_rgb_tuples_with_three_components(self):
28
+ """Verify that each returned color is a tuple of three components."""
29
+ color_palette = ColorPalette(colormap_name="batlow", number_of_colors=4)
30
+ for color in color_palette.colors:
31
+ self.assertEqual(len(color), 3)
32
+
33
+ def test_colors_components_are_within_valid_range(self):
34
+ """Verify that each color component lies within the closed interval [0, 1]."""
35
+ color_palette = ColorPalette(colormap_name="batlow", number_of_colors=10)
36
+ for color in color_palette.colors:
37
+ for component in color:
38
+ self.assertGreaterEqual(component, 0.0)
39
+ self.assertLessEqual(component, 1.0)
40
+
41
+ def test_colors_match_source_categorical_colormap(self):
42
+ """Verify that returned colors match the first entries of the underlying categorical colormap."""
43
+ color_palette = ColorPalette(colormap_name="oslo", number_of_colors=3)
44
+ expected_colors = [tuple(color[:3]) for color in cmc.osloS.colors[:3]]
45
+ self.assertEqual(color_palette.colors, expected_colors)
46
+
47
+ def test_invalid_colormap_name_raises_attribute_error(self):
48
+ """Verify that an unknown colormap name raises an AttributeError."""
49
+ color_palette = ColorPalette(colormap_name="not_a_real_colormap", number_of_colors=5)
50
+ with self.assertRaises(AttributeError):
51
+ _ = color_palette.colors
52
+
53
+ def test_number_of_colors_exceeding_available_raises_value_error(self):
54
+ """Verify that requesting more colors than available raises a ValueError."""
55
+ available_color_count = len(cmc.batlowS.colors)
56
+ color_palette = ColorPalette(colormap_name="batlow", number_of_colors=available_color_count + 1)
57
+ with self.assertRaises(ValueError):
58
+ _ = color_palette.colors
59
+
60
+ def test_number_of_colors_equal_to_available_does_not_raise(self):
61
+ """Verify that requesting exactly the available number of colors succeeds without error."""
62
+ available_color_count = len(cmc.batlowS.colors)
63
+ color_palette = ColorPalette(colormap_name="batlow", number_of_colors=available_color_count)
64
+ self.assertEqual(len(color_palette.colors), available_color_count)
@@ -0,0 +1,192 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2026 Joel Kimmich
3
+ # ------------------------------------------------------------------------------
4
+ # License
5
+ # This file is part of LeanPlot, distributed under MIT license.
6
+ # ------------------------------------------------------------------------------
7
+ #
8
+ from unittest import TestCase
9
+ from unittest.mock import MagicMock
10
+
11
+ import numpy as np
12
+
13
+ from leanplot.core.line import LinePlot, TimePlot
14
+
15
+
16
+ class TestLinePlot(TestCase):
17
+ def setUp(self):
18
+ """Create shared x values and y series used across most LinePlot tests."""
19
+ self.x_values = [0, 1, 2, 3, 4]
20
+ self.single_y_series = [1, 2, 3, 4, 5]
21
+
22
+ def tearDown(self):
23
+ """Close the figure created during each test to avoid lingering matplotlib state."""
24
+ if hasattr(self, "line_plot"):
25
+ self.line_plot.close()
26
+
27
+ def test_initialization_wraps_single_list_y_into_one_series(self):
28
+ """Verify that a flat list of y values is wrapped into a single-element series list."""
29
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
30
+ self.assertEqual(len(self.line_plot.ys), 1)
31
+ np.testing.assert_array_equal(self.line_plot.ys[0], np.array(self.single_y_series))
32
+
33
+ def test_initialization_wraps_single_one_dimensional_array_into_one_series(self):
34
+ """Verify that a one-dimensional ndarray of y values is wrapped into a single-element series list."""
35
+ self.line_plot = LinePlot(x=self.x_values, ys=np.array(self.single_y_series))
36
+ self.assertEqual(len(self.line_plot.ys), 1)
37
+ np.testing.assert_array_equal(self.line_plot.ys[0], np.array(self.single_y_series))
38
+
39
+ def test_initialization_keeps_list_of_arrays_as_multiple_series(self):
40
+ """Verify that a list containing multiple y series is kept as multiple series."""
41
+ multiple_y_series = [[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]]
42
+ self.line_plot = LinePlot(x=self.x_values, ys=multiple_y_series)
43
+ self.assertEqual(len(self.line_plot.ys), 2)
44
+
45
+ def test_initialization_keeps_two_dimensional_array_as_multiple_series(self):
46
+ """Verify that a two-dimensional ndarray with multiple rows is kept as multiple series."""
47
+ two_dimensional_y_series = np.array([[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]])
48
+ self.line_plot = LinePlot(x=self.x_values, ys=two_dimensional_y_series)
49
+ self.assertEqual(len(self.line_plot.ys), 2)
50
+
51
+ def test_initialization_default_labels_are_none_for_each_series(self):
52
+ """Verify that omitting labels results in a None label for every series."""
53
+ multiple_y_series = [[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]]
54
+ self.line_plot = LinePlot(x=self.x_values, ys=multiple_y_series)
55
+ self.assertEqual(self.line_plot.labels, [None, None])
56
+
57
+ def test_initialization_wraps_single_label_string_into_list(self):
58
+ """Verify that a single label string is wrapped into a one-element list."""
59
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series, labels="Series One")
60
+ self.assertEqual(self.line_plot.labels, ["Series One"])
61
+
62
+ def test_initialization_sets_y_scale_and_line_width_attributes(self):
63
+ """Verify that y_scale and linewidth constructor arguments are stored correctly."""
64
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series, y_scale=2.0, linewidth=3.5)
65
+ self.assertEqual(self.line_plot.y_scale, 2.0)
66
+ self.assertEqual(self.line_plot.line_width, 3.5)
67
+
68
+ def test_plot_adds_one_line_per_series(self):
69
+ """Verify that plot() adds exactly one line per y series."""
70
+ multiple_y_series = [[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]]
71
+ self.line_plot = LinePlot(x=self.x_values, ys=multiple_y_series)
72
+ self.line_plot.plot()
73
+ self.assertEqual(len(self.line_plot.lines), 2)
74
+
75
+ def test_plot_applies_y_scale_to_plotted_values(self):
76
+ """Verify that plot() multiplies the y values by y_scale before plotting."""
77
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series, y_scale=2.0)
78
+ self.line_plot.ax.plot = MagicMock(return_value=[MagicMock()])
79
+ self.line_plot.plot()
80
+ plotted_y_values = self.line_plot.ax.plot.call_args[0][1]
81
+ np.testing.assert_array_equal(plotted_y_values, np.array(self.single_y_series) * 2.0)
82
+
83
+ def test_plot_uses_instance_x_and_ys_when_not_provided(self):
84
+ """Verify that plot() defaults to self.x and self.ys when no arguments are given."""
85
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
86
+ self.line_plot.ax.plot = MagicMock(return_value=[MagicMock()])
87
+ self.line_plot.plot()
88
+ plotted_x_values = self.line_plot.ax.plot.call_args[0][0]
89
+ np.testing.assert_array_equal(plotted_x_values, np.array(self.x_values))
90
+
91
+ def test_plot_calls_format(self):
92
+ """Verify that plot() calls format() after adding the lines."""
93
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
94
+ self.line_plot.format = MagicMock()
95
+ self.line_plot.plot()
96
+ self.line_plot.format.assert_called_once()
97
+
98
+ def test_add_points_adds_one_line_per_point_group(self):
99
+ """Verify that add_points() adds one line per group of point values."""
100
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
101
+ point_y_values = [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]
102
+ self.line_plot.add_points(x=self.x_values, ys=point_y_values, labels=["A", "B"])
103
+ self.assertEqual(len(self.line_plot.lines), 2)
104
+ self.assertEqual(self.line_plot.labels, [None, "A", "B"])
105
+
106
+ def test_add_points_wraps_single_label_into_list(self):
107
+ """Verify that add_points() wraps a single label string into a one-element list."""
108
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
109
+ self.line_plot.add_points(x=self.x_values, ys=self.single_y_series, labels="Measurements")
110
+ self.assertEqual(self.line_plot.labels, [None, "Measurements"])
111
+
112
+ def test_add_points_raises_assertion_error_on_label_count_mismatch(self):
113
+ """Verify that add_points() raises an AssertionError when labels and ys lengths differ."""
114
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
115
+ point_y_values = [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]
116
+ with self.assertRaises(AssertionError):
117
+ self.line_plot.add_points(x=self.x_values, ys=point_y_values, labels=["A"])
118
+
119
+ def test_add_points_does_not_apply_y_scale(self):
120
+ """Verify that add_points() plots the raw y values without applying y_scale."""
121
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series, y_scale=2.0)
122
+ self.line_plot.ax.plot = MagicMock(return_value=[MagicMock()])
123
+ self.line_plot.add_points(x=self.x_values, ys=self.single_y_series)
124
+ plotted_y_values = self.line_plot.ax.plot.call_args[0][1]
125
+ np.testing.assert_array_equal(plotted_y_values, np.array(self.single_y_series))
126
+
127
+ def test_add_points_calls_format(self):
128
+ """Verify that add_points() calls format() after adding the point lines."""
129
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
130
+ self.line_plot.format = MagicMock()
131
+ self.line_plot.add_points(x=self.x_values, ys=self.single_y_series)
132
+ self.line_plot.format.assert_called_once()
133
+
134
+ def test_plot_marker_adds_one_plot_call_per_series(self):
135
+ """Verify that plot_marker() issues one ax.plot call per y series with a marker set."""
136
+ multiple_y_series = [[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]]
137
+ self.line_plot = LinePlot(x=self.x_values, ys=multiple_y_series)
138
+ self.line_plot.ax.plot = MagicMock(return_value=[MagicMock()])
139
+ self.line_plot.plot_marker()
140
+ self.assertEqual(self.line_plot.ax.plot.call_count, 2)
141
+ for call in self.line_plot.ax.plot.call_args_list:
142
+ self.assertIn("marker", call.kwargs)
143
+
144
+ def test_plot_marker_calls_format(self):
145
+ """Verify that plot_marker() calls format() after plotting the markers."""
146
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
147
+ self.line_plot.format = MagicMock()
148
+ self.line_plot.plot_marker()
149
+ self.line_plot.format.assert_called_once()
150
+
151
+ def test_plot_stack_calls_stackplot_with_labels_and_colors(self):
152
+ """Verify that plot_stack() calls ax.stackplot with the instance labels and colors."""
153
+ multiple_y_series = [[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]]
154
+ self.line_plot = LinePlot(x=self.x_values, ys=multiple_y_series, labels=["A", "B"])
155
+ self.line_plot.ax.stackplot = MagicMock()
156
+ self.line_plot.plot_stack()
157
+ _, call_kwargs = self.line_plot.ax.stackplot.call_args
158
+ self.assertEqual(call_kwargs["labels"], ["A", "B"])
159
+ self.assertEqual(call_kwargs["colors"], self.line_plot.colors)
160
+
161
+ def test_plot_stack_calls_format(self):
162
+ """Verify that plot_stack() calls format() after stacking the series."""
163
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
164
+ self.line_plot.format = MagicMock()
165
+ self.line_plot.plot_stack()
166
+ self.line_plot.format.assert_called_once()
167
+
168
+ def test_format_calls_format_numeric_ticks(self):
169
+ """Verify that format() calls format_numeric_ticks() in addition to the base format logic."""
170
+ self.line_plot = LinePlot(x=self.x_values, ys=self.single_y_series)
171
+ self.line_plot.format_numeric_ticks = MagicMock()
172
+ self.line_plot.format()
173
+ self.line_plot.format_numeric_ticks.assert_called_once()
174
+
175
+
176
+ class TestTimePlot(TestCase):
177
+ def setUp(self):
178
+ """Create shared x and y values used across TimePlot tests."""
179
+ self.x_values = [0, 1, 2, 3, 4]
180
+ self.y_values = [1, 2, 3, 4, 5]
181
+
182
+ def tearDown(self):
183
+ """Close the figure created during each test to avoid lingering matplotlib state."""
184
+ self.time_plot.close()
185
+
186
+ def test_format_calls_formate_x_datetime_and_format_numeric_ticks(self):
187
+ """Verify that format() calls both formate_x_datetime() and format_numeric_ticks()."""
188
+ self.time_plot = TimePlot(x=self.x_values, ys=self.y_values)
189
+ self.time_plot.formate_x_datetime = MagicMock()
190
+ self.time_plot.format_numeric_ticks = MagicMock()
191
+ self.time_plot.format()
192
+ self.time_plot.formate_x_datetime.assert_called_once()
@@ -0,0 +1,208 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2026 Joel Kimmich
3
+ # ------------------------------------------------------------------------------
4
+ # License
5
+ # This file is part of LeanPlot, distributed under MIT license.
6
+ # ------------------------------------------------------------------------------
7
+ #
8
+
9
+ from datetime import datetime
10
+ from unittest import TestCase
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ from leanplot.core.colors.categorical import ColorPalette
14
+ from leanplot.core.mixin import PlotMixin
15
+
16
+
17
+ class TestPlotMixin(TestCase):
18
+ def setUp(self):
19
+ """Create a default numeric PlotMixin instance shared by most tests."""
20
+ self.numeric_x_values = [1, 2, 3, 4, 5]
21
+ self.y_series = [[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]]
22
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series)
23
+
24
+ def tearDown(self):
25
+ """Close the figure created during each test to avoid lingering matplotlib state."""
26
+ self.plot_mixin.close()
27
+
28
+ def test_initialization_sets_default_color_palette_when_none_provided(self):
29
+ """Verify that omitting color_palette falls back to the default ColorPalette colors."""
30
+ expected_colors = ColorPalette().colors
31
+ self.assertEqual(self.plot_mixin.colors, expected_colors)
32
+
33
+ def test_initialization_uses_provided_color_palette(self):
34
+ """Verify that a custom ColorPalette instance is applied instead of the default one."""
35
+ custom_color_palette = ColorPalette(colormap_name="buda", number_of_colors=3)
36
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, color_palette=custom_color_palette)
37
+ self.assertEqual(self.plot_mixin.colors, custom_color_palette.colors)
38
+
39
+ def test_initialization_creates_color_palette_from_string_colormap_name(self):
40
+ """Verify that passing a colormap name as a string creates a matching ColorPalette."""
41
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, color_palette="hawaii")
42
+ expected_colors = ColorPalette("hawaii").colors
43
+ self.assertEqual(self.plot_mixin.colors, expected_colors)
44
+
45
+ def test_initialization_raises_assertion_error_for_invalid_color_palette_type(self):
46
+ """Verify that passing a color_palette that is neither None, a string, nor a ColorPalette raises an error."""
47
+ with self.assertRaises(AssertionError):
48
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, color_palette=42)
49
+
50
+ def test_initialization_detects_datetime_x_axis(self):
51
+ """Verify that x_is_datetime is True when the x values are datetime objects."""
52
+ datetime_x_values = [datetime(2026, 1, 1), datetime(2026, 1, 2), datetime(2026, 1, 3)]
53
+ self.plot_mixin = PlotMixin(x=datetime_x_values, ys=self.y_series)
54
+ self.assertTrue(self.plot_mixin.x_is_datetime)
55
+
56
+ def test_initialization_detects_non_datetime_x_axis(self):
57
+ """Verify that x_is_datetime is False when the x values are numeric."""
58
+ self.assertFalse(self.plot_mixin.x_is_datetime)
59
+
60
+ def test_markers_property_returns_expected_marker_list(self):
61
+ """Verify that the markers property returns the fixed list of marker symbols."""
62
+ expected_markers = ["o", "s", "^", "v", "<", ">", "d", "p", "*", "h"]
63
+ self.assertEqual(self.plot_mixin.markers, expected_markers)
64
+
65
+ def test_marker_iter_cycles_back_to_first_marker(self):
66
+ """Verify that marker_iter wraps around to the first marker after exhausting the list."""
67
+
68
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, iter_color_first=False)
69
+ markers = self.plot_mixin.markers
70
+ marker_generator = self.plot_mixin.marker_iter()
71
+ produced_markers = [next(marker_generator) for _ in range(len(markers) + 1)]
72
+ self.assertEqual(produced_markers[0], produced_markers[-1])
73
+ self.assertEqual(produced_markers[:-1], markers)
74
+
75
+ def test_color_iter_cycles_back_to_first_color(self):
76
+ """Verify that color_iter wraps around to the first color after exhausting the palette."""
77
+ colors = self.plot_mixin.colors
78
+ color_generator = self.plot_mixin.color_iter()
79
+ produced_colors = [next(color_generator) for _ in range(len(colors) + 1)]
80
+ self.assertEqual(produced_colors[0], produced_colors[-1])
81
+ self.assertEqual(produced_colors[:-1], [tuple(color) for color in colors])
82
+
83
+ def test_line_style_iter_starts_with_solid_style(self):
84
+ """Verify that line_style_iter begins with the solid line style for the first color."""
85
+ line_style_generator = self.plot_mixin.line_style_iter()
86
+ first_style = next(line_style_generator)
87
+ self.assertEqual(first_style, "-")
88
+
89
+ def test_line_style_iter_switches_style_after_one_full_color_cycle(self):
90
+ """Verify that line_style_iter switches to the next style after cycling through all colors."""
91
+ number_of_colors = len(self.plot_mixin.colors)
92
+ line_style_generator = self.plot_mixin.line_style_iter()
93
+ produced_styles = [next(line_style_generator) for _ in range(number_of_colors + 1)]
94
+ self.assertEqual(produced_styles[:number_of_colors], ["-"] * number_of_colors)
95
+ self.assertEqual(produced_styles[number_of_colors], "--")
96
+
97
+ def test_add_line_initializes_lines_when_empty(self):
98
+ """Verify that _add_line sets self.lines when no lines have been added yet."""
99
+ plotted_line = self.plot_mixin.ax.plot(self.numeric_x_values, self.y_series[0])
100
+ self.plot_mixin._add_line(plotted_line)
101
+ self.assertEqual(self.plot_mixin.lines, plotted_line)
102
+
103
+ def test_add_line_appends_to_existing_lines(self):
104
+ """Verify that _add_line concatenates new lines onto previously added lines."""
105
+ first_line = self.plot_mixin.ax.plot(self.numeric_x_values, self.y_series[0])
106
+ second_line = self.plot_mixin.ax.plot(self.numeric_x_values, self.y_series[1])
107
+ self.plot_mixin._add_line(first_line)
108
+ self.plot_mixin._add_line(second_line)
109
+ self.assertEqual(self.plot_mixin.lines, first_line + second_line)
110
+
111
+ def test_add_line_and_label_appends_label(self):
112
+ """Verify that _add_line_and_label appends the given label to self.labels."""
113
+ plotted_line = self.plot_mixin.ax.plot(self.numeric_x_values, self.y_series[0])
114
+ self.plot_mixin._add_line_and_label(plotted_line, label="Series One")
115
+ self.assertEqual(self.plot_mixin.labels, ["Series One"])
116
+
117
+ def test_format_sets_axis_labels_when_titles_provided(self):
118
+ """Verify that format() applies x_title and y_title to the axis labels."""
119
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, x_title="Time / s", y_title="Value / "
120
+ "unit")
121
+ self.plot_mixin.format()
122
+ self.assertEqual(self.plot_mixin.ax.get_xlabel(), "Time / s")
123
+ self.assertEqual(self.plot_mixin.ax.get_ylabel(), "Value / unit")
124
+
125
+ def test_format_sets_x_limits_to_first_and_last_x_value(self):
126
+ """Verify that format() sets the x axis limits to the first and last x values."""
127
+ self.plot_mixin.format()
128
+ left_limit, right_limit = self.plot_mixin.ax.get_xlim()
129
+ self.assertEqual(left_limit, self.numeric_x_values[0])
130
+ self.assertEqual(right_limit, self.numeric_x_values[-1])
131
+
132
+ def test_format_calls_datetime_formatting_when_x_is_datetime(self):
133
+ """Verify that format() invokes formate_x_datetime when x values are datetime objects."""
134
+ datetime_x_values = [datetime(2026, 1, 1), datetime(2026, 1, 2), datetime(2026, 1, 3)]
135
+ self.plot_mixin = PlotMixin(x=datetime_x_values, ys=self.y_series)
136
+ self.plot_mixin.formate_x_datetime = MagicMock()
137
+ self.plot_mixin.format()
138
+ self.plot_mixin.formate_x_datetime.assert_called_once()
139
+
140
+ def test_show_legend_uses_default_number_of_columns(self):
141
+ """Verify that show_legend caps the column count at four when more labels are present."""
142
+ for index in range(5):
143
+ plotted_line = self.plot_mixin.ax.plot(self.numeric_x_values, self.y_series[0])
144
+ self.plot_mixin._add_line_and_label(plotted_line, label=f"Series {index}")
145
+ self.plot_mixin.fig.legend = MagicMock()
146
+ self.plot_mixin.show_legend()
147
+ _, call_kwargs = self.plot_mixin.fig.legend.call_args
148
+ self.assertEqual(call_kwargs["ncols"], 4)
149
+
150
+ def test_show_legend_overrides_default_kwargs(self):
151
+ """Verify that explicit keyword arguments override the default show_legend settings."""
152
+ plotted_line = self.plot_mixin.ax.plot(self.numeric_x_values, self.y_series[0])
153
+ self.plot_mixin._add_line_and_label(plotted_line, label="Series One")
154
+ self.plot_mixin.fig.legend = MagicMock()
155
+ self.plot_mixin.show_legend(loc="lower left")
156
+ _, call_kwargs = self.plot_mixin.fig.legend.call_args
157
+ self.assertEqual(call_kwargs["loc"], "lower left")
158
+
159
+ def test_save_calls_savefig_with_given_path_and_dpi(self):
160
+ """Verify that save() calls fig.savefig with the given path and a dpi of 600."""
161
+ self.plot_mixin.fig.savefig = MagicMock()
162
+ self.plot_mixin.save("output.png")
163
+ self.plot_mixin.fig.savefig.assert_called_once_with("output.png", dpi=600)
164
+
165
+ @patch("matplotlib.pyplot.close")
166
+ def test_close_closes_the_figure(self, mock_plt_close):
167
+ """Verify that close() calls plt.close on the mixin's figure."""
168
+ figure_reference = self.plot_mixin.fig
169
+ self.plot_mixin.close()
170
+ mock_plt_close.assert_called_once_with(figure_reference)
171
+
172
+ def test_color_iter_advances_every_call_when_iter_color_and_style_together(self):
173
+ """Verify that color_iter cycles through every color on each call when colors and styles iterate together."""
174
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, iter_color_and_style_together=True)
175
+ colors = self.plot_mixin.colors
176
+ color_generator = self.plot_mixin.color_iter()
177
+ produced_colors = [next(color_generator) for _ in range(len(colors) + 1)]
178
+ self.assertEqual(produced_colors[:-1], [tuple(color) for color in colors])
179
+ self.assertEqual(produced_colors[0], produced_colors[-1])
180
+
181
+ def test_line_style_iter_advances_every_call_when_iter_color_and_style_together(self):
182
+ """Verify that line_style_iter cycles through every style on each call when colors and styles iterate together."""
183
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, iter_color_and_style_together=True)
184
+ styles = self.plot_mixin.styles
185
+ line_style_generator = self.plot_mixin.line_style_iter()
186
+ produced_styles = [next(line_style_generator) for _ in range(len(styles) + 1)]
187
+ self.assertEqual(produced_styles[:-1], styles)
188
+ self.assertEqual(produced_styles[0], produced_styles[-1])
189
+
190
+ def test_line_style_iter_advances_every_call_when_iter_color_first_is_false(self):
191
+ """Verify that line_style_iter cycles through every style on each call when styles are iterated first."""
192
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, iter_color_first=False)
193
+ styles = self.plot_mixin.styles
194
+ line_style_generator = self.plot_mixin.line_style_iter()
195
+ produced_styles = [next(line_style_generator) for _ in range(len(styles) + 1)]
196
+ self.assertEqual(produced_styles[:-1], styles)
197
+ self.assertEqual(produced_styles[0], produced_styles[-1])
198
+
199
+ def test_color_iter_switches_color_after_one_full_style_cycle_when_iter_color_first_is_false(self):
200
+ """Verify that color_iter switches to the next color only after cycling through all styles."""
201
+ self.plot_mixin = PlotMixin(x=self.numeric_x_values, ys=self.y_series, iter_color_first=False)
202
+ number_of_styles = len(self.plot_mixin.styles)
203
+ color_generator = self.plot_mixin.color_iter()
204
+ first_color = self.plot_mixin.colors[0]
205
+ second_color = self.plot_mixin.colors[1]
206
+ produced_colors = [next(color_generator) for _ in range(number_of_styles + 1)]
207
+ self.assertEqual(produced_colors[:number_of_styles], [tuple(first_color)] * number_of_styles)
208
+ self.assertEqual(produced_colors[number_of_styles], tuple(second_color))