1#-----------------------------------------------------------------------------
2# Copyright (c) 2012 - 2021, Anaconda, Inc., and Bokeh Contributors.
3# All rights reserved.
4#
5# The full license is in the file LICENSE.txt, distributed with this software.
6#-----------------------------------------------------------------------------
7
8#-----------------------------------------------------------------------------
9# Boilerplate
10#-----------------------------------------------------------------------------
11import pytest ; pytest
12
13#-----------------------------------------------------------------------------
14# Imports
15#-----------------------------------------------------------------------------
16
17# External imports
18import mock
19from mock import patch
20
21# Bokeh imports
22from bokeh.core.validation import check_integrity
23from bokeh.models import (
24    CategoricalScale,
25    CustomJS,
26    DataRange1d,
27    FactorRange,
28    GlyphRenderer,
29    Label,
30    LinearAxis,
31    LinearScale,
32    LogScale,
33    PanTool,
34    Plot,
35    Range1d,
36)
37from bokeh.plotting import figure
38
39# Module under test
40import bokeh.models.plots as bmp # isort:skip
41
42#-----------------------------------------------------------------------------
43# Setup
44#-----------------------------------------------------------------------------
45
46_LEGEND_EMPTY_WARNING = """
47You are attempting to set `plot.legend.location` on a plot that has zero legends added, this will have no effect.
48
49Before legend properties can be set, you must add a Legend explicitly, or call a glyph method with a legend parameter set.
50"""
51
52#-----------------------------------------------------------------------------
53# General API
54#-----------------------------------------------------------------------------
55
56
57class TestPlotLegendProperty:
58    def test_basic(self) -> None:
59        plot = figure(tools='')
60        x = plot.legend
61        assert isinstance(x, bmp._list_attr_splat)
62        assert len(x) == 0
63        plot.circle([1,2], [3,4], legend_label="foo")
64        x = plot.legend
65        assert isinstance(x, bmp._list_attr_splat)
66        assert len(x) == 1
67
68    def test_warnign(self) -> None:
69        plot = figure(tools='')
70        with pytest.warns(UserWarning) as warns:
71            plot.legend.location = "above"
72            assert len(warns) == 1
73            assert warns[0].message.args[0] == _LEGEND_EMPTY_WARNING
74
75
76class TestPlotSelect:
77    def setup_method(self):
78        self._plot = figure(tools='pan')
79        self._plot.circle([1,2,3], [3,2,1], name='foo')
80
81    @patch('bokeh.models.plots.find')
82    def test_string_arg(self, mock_find) -> None:
83        self._plot.select('foo')
84        assert mock_find.called
85        assert mock_find.call_args[0][1] == dict(name='foo')
86
87    @patch('bokeh.models.plots.find')
88    def test_type_arg(self, mock_find) -> None:
89        self._plot.select(PanTool)
90        assert mock_find.called
91        assert mock_find.call_args[0][1] == dict(type=PanTool)
92
93    @patch('bokeh.models.plots.find')
94    def test_kwargs(self, mock_find) -> None:
95        kw = dict(name='foo', type=GlyphRenderer)
96        self._plot.select(**kw)
97        assert mock_find.called
98        assert mock_find.call_args[0][1] == kw
99
100    @patch('bokeh.models.plots.find')
101    def test_single_selector_kwarg(self, mock_find) -> None:
102        kw = dict(name='foo', type=GlyphRenderer)
103        self._plot.select(selector=kw)
104        assert mock_find.called
105        assert mock_find.call_args[0][1] == kw
106
107    def test_selector_kwarg_and_extra_kwargs(self) -> None:
108        with pytest.raises(TypeError) as exc:
109            self._plot.select(selector=dict(foo='foo'), bar='bar')
110        assert "when passing 'selector' keyword arg, not other keyword args may be present" == str(exc.value)
111
112    def test_bad_arg_type(self) -> None:
113        with pytest.raises(TypeError) as exc:
114            self._plot.select(10)
115        assert "selector must be a dictionary, string or plot object." == str(exc.value)
116
117    def test_too_many_args(self) -> None:
118        with pytest.raises(TypeError) as exc:
119            self._plot.select('foo', 'bar')
120        assert 'select accepts at most ONE positional argument.' == str(exc.value)
121
122    def test_no_input(self) -> None:
123        with pytest.raises(TypeError) as exc:
124            self._plot.select()
125        assert 'select requires EITHER a positional argument, OR keyword arguments.' == str(exc.value)
126
127    def test_arg_and_kwarg(self) -> None:
128        with pytest.raises(TypeError) as exc:
129            self._plot.select('foo', type=PanTool)
130        assert 'select accepts EITHER a positional argument, OR keyword arguments (not both).' == str(exc.value)
131
132
133class TestPlotValidation:
134    def test_missing_renderers(self) -> None:
135        p = figure()
136        p.renderers = []
137        with mock.patch('bokeh.core.validation.check.log') as mock_logger:
138            check_integrity([p])
139        assert mock_logger.warning.call_count == 1
140        assert mock_logger.warning.call_args[0][0].startswith("W-1000 (MISSING_RENDERERS): Plot has no renderers")
141
142    def test_missing_scale(self) -> None:
143        p = figure()
144
145        with pytest.raises(ValueError):
146            p.x_scale = None
147
148        with pytest.raises(ValueError):
149            p.y_scale = None
150
151    def test_missing_range(self) -> None:
152        p = figure()
153
154        with pytest.raises(ValueError):
155            p.x_range = None
156
157        with pytest.raises(ValueError):
158            p.y_range = None
159
160    def test_bad_extra_range_name(self) -> None:
161        p = figure()
162        p.xaxis.x_range_name="junk"
163        with mock.patch('bokeh.core.validation.check.log') as mock_logger:
164            check_integrity([p])
165        assert mock_logger.error.call_count == 1
166        assert mock_logger.error.call_args[0][0].startswith(
167            "E-1020 (BAD_EXTRA_RANGE_NAME): An extra range name is configued with a name that does not correspond to any range: x_range_name='junk' [LinearAxis"
168        )
169
170        p = figure()
171        p.extra_x_ranges['foo'] = Range1d()
172        p.grid.x_range_name="junk"
173        with mock.patch('bokeh.core.validation.check.log') as mock_logger:
174            check_integrity([p])
175        assert mock_logger.error.call_count == 1
176        assert mock_logger.error.call_args[0][0].startswith(
177            "E-1020 (BAD_EXTRA_RANGE_NAME): An extra range name is configued with a name that does not correspond to any range: x_range_name='junk' [Grid"
178        )
179        assert mock_logger.error.call_args[0][0].count("Grid") == 2
180
181    def test_bad_extra_range_only_immediate_refs(self) -> None:
182        # test whether adding a figure (*and* it's extra ranges)
183        # to another's references doesn't create a false positive
184        p, dep = figure(), figure()
185        dep.extra_x_ranges['foo'] = Range1d()
186        dep.grid.x_range_name="foo"
187        p.grid[0].js_on_change("dimension", CustomJS(code = "", args = {"toto": dep.grid[0]}))
188        with mock.patch('bokeh.core.validation.check.log') as mock_logger:
189            check_integrity([p])
190        assert mock_logger.error.call_count == 0
191
192def test_plot_add_layout_raises_error_if_not_render() -> None:
193    plot = figure()
194    with pytest.raises(ValueError):
195        plot.add_layout(Range1d())
196
197
198def test_plot_add_layout_adds_label_to_plot_renderers() -> None:
199    plot = figure()
200    label = Label()
201    plot.add_layout(label)
202    assert label in plot.center
203
204
205def test_plot_add_layout_adds_axis_to_renderers_and_side_renderers() -> None:
206    plot = figure()
207    axis = LinearAxis()
208    plot.add_layout(axis, 'left')
209    assert axis in plot.left
210
211
212def test_sizing_mode_property_is_fixed_by_default() -> None:
213    plot = figure()
214    assert plot.sizing_mode is None
215
216
217class BaseTwinAxis:
218    """Base class for testing extra ranges"""
219
220    def verify_axis(self, axis_name):
221        plot = Plot()
222        range_obj = getattr(plot, f"extra_{axis_name}_ranges")
223        range_obj["foo_range"] = self.get_range_instance()
224        assert range_obj["foo_range"]
225
226    def test_x_range(self) -> None:
227        self.verify_axis('x')
228
229    def test_y_range(self) -> None:
230        self.verify_axis('y')
231
232    @staticmethod
233    def get_range_instance():
234        raise NotImplementedError
235
236
237class TestCategoricalTwinAxis(BaseTwinAxis):
238    """Test whether extra x and y ranges can be categorical"""
239
240    @staticmethod
241    def get_range_instance():
242        return FactorRange('foo', 'bar')
243
244
245class TestLinearTwinAxis(BaseTwinAxis):
246    """Test whether extra x and y ranges can be Range1d"""
247
248    @staticmethod
249    def get_range_instance():
250        return Range1d(0, 42)
251
252
253def test_plot_with_no_title_specified_creates_an_empty_title() -> None:
254    plot = Plot()
255    assert plot.title.text == ""
256
257
258def test_plot__scale_classmethod() -> None:
259    assert isinstance(Plot._scale("auto"), LinearScale)
260    assert isinstance(Plot._scale("linear"), LinearScale)
261    assert isinstance(Plot._scale("log"), LogScale)
262    assert isinstance(Plot._scale("categorical"), CategoricalScale)
263    with pytest.raises(ValueError):
264        Plot._scale("malformed_type")
265
266
267def test__check_required_scale_has_scales() -> None:
268    plot = Plot()
269    check = plot._check_required_scale()
270    assert check == []
271
272
273def test__check_required_scale_missing_scales() -> None:
274    with pytest.raises(ValueError):
275        Plot(x_scale=None, y_scale=None)
276
277
278def test__check_compatible_scale_and_ranges_compat_numeric() -> None:
279    plot = Plot(x_scale=LinearScale(), x_range=Range1d())
280    check = plot._check_compatible_scale_and_ranges()
281    assert check == []
282
283    plot = Plot(y_scale=LogScale(), y_range=DataRange1d())
284    check = plot._check_compatible_scale_and_ranges()
285    assert check == []
286
287
288def test__check_compatible_scale_and_ranges_compat_factor() -> None:
289    plot = Plot(x_scale=CategoricalScale(), x_range=FactorRange())
290    check = plot._check_compatible_scale_and_ranges()
291    assert check == []
292
293
294def test__check_compatible_scale_and_ranges_incompat_numeric_scale_and_factor_range() -> None:
295    plot = Plot(x_scale=LinearScale(), x_range=FactorRange())
296    check = plot._check_compatible_scale_and_ranges()
297    assert check != []
298
299
300def test__check_compatible_scale_and_ranges_incompat_factor_scale_and_numeric_range() -> None:
301    plot = Plot(x_scale=CategoricalScale(), x_range=DataRange1d())
302    check = plot._check_compatible_scale_and_ranges()
303    assert check != []
304
305#-----------------------------------------------------------------------------
306# Dev API
307#-----------------------------------------------------------------------------
308
309#-----------------------------------------------------------------------------
310# Private API
311#-----------------------------------------------------------------------------
312
313
314class Test_list_attr_splat:
315    def test_set(self) -> None:
316        obj = bmp._list_attr_splat([DataRange1d(), DataRange1d()])
317        assert len(obj) == 2
318        assert obj[0].start == None
319        assert obj[1].start == None
320        obj.start = 10
321        assert obj[0].start == 10
322        assert obj[1].start == 10
323
324    def test_set_empty(self) -> None:
325        obj = bmp._list_attr_splat([])
326        assert len(obj) == 0
327        obj.start = 10
328        assert len(obj) == 0
329
330    def test_get_set_single(self) -> None:
331        p = figure()
332        assert len(p.xaxis) == 1
333
334        # check both ways to access
335        assert p.xaxis.formatter.power_limit_low != 100
336        assert p.xaxis[0].formatter.power_limit_low != 100
337
338        p.axis.formatter.power_limit_low = 100
339
340        assert p.xaxis.formatter.power_limit_low == 100
341        assert p.xaxis[0].formatter.power_limit_low == 100
342
343    def test_get_set_multi(self) -> None:
344        p = figure()
345        assert len(p.axis) == 2
346
347        # check both ways to access
348        assert p.axis[0].formatter.power_limit_low != 100
349        assert p.axis[1].formatter.power_limit_low != 100
350        assert p.axis.formatter[0].power_limit_low != 100
351        assert p.axis.formatter[1].power_limit_low != 100
352
353        p.axis.formatter.power_limit_low = 100
354
355        assert p.axis[0].formatter.power_limit_low == 100
356        assert p.axis[1].formatter.power_limit_low == 100
357        assert p.axis.formatter[0].power_limit_low == 100
358        assert p.axis.formatter[1].power_limit_low == 100
359
360    def test_get_set_multi_mismatch(self) -> None:
361        obj = bmp._list_attr_splat([LinearAxis(), FactorRange()])
362        with pytest.raises(AttributeError) as e:
363            obj.formatter.power_limit_low == 10
364        assert str(e.value).endswith("list items have no %r attribute" % "formatter")
365
366    def test_get_empty(self) -> None:
367        obj = bmp._list_attr_splat([])
368        with pytest.raises(AttributeError) as e:
369            obj.start
370        assert str(e.value).endswith("Trying to access %r attribute on an empty 'splattable' list" % "start")
371
372    def test_get_index(self) -> None:
373        obj = bmp._list_attr_splat([1, 2, 3])
374        assert obj.index(2) == 1
375
376    def test_pop_value(self) -> None:
377        obj = bmp._list_attr_splat([1, 2, 3])
378        obj.pop(1)
379        assert obj == [1, 3]
380
381#-----------------------------------------------------------------------------
382# Code
383#-----------------------------------------------------------------------------
384