Repository: enthought/chaco

Issue:
Removing a data source makes `DataView` objects inconsistent
**Problem Description**
Removing a data source from a `DataView` instance leaves it internally inconsistent.

**Reproduction Steps:**
This snippet demonstrates the inconsistency:
```python
import numpy as np

from chaco.api import DataView
from chaco.grid_data_source import GridDataSource

source = GridDataSource(xdata=np.array([1, 2, 4]), ydata=np.array([7, 8, 9]))
component = DataView()
print(f"Initial output: {component.map_screen(np.array([0, 1]))}")
component.range2d.add(source)  # (1)
component.map_screen(np.array([0, 1]))  # (2)
component.range2d.remove(source)  # (3)
print(f"Final output: {component.map_screen(np.array([0, 1]))}")
```
Output:
```
Initial output: [[0. 0.]]
Final output: [inf inf]
```

Initially, without any data sources, the output of `component.map_screen` is `[[0, 0]]`. When a data source is added and then removed, the bounds on `component.value_mapper.range` to refreshed to (-inf, inf). However, `component.value_mapper._cache_valid` isn't reverted from `True` to `False`. (It's set to `True` as a consequence of (2) in the snippet above.) As a result, subsequent calls to `component.map_screen` produce `inf`s. This `inf`s in turn have the potential to turn into `nan`s, raising exceptions in unexpected places downstream.

**Expected behavior:**
I *think* the output should be [[0., 0.]] again, (or more precisely, `component.value_mapper._cache_valid` should revert to `False` if `component.value_mapper.range.refresh()` is called.  However,  I'm not sure and opening this issue for discussion.

**OS, Python version:** macOS Catalina 10.15.7, Python 3.6.13



Discussion:
So the root cause appears to be that in the branch where there is no data and so the low and high values get set to ±`inf`, the `update` trait does not get fired, and so the mapper doesn't get the `_cache_valid` trait reset.  If we fire that event it will then return `[[0 0]]` as suggested.

From a UX perspective, I'm not 100% sure that this is the right thing, however.  It may be more natural to have the range stay fixed at its previous values when the last data is removed from the `Range` to prevent sudden resets of the view.  But on the other hand, this may be OK when in "auto" or "track" modes.

I'll think about this some more.

Resolving patch:
diff --git a/chaco/data_range_1d.py b/chaco/data_range_1d.py
--- a/chaco/data_range_1d.py
+++ b/chaco/data_range_1d.py
@@ -352,6 +352,7 @@ def _refresh_bounds(self):
                 self._high_value = inf
             else:
                 self._high_value = self._high_setting
+            self.updated = (self._low_value, self._high_value)
             return
         else:
             mins, maxes = zip(*bounds_list)


Test patch:
diff --git a/chaco/tests/test_datarange_1d.py b/chaco/tests/test_datarange_1d.py
--- a/chaco/tests/test_datarange_1d.py
+++ b/chaco/tests/test_datarange_1d.py
@@ -15,6 +15,7 @@
 from numpy.testing import assert_equal
 
 from traits.api import HasTraits, Instance, Bool, observe
+from traits.testing.api import UnittestTools
 
 from chaco.api import DataRange1D, ArrayDataSource
 
@@ -35,7 +36,7 @@ def range_changed(self, event):
         self.range_updated = True
 
 
-class DataRangeTestCase(unittest.TestCase):
+class DataRangeTestCase(UnittestTools, unittest.TestCase):
     def test_empty_range(self):
         r = DataRange1D()
         self.assertEqual(r.low, -inf)
@@ -337,3 +338,28 @@ def test_inf_in_source(self):
         r.sources.append(ds1)
         self.assertEqual(r.low, -inf)
         self.assertEqual(r.high, inf)
+
+    def test_sources_changed_auto(self):
+        ds1 = ArrayDataSource(array([3, 4, 5, 6, 7]))
+        ds2 = ArrayDataSource(array([5, 10, 15, 20]))
+        r = DataRange1D()
+        events = []
+        r.observe(events.append, 'updated')
+
+        self.assertEqual(r.low, -inf)
+        self.assertEqual(r.high, inf)
+
+        with self.assertTraitChanges(r, "updated", count=1):
+            r.add(ds1)
+
+        self.assertEqual(events[-1].new, (3, 7))
+        self.assertEqual(r.low, 3)
+        self.assertEqual(r.high, 7)
+
+
+        with self.assertTraitChanges(r, "updated", count=1):
+            r.remove(ds1)
+
+        self.assertEqual(events[-1].new, (-inf, inf))
+        self.assertEqual(r.low, -inf)
+        self.assertEqual(r.high, inf)