# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # The MIT License (MIT) # # Copyright (c) 2020 Kevin J. Walters # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import sys import time import array import os import unittest from unittest.mock import Mock, MagicMock, patch import numpy verbose = int(os.getenv('TESTVERBOSE', '2')) # Mocking libraries which are about to be import'd by Plotter sys.modules['board'] = MagicMock() sys.modules['displayio'] = MagicMock() sys.modules['terminalio'] = MagicMock() sys.modules['adafruit_display_text.label'] = MagicMock() # Replicate CircuitPython's time.monotonic_ns() pre 3.5 if not hasattr(time, "monotonic_ns"): time.monotonic_ns = lambda: int(time.monotonic() * 1e9) # Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # pylint: disable=wrong-import-position # import what we are testing from plotter import Plotter import terminalio # mocked terminalio.FONT = Mock() terminalio.FONT.get_bounding_box = Mock(return_value=(6, 14)) # TODO use setup() and tearDown() # - https://docs.python.org/3/library/unittest.html#unittest.TestCase.tearDown # pylint: disable=protected-access, no-self-use, too-many-locals class Test_Plotter(unittest.TestCase): """Tests for Plotter. Very useful but code needs a good tidy particulary around widths, lots of 200 hard-coded numbers. Would benefit from testing different widths too.""" # These were the original dimensions of the Bitmap # Current clue-plotter uses 192 for width and # scrolling is set to 50 _PLOT_WIDTH = 200 _PLOT_HEIGHT = 201 _SCROLL_PX = 25 def count_nz_rows(self, bitmap): nz_rows = [] for y_pos in range(self._PLOT_HEIGHT): count = 0 for x_pos in range(self._PLOT_WIDTH): if bitmap[x_pos, y_pos] != 0: count += 1 if count > 0: nz_rows.append(y_pos) return nz_rows def aprint_plot(self, bitmap): for y in range(self._PLOT_HEIGHT): for x in range(self._PLOT_WIDTH): print("X" if bitmap[x][y] else " ", end="") print() def make_a_Plotter(self, style, mode, scale_mode=None): mocked_display = Mock() plotter = Plotter(mocked_display, style=style, mode=mode, scale_mode=scale_mode, scroll_px=self._SCROLL_PX, plot_width=self._PLOT_WIDTH, plot_height=self._PLOT_HEIGHT, title="Debugging", max_title_len=99, mu_output=False, debug=0) return plotter def ready_plot_source(self, plttr, source): #source_name = str(source) plttr.clear_all() #plttr.title = source_name #plttr.y_axis_lab = source.units() plttr.y_range = (source.initial_min(), source.initial_max()) plttr.y_full_range = (source.min(), source.max()) plttr.y_min_range = source.range_min() channels_from_source = source.values() plttr.channels = channels_from_source plttr.channel_colidx = (1, 2, 3) source.start() return (source, channels_from_source) def make_a_PlotSource(self, channels = 1): ps = Mock() ps.initial_min = Mock(return_value=-100.0) ps.initial_max = Mock(return_value=100.0) ps.min = Mock(return_value=-100.0) ps.max = Mock(return_value=100.0) ps.range_min = Mock(return_value=5.0) if channels == 1: ps.values = Mock(return_value=channels) ps.data = Mock(side_effect=list(range(10,90)) * 100) elif channels == 3: ps.values = Mock(return_value=channels) ps.data = Mock(side_effect=list(zip(list(range(10,90)), list(range(15,95)), list(range(40,60)) * 4)) * 100) return ps def make_a_PlotSource_narrowrange(self): ps = Mock() ps.initial_min = Mock(return_value=0.0) ps.initial_max = Mock(return_value=500.0) ps.min = Mock(return_value=0.0) ps.max = Mock(return_value=500.0) ps.range_min = Mock(return_value=5.0) ps.values = Mock(return_value=1) # 24 elements repeated 13 times ranging between 237 and 253 # 5 elements repeated 6000 times ps.data = Mock(side_effect=(list(range(237, 260 + 1)) * 13 + list(range(100, 400 + 1, 75)) * 6000)) return ps def make_a_PlotSource_onespike(self): ps = Mock() ps.initial_min = Mock(return_value=-100.0) ps.initial_max = Mock(return_value=100.0) ps.min = Mock(return_value=-100.0) ps.max = Mock(return_value=100.0) ps.range_min = Mock(return_value=5.0) ps.values = Mock(return_value=1) ps.data = Mock(side_effect=([0]*95 + [5,10,20,50,80,90,70,30,20,10] + [0] * 95 + [1] * 1000)) return ps def make_a_PlotSource_bilevel(self, first_v=60, second_v=700): ps = Mock() ps.initial_min = Mock(return_value=-100.0) ps.initial_max = Mock(return_value=100.0) ps.min = Mock(return_value=-1000.0) ps.max = Mock(return_value=1000.0) ps.range_min = Mock(return_value=10.0) ps.values = Mock(return_value=1) ps.data = Mock(side_effect=[first_v] * 199 + [second_v] * 1001) return ps def test_spike_after_wrap_and_overwrite_one_channel(self): """A specific test to check that a spike that appears in wrap mode is correctly cleared by subsequent flat data.""" plotter = self.make_a_Plotter("lines", "wrap") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_source1 = self.make_a_PlotSource_onespike() self.ready_plot_source(plotter, test_source1) unique1, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique1 == [0]), "Checking all pixels start as 0") # Fill screen for _ in range(200): plotter.data_add((test_source1.data(),)) unique2, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique2 == [0, 1]), "Checking pixels are now a mix of 0 and 1") # Rewrite whole screen with new data as we are in wrap mode for _ in range(190): plotter.data_add((test_source1.data(),)) non_zero_rows = self.count_nz_rows(plot) if verbose >= 4: print("y=99", plot[:, 99]) print("y=100", plot[:, 100]) self.assertTrue(9 not in non_zero_rows, "Check nothing is just above 90 which plots at 10") self.assertEqual(non_zero_rows, [99, 100], "Only pixels left plotted should be from" + "values 0 and 1 being plotted at 99 and 100") self.assertTrue(numpy.alltrue(plot[:, 99] == [1] * 190 + [0] * 10), "Checking row 99 precisely") self.assertTrue(numpy.alltrue(plot[:, 100] == [0] * 190 + [1] * 10), "Checking row 100 precisely") plotter.display_off() def test_clearmode_from_lines_wrap_to_dots_scroll(self): """A specific test to check that a spike that appears in lines wrap mode is correctly cleared by a change to dots scroll.""" plotter = self.make_a_Plotter("lines", "wrap") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_source1 = self.make_a_PlotSource_onespike() self.ready_plot_source(plotter, test_source1) unique1, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique1 == [0]), "Checking all pixels start as 0") # Fill screen then wrap to write another 20 values for _ in range(200 + 20): plotter.data_add((test_source1.data(),)) unique2, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique2 == [0, 1]), "Checking pixels are now a mix of 0 and 1") plotter.change_stylemode("dots", "scroll") unique3, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique3 == [0]), "Checking all pixels are now 0 after change_stylemode") plotter.display_off() def test_clear_after_scrolling_one_channel(self): """A specific test to check screen clears after a scroll to help investigate a bug with that failing to happen in most cases.""" plotter = self.make_a_Plotter("lines", "scroll") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_source1 = self.make_a_PlotSource() self.ready_plot_source(plotter, test_source1) unique1, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique1 == [0]), "Checking all pixels start as 0") # Fill screen for _ in range(200): plotter.data_add((test_source1.data(),)) unique2, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique2 == [0, 1]), "Checking pixels are now a mix of 0 and 1") self.assertEqual(plotter._values, 200) self.assertEqual(plotter._data_values, 200) # Force a single scroll of the data for _ in range(10): plotter.data_add((test_source1.data(),)) self.assertEqual(plotter._values, 200 + 10) self.assertEqual(plotter._data_values, 200 + 10 - self._SCROLL_PX) # This should clear all data and the screen if verbose >= 3: print("change_stylemode() to a new mode which will clear screen") plotter.change_stylemode("dots", "wrap") unique3, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique3 == [0]), "Checking all pixels are now 0") plotter.display_off() def test_check_internal_data_three_channels(self): width = self._PLOT_WIDTH plotter = self.make_a_Plotter("lines", "scroll") (tg, plot) = (Mock(), numpy.zeros((width, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_triplesource1 = self.make_a_PlotSource(channels=3) self.ready_plot_source(plotter, test_triplesource1) unique1, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique1 == [0]), "Checking all pixels start as 0") # Three data samples all_data = [] for d_idx in range(3): all_data.append(test_triplesource1.data()) plotter.data_add(all_data[-1]) # all_data is now [(10, 15, 40), (11, 16, 41), (12, 17, 42)] self.assertEqual(plotter._data_y_pos[0][0:3], array.array('i', [90, 89, 88]), "channel 0 plotted y positions") self.assertEqual(plotter._data_y_pos[1][0:3], array.array('i', [85, 84, 83]), "channel 1 plotted y positions") self.assertEqual(plotter._data_y_pos[2][0:3], array.array('i', [60, 59, 58]), "channel 2 plotted y positions") # Fill rest of screen for d_idx in range(197): all_data.append(test_triplesource1.data()) plotter.data_add(all_data[-1]) # Three values more values to force a scroll for d_idx in range(3): all_data.append(test_triplesource1.data()) plotter.data_add(all_data[-1]) # all_data[-4] is (49, 54, 59) # all_data[-3:0] is [(50, 55, 40) (51, 56, 41) (52, 57, 42)] expected_data_size = width - self._SCROLL_PX + 3 st_x_pos = width - self._SCROLL_PX d_idx = plotter._data_idx - 3 self.assertTrue(self._SCROLL_PX > 3, "Ensure no scrolling occurred from recent 3 values") # the data_idx here is 2 because the size is now plot_width + 1 self.assertEqual(plotter._data_idx, 2) self.assertEqual(plotter._x_pos, st_x_pos + 3) self.assertEqual(plotter._data_values, expected_data_size) self.assertEqual(plotter._values, len(all_data)) if verbose >= 4: print("YP",d_idx, plotter._data_y_pos[0][d_idx:d_idx+3]) print("Y POS", [str(plotter._data_y_pos[ch_idx][d_idx:d_idx+3]) for ch_idx in [0, 1, 2]]) ch0_ypos = [50, 49, 48] self.assertEqual([plotter._data_y_pos[0][idx] for idx in range(d_idx, d_idx + 3)], ch0_ypos, "channel 0 plotted y positions") ch1_ypos = [45, 44, 43] self.assertEqual([plotter._data_y_pos[1][idx] for idx in range(d_idx, d_idx + 3)], ch1_ypos, "channel 1 plotted y positions") ch2_ypos = [60, 59, 58] self.assertEqual([plotter._data_y_pos[2][idx] for idx in range(d_idx, d_idx + 3)], ch2_ypos, "channel 2 plotted y positions") # Check for plot points - fortunately none overlap total_pixel_matches = 0 for ch_idx, ch_ypos in enumerate((ch0_ypos, ch1_ypos, ch2_ypos)): expected = plotter.channel_colidx[ch_idx] for idx, y_pos in enumerate(ch_ypos): actual = plot[st_x_pos+idx, y_pos] if actual == expected: total_pixel_matches += 1 else: if verbose >= 4: print("Pixel value for channel", "{:d}, naive expectation {:d},".format(ch_idx, expected), "actual {:d} at {:d}, {:d}, {:d}".format(idx, actual, st_x_pos + idx, y_pos)) # Only 7 out of 9 will match because channel 2 put a vertical # line at x position 175 over-writing ch0 and ch1 self.assertEqual(total_pixel_matches, 7, "plotted pixels check") # Check for that line from pixel positions 42 to 60 for y_pos in range(42, 60 + 1): self.assertEqual(plot[st_x_pos, y_pos], plotter.channel_colidx[2], "channel 2 (over-writing) vertical line") plotter.display_off() def test_clear_after_scrolling_three_channels(self): """A specific test to check screen clears after a scroll with multiple channels being plotted (three) to help investigate a bug with that failing to happen in most cases for the second and third channels.""" plotter = self.make_a_Plotter("lines", "scroll") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_triplesource1 = self.make_a_PlotSource(channels=3) self.ready_plot_source(plotter, test_triplesource1) unique1, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique1 == [0]), "Checking all pixels start as 0") # Fill screen for _ in range(200): plotter.data_add(test_triplesource1.data()) unique2, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique2 == [0, 1, 2, 3]), "Checking pixels are now a mix of 0, 1, 2, 3") # Force a single scroll of the data for _ in range(10): plotter.data_add(test_triplesource1.data()) # This should clear all data and the screen if verbose >= 3: print("change_stylemode() to a new mode which will clear screen") plotter.change_stylemode("dots", "wrap") unique3, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique3 == [0]), "Checking all pixels are now 0") plotter.display_off() def test_auto_rescale_wrap_mode(self): """Ensure the auto-scaling is working and not leaving any remnants of previous plot.""" plotter = self.make_a_Plotter("lines", "wrap") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_source1 = self.make_a_PlotSource_bilevel(first_v=60, second_v=900) self.ready_plot_source(plotter, test_source1) unique1, _ = numpy.unique(plot, return_counts=True) self.assertTrue(numpy.alltrue(unique1 == [0]), "Checking all pixels start as 0") # Fill screen with first 200 for _ in range(200): plotter.data_add((test_source1.data(),)) non_zero_rows1 = self.count_nz_rows(plot) self.assertEqual(non_zero_rows1, list(range(0, 40 + 1)), "From value 60 being plotted at 40 but also upward line at end") # Rewrite screen with next 200 but these should force an internal # rescaling of y axis for _ in range(200): plotter.data_add((test_source1.data(),)) self.assertEqual(plotter.y_range, (-108.0, 1000.0), "Check rescaled y range") non_zero_rows2 = self.count_nz_rows(plot) self.assertEqual(non_zero_rows2, [18], "Only pixels now should be from value 900 being plotted at 18") plotter.display_off() def test_rescale_zoom_in_minequalsmax(self): """Test y_range adjusts any attempt to set the effective range to 0.""" plotter = self.make_a_Plotter("lines", "wrap") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_source1 = self.make_a_PlotSource_bilevel(first_v=20, second_v=20) self.ready_plot_source(plotter, test_source1) # Set y_range to a value which will cause a range of 0 with # the potential dire consequence of divide by zero plotter.y_range = (20, 20) plotter.data_add((test_source1.data(),)) y_min, y_max = plotter.y_range self.assertTrue(y_max - y_min > 0, "Range is not zero and implicitly" + "ZeroDivisionError exception has not occurred.") plotter.display_off() def test_rescale_zoom_in_narrowrangedata(self): """Test y_range adjusts on data from a narrow range with unusual per pixel scaling mode.""" # There was a bug which was visually obvious in pixel scale_mode # test this to ensure bug was squashed # time.monotonic_ns.return_value = lambda: global_time_ns local_time_ns = time.monotonic_ns() with patch('time.monotonic_ns', create=True, side_effect=lambda: local_time_ns) as _: plotter = self.make_a_Plotter("lines", "wrap", scale_mode="pixel") (tg, plot) = (Mock(), numpy.zeros((self._PLOT_WIDTH, self._PLOT_HEIGHT), numpy.uint8)) plotter.display_on(tg_and_plot=(tg, plot)) test_source1 = self.make_a_PlotSource_narrowrange() self.ready_plot_source(plotter, test_source1) # About 11 seconds worth - will have zoomed in during this time for _ in range(300): val = test_source1.data() plotter.data_add((val,)) local_time_ns += round(1/27 * 1e9) # emulation of time.sleep(1/27) y_min1, y_max1 = plotter.y_range self.assertAlmostEqual(y_min1, 232.4) self.assertAlmostEqual(y_max1, 264.6) unique, counts = numpy.unique(plotter._data_y_pos[0], return_counts=True) self.assertEqual(min(unique), 29) self.assertEqual(max(unique), 171) self.assertEqual(len(unique), 24) self.assertLessEqual(max(counts) - min(counts), 1) # Another 14 seconds and now data is in narrow range so another zoom is due # Why does this take so long? for _ in range(400): val = test_source1.data() plotter.data_add((val,)) local_time_ns += round(1/27 * 1e9) # emulation of time.sleep(1/27) y_min2, y_max2 = plotter.y_range self.assertAlmostEqual(y_min2, 40.0) self.assertAlmostEqual(y_max2, 460.0) #unique2, counts2 = numpy.unique(plotter._data_y_pos[0], # return_counts=True) #self.assertEqual(list(unique2), [29, 100, 171]) #self.assertLessEqual(max(counts2) - min(counts2), 1) if verbose >= 3: self.aprint_plot(plot) # Look for a specific bug which leaves some previous pixels # set on screen at column 24 # Checking either side as this will be timing sensitive but the time # functions are now precisely controlled in this test so should not vary # with test execution duration vs wall clock for offset in range(-15, 15 + 5, 5): self.assertEqual(list(plot[24 + offset][136:172]), [0] * 36, "Checking for erased pixels at various columns") plotter.display_off() if __name__ == '__main__': unittest.main(verbosity=verbose)