Repository: bokeh/bokeh

Issue:
Simplify/refactor filters in bokehjs
ref https://github.com/bokeh/bokeh/pull/10084#discussion_r432112063


Discussion:

Resolving patch:
diff --git a/bokeh/models/filters.py b/bokeh/models/filters.py
--- a/bokeh/models/filters.py
+++ b/bokeh/models/filters.py
@@ -16,7 +16,8 @@
 #-----------------------------------------------------------------------------
 
 # Bokeh imports
-from ..core.properties import AnyRef, Bool, Dict, Either, Int, Seq, String
+from ..core.has_props import abstract
+from ..core.properties import AnyRef, Bool, Dict, Int, Seq, String
 from ..model import Model
 
 #-----------------------------------------------------------------------------
@@ -35,21 +36,12 @@
 # General API
 #-----------------------------------------------------------------------------
 
+@abstract
 class Filter(Model):
     ''' A Filter model represents a filtering operation that returns a row-wise subset of
     data when applied to a ``ColumnDataSource``.
     '''
 
-    filter = Either(Seq(Int), Seq(Bool), help="""
-    A list that can be either integer indices or booleans representing a row-wise subset of data.
-    """)
-
-    def __init__(self, *args, **kw):
-        if len(args) == 1 and "filter" not in kw:
-            kw["filter"] = args[0]
-
-        super().__init__(**kw)
-
 class IndexFilter(Filter):
     ''' An ``IndexFilter`` filters data by returning the subset of data at a given set of indices.
     '''
@@ -132,7 +124,7 @@ class CustomJSFilter(Filter):
 
             code = '''
             const indices = []
-            for (var i = 0; i <= source.data['some_column'].length; i++) {
+            for (let i = 0; i <= source.data['some_column'].length; i++) {
                 if (source.data['some_column'][i] == 'some_value') {
                     indices.push(i)
                 }


Test patch:
diff --git a/bokehjs/test/unit/core/selection_manager.ts b/bokehjs/test/unit/core/selection_manager.ts
--- a/bokehjs/test/unit/core/selection_manager.ts
+++ b/bokehjs/test/unit/core/selection_manager.ts
@@ -36,7 +36,7 @@ describe("SelectionManager", () => {
       glyph_stub.returns(new Selection({indices: [0]}))
       const source = renderer_view.model.data_source
       const filter = new IndexFilter({indices: [1]})
-      renderer_view.model.view = new CDSView({filters: [filter]})
+      renderer_view.model.view = new CDSView({source, filters: [filter]})
 
       const did_hit = source.selection_manager.select([renderer_view], {type: "point", sx: 0, sy: 0}, true)
       expect(did_hit).to.be.true
diff --git a/bokehjs/test/unit/core/util/data_structures.ts b/bokehjs/test/unit/core/util/data_structures.ts
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/unit/core/util/data_structures.ts
@@ -0,0 +1,78 @@
+import {expect} from "assertions"
+import {BitSet} from "@bokehjs/core/util/data_structures"
+
+describe("core/util/data_structures module", () => {
+
+  describe("BitSet data structure", () => {
+
+    it("should constructable", () => {
+      const bs0 = new BitSet(39)
+      expect(bs0.size).to.be.equal(39)
+      expect([...bs0].length).to.be.equal(0)
+
+      const bs1 = new BitSet(39, 0)
+      expect(bs1.size).to.be.equal(39)
+      expect([...bs1].length).to.be.equal(0)
+
+      const bs2 = new BitSet(39, 1)
+      expect(bs2.size).to.be.equal(39)
+      expect([...bs2].length).to.be.equal(39)
+    })
+
+    const bs0 = BitSet.from_indices(39, [0, 1, 15, 16, 31, 32, 33, 38])
+    const bs1 = BitSet.from_indices(39, [1, 6, 15, 31, 32, 34, 35, 38])
+
+    it("should suppport get() method", () => {
+      expect(bs0.get(0)).to.be.equal(true)
+      expect(bs0.get(1)).to.be.equal(true)
+      expect(bs0.get(2)).to.be.equal(false)
+
+      expect(bs0.get(14)).to.be.equal(false)
+      expect(bs0.get(15)).to.be.equal(true)
+      expect(bs0.get(16)).to.be.equal(true)
+      expect(bs0.get(17)).to.be.equal(false)
+
+      expect(bs0.get(30)).to.be.equal(false)
+      expect(bs0.get(31)).to.be.equal(true)
+      expect(bs0.get(32)).to.be.equal(true)
+      expect(bs0.get(33)).to.be.equal(true)
+      expect(bs0.get(34)).to.be.equal(false)
+
+      expect(bs0.get(36)).to.be.equal(false)
+      expect(bs0.get(37)).to.be.equal(false)
+      expect(bs0.get(38)).to.be.equal(true)
+
+      expect(() => bs0.get(39)).to.throw()
+    })
+
+    it("should suppport iterator protocol", () => {
+      expect([...bs0]).to.be.equal([0, 1, 15, 16, 31, 32, 33, 38])
+      expect([...bs0.ones()]).to.be.equal([0, 1, 15, 16, 31, 32, 33, 38])
+      expect([...bs0.zeros()]).to.be.equal([
+        2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19,
+        20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 34, 35, 36, 37,
+      ])
+    })
+
+    it("should suppport a | b operation", () => {
+      const bs = bs0.union(bs1)
+      expect(bs).to.be.instanceof(BitSet)
+      expect(bs.size).to.be.equal(39)
+      expect([...bs.ones()]).to.be.equal([0, 1, 6, 15, 16, 31, 32, 33, 34, 35, 38])
+    })
+
+    it("should suppport a & b operation", () => {
+      const bs = bs0.intersection(bs1)
+      expect(bs).to.be.instanceof(BitSet)
+      expect(bs.size).to.be.equal(39)
+      expect([...bs.ones()]).to.be.equal([1, 15, 31, 32, 38])
+    })
+
+    it("should suppport a - b operation", () => {
+      const bs = bs0.difference(bs1)
+      expect(bs).to.be.instanceof(BitSet)
+      expect(bs.size).to.be.equal(39)
+      expect([...bs.ones()]).to.be.equal([0, 16, 33])
+    })
+  })
+})
diff --git a/bokehjs/test/unit/core/visuals.ts b/bokehjs/test/unit/core/visuals.ts
--- a/bokehjs/test/unit/core/visuals.ts
+++ b/bokehjs/test/unit/core/visuals.ts
@@ -1,6 +1,7 @@
 import {expect} from "assertions"
 import {create_glyph_renderer_view} from "../models/glyphs/glyph_utils"
 
+import {Indices} from "@bokehjs/core/types"
 import {Fill, Line, Text, Visuals} from "@bokehjs/core/visuals"
 import {Context2d} from "@bokehjs/core/util/canvas"
 import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"
@@ -183,8 +184,8 @@ describe("Visuals", () => {
     const circle = new Circle(attrs)
     const visuals = new Visuals(circle) as Visuals & {fill: Fill}
 
-    visuals.warm_cache(source)
-    visuals.set_all_indices([1, 2])
+    const subset = Indices.from_indices(3, [1, 2])
+    visuals.warm_cache(source, subset)
 
     const ctx = {} as Context2d
     visuals.fill.set_vectorize(ctx, 1)
@@ -193,7 +194,7 @@ describe("Visuals", () => {
 
   describe("interacting with GlyphViews", () => {
 
-    it("set_all_indices should be called by the glyph view", async () => {
+    it("warm_cache(..., all_indices) should be called by the glyph view", async () => {
       const attrs = {fill_color: {field: "fill_color"}, fill_alpha: {field: "fill_alpha"}}
 
       const circle = new Circle(attrs)
diff --git a/bokehjs/test/unit/models/filters/boolean_filter.ts b/bokehjs/test/unit/models/filters/boolean_filter.ts
--- a/bokehjs/test/unit/models/filters/boolean_filter.ts
+++ b/bokehjs/test/unit/models/filters/boolean_filter.ts
@@ -13,24 +13,29 @@ describe("BooleanFilter", () => {
 
   describe("compute_indices", () => {
 
-    it("returns the correct indices when booleans is all boooleans", () => {
-      const boolean_filter = new BooleanFilter({booleans: [false, true, true]})
-      expect(boolean_filter.compute_indices(cds)).to.be.equal([1, 2])
+    it("returns the correct indices", () => {
+      const boolean_filter = new BooleanFilter({booleans: [false, true, true, false, false]})
+      expect([...boolean_filter.compute_indices(cds)]).to.be.equal([1, 2])
+    })
+
+    it("returns the correct indices when too many entries were given", () => {
+      const boolean_filter = new BooleanFilter({booleans: [false, true, true, false, false, true]})
+      expect([...boolean_filter.compute_indices(cds)]).to.be.equal([1, 2])
     })
 
-    it("returns null when booleans has non-booleans", () => {
-      const boolean_filter = new BooleanFilter({booleans: [0.2, 1, 3] as any}) // XXX
-      expect(boolean_filter.compute_indices(cds)).to.be.null
+    it("returns the correct indices when too few entries were given", () => {
+      const boolean_filter = new BooleanFilter({booleans: [false, true, true]})
+      expect([...boolean_filter.compute_indices(cds)]).to.be.equal([1, 2])
     })
 
-    it("returns null when booleans is an empty array", () => {
+    it("returns the correct indices when an empty array was given", () => {
       const boolean_filter = new BooleanFilter({booleans: []})
-      expect(boolean_filter.compute_indices(cds)).to.be.null
+      expect([...boolean_filter.compute_indices(cds)]).to.be.equal([])
     })
 
-    it("returns null when not initialized with booleans", () => {
+    it("returns full set of indices where booleans were not given", () => {
       const boolean_filter = new BooleanFilter()
-      expect(boolean_filter.compute_indices(cds)).to.be.null
+      expect([...boolean_filter.compute_indices(cds)]).to.be.equal([0, 1, 2, 3, 4])
     })
   })
 })
diff --git a/bokehjs/test/unit/models/filters/customjs_filter.ts b/bokehjs/test/unit/models/filters/customjs_filter.ts
--- a/bokehjs/test/unit/models/filters/customjs_filter.ts
+++ b/bokehjs/test/unit/models/filters/customjs_filter.ts
@@ -65,34 +65,34 @@ describe("CustomJSFilter", () => {
 
     it("should execute the code and return the result", () => {
       const filter = new CustomJSFilter({code: "return [0]"})
-      expect(filter.compute_indices(cds)).to.be.equal([0])
+      expect([...filter.compute_indices(cds)]).to.be.equal([0])
     })
 
     it("should compute indices using a source", () => {
       const code = `
-        var column = source.data["x"];
-        var indices = [];
-        for (var i = 0; i < source.get_length(); i++) {
-          indices.push(column[i] == "a");
+        const column = source.data["x"]
+        const indices = []
+        for (let i = 0; i < source.length; i++) {
+          indices.push(column[i] == "a")
         }
-        return indices;
+        return indices
       `
       const filter = new CustomJSFilter({code})
-      expect(filter.compute_indices(cds)).to.be.equal([0, 1])
+      expect([...filter.compute_indices(cds)]).to.be.equal([0, 1])
     })
 
     it("should compute indices using an arg property", () => {
       const code = `
-        var column = source.data["y"];
-        var indices = [];
-        for (var i = 0; i < source.get_length(); i++) {
+        const column = source.data["y"]
+        const indices = []
+        for (let i = 0; i < source.length; i++) {
           indices.push(column[i] == foo.start)
         }
-        return indices;
+        return indices
       `
       const rng = new Range1d({start: 5, end: 21})
       const filter = new CustomJSFilter({args: {foo: rng}, code})
-      expect(filter.compute_indices(cds)).to.be.equal([4])
+      expect([...filter.compute_indices(cds)]).to.be.equal([4])
     })
   })
 })
diff --git a/bokehjs/test/unit/models/filters/filter.ts b/bokehjs/test/unit/models/filters/filter.ts
deleted file mode 100644
--- a/bokehjs/test/unit/models/filters/filter.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import {expect} from "assertions"
-
-import {Filter} from "@bokehjs/models/filters/filter"
-import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"
-
-describe("Filter", () => {
-
-  const cds = new ColumnDataSource({
-    data: {
-      x: ["a", "a", "b", "b", "b"],
-    },
-  })
-
-  describe("compute_indices", () => {
-
-    it("returns the correct indices when filter is all booleans", () => {
-      const filter = new Filter({filter: [true, true, false, true]})
-      expect(filter.compute_indices(cds)).to.be.equal([0, 1, 3])
-    })
-
-    it("returns the correct indices when filter is all ints", () => {
-      const filter = new Filter({filter: [0, 1, 3]})
-      expect(filter.compute_indices(cds)).to.be.equal([0, 1, 3])
-    })
-
-    it("returns null when filter is not all booleans or all ints", () => {
-      const filter = new Filter({filter: [true, 1, 3] as any}) // XXX
-      expect(filter.compute_indices(cds)).to.be.null
-    })
-
-    it("returns null when filter has floats", () => {
-      const filter = new Filter({filter: [0.2, 1, 3]})
-      expect(filter.compute_indices(cds)).to.be.null
-    })
-
-    it("returns [] when filter is an empty array", () => {
-      const filter = new Filter({filter: []})
-      expect(filter.compute_indices(cds)).to.be.equal([])
-    })
-
-    it("returns null when not initialized with filter", () => {
-      const filter = new Filter()
-      expect(filter.compute_indices(cds)).to.be.null
-    })
-  })
-})
diff --git a/bokehjs/test/unit/models/filters/group_filter.ts b/bokehjs/test/unit/models/filters/group_filter.ts
--- a/bokehjs/test/unit/models/filters/group_filter.ts
+++ b/bokehjs/test/unit/models/filters/group_filter.ts
@@ -15,17 +15,17 @@ describe("GroupFilter", () => {
 
     it("returns the correct indices when column_name is in the data source", () => {
       const group_filter = new GroupFilter({column_name: "x", group: "a"})
-      expect(group_filter.compute_indices(cds)).to.be.equal([0, 1])
+      expect([...group_filter.compute_indices(cds)]).to.be.equal([0, 1])
     })
 
     it("returns empty array when no elements in the column match the group", () => {
       const group_filter = new GroupFilter({column_name: "x", group: "c"})
-      expect(group_filter.compute_indices(cds)).to.be.equal([])
+      expect([...group_filter.compute_indices(cds)]).to.be.equal([])
     })
 
     it("returns null when column_name is not in the data source", () => {
       const group_filter = new GroupFilter({column_name: "y", group: "c"})
-      expect(group_filter.compute_indices(cds)).to.be.null
+      expect([...group_filter.compute_indices(cds)]).to.be.equal([0, 1, 2, 3, 4])
     })
   })
 })
diff --git a/bokehjs/test/unit/models/filters/index.ts b/bokehjs/test/unit/models/filters/index.ts
--- a/bokehjs/test/unit/models/filters/index.ts
+++ b/bokehjs/test/unit/models/filters/index.ts
@@ -1,5 +1,4 @@
 import "./boolean_filter"
 import "./customjs_filter"
-import "./filter"
 import "./group_filter"
 import "./index_filter"
diff --git a/bokehjs/test/unit/models/filters/index_filter.ts b/bokehjs/test/unit/models/filters/index_filter.ts
--- a/bokehjs/test/unit/models/filters/index_filter.ts
+++ b/bokehjs/test/unit/models/filters/index_filter.ts
@@ -15,22 +15,17 @@ describe("IndexFilter", () => {
 
     it("returns the correct indices when indices is all ints", () => {
       const index_filter = new IndexFilter({indices: [0, 1, 3]})
-      expect(index_filter.compute_indices(cds)).to.be.equal([0, 1, 3])
-    })
-
-    it("returns null when indices has floats", () => {
-      const index_filter = new IndexFilter({indices: [0.2, 1, 3]})
-      expect(index_filter.compute_indices(cds)).to.be.null
+      expect([...index_filter.compute_indices(cds)]).to.be.equal([0, 1, 3])
     })
 
     it("returns [] when indices is an empty array", () => {
       const index_filter = new IndexFilter({indices: []})
-      expect(index_filter.compute_indices(cds)).to.be.equal([])
+      expect([...index_filter.compute_indices(cds)]).to.be.equal([])
     })
 
     it("returns null when not initialized with indices", () => {
       const index_filter = new IndexFilter()
-      expect(index_filter.compute_indices(cds)).to.be.null
+      expect([...index_filter.compute_indices(cds)]).to.be.equal([0, 1, 2, 3, 4])
     })
   })
 })
diff --git a/bokehjs/test/unit/models/glyphs/image_url.ts b/bokehjs/test/unit/models/glyphs/image_url.ts
--- a/bokehjs/test/unit/models/glyphs/image_url.ts
+++ b/bokehjs/test/unit/models/glyphs/image_url.ts
@@ -42,6 +42,7 @@ describe("ImageURL module", () => {
     it("`_map_data` should correctly map data if w and h units are 'data'", async () => {
       // ImageURLView._map_data is called by ImageURLView.map_data
       const image_url = new ImageURL()
+      image_url.url = {value: "data:image/png;base64,"}
       image_url.x = 0
       image_url.y = 0
       image_url.w = 17
@@ -57,6 +58,7 @@ describe("ImageURL module", () => {
     it("`_map_data` should correctly map data if w and h units are 'screen'", async () => {
       // ImageURLView._map_data is called by ImageURLView.map_data
       const image_url = new ImageURL()
+      image_url.url = {value: "data:image/png;base64,"}
       image_url.x = 0
       image_url.y = 0
       image_url.w = 1
@@ -74,6 +76,7 @@ describe("ImageURL module", () => {
     it("`_map_data` should map data to NaN if w and h are null, 'data' units", async () => {
       // if sw, sh are NaN, then the image width or height are used during render
       const image_url = new ImageURL()
+      image_url.url = {value: "data:image/png;base64,"}
       image_url.x = 0
       image_url.y = 0
       image_url.w = null as any // XXX
@@ -88,6 +91,7 @@ describe("ImageURL module", () => {
 
     it("`_map_data` should map data to NaN if w and h are null, 'screen' units", async () => {
       const image_url = new ImageURL()
+      image_url.url = {value: "data:image/png;base64,"}
       image_url.x = 0
       image_url.y = 0
       image_url.w = null as any // XXX
diff --git a/bokehjs/test/unit/models/sources/cds_view.ts b/bokehjs/test/unit/models/sources/cds_view.ts
--- a/bokehjs/test/unit/models/sources/cds_view.ts
+++ b/bokehjs/test/unit/models/sources/cds_view.ts
@@ -3,7 +3,7 @@ import {expect} from "assertions"
 import {CDSView} from "@bokehjs/models/sources/cds_view"
 import {Selection} from "@bokehjs/models/selections/selection"
 import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"
-import {Filter} from "@bokehjs/models/filters/filter"
+import {IndexFilter} from "@bokehjs/models/filters/index_filter"
 import {GroupFilter} from "@bokehjs/models/filters/group_filter"
 
 describe("CDSView", () => {
@@ -15,32 +15,32 @@ describe("CDSView", () => {
     },
   })
 
-  const filter1 = new Filter({filter: [0, 1, 2]})
-  const filter2 = new Filter({filter: [1, 2, 3]})
-  const filter_null = new Filter()
+  const filter1 = new IndexFilter({indices: [0, 1, 2]})
+  const filter2 = new IndexFilter({indices: [1, 2, 3]})
+  const filter_null = new IndexFilter()
 
   describe("compute_indices", () => {
 
     it("is called on init and sets the cds view's indices", () => {
       const view = new CDSView({source: cds, filters: [filter1]})
-      expect(view.indices).to.be.equal([0, 1, 2])
+      expect([...view.indices]).to.be.equal([0, 1, 2])
     })
 
     it("updates indices when filters is changed", () => {
       const view = new CDSView({source: cds, filters: [filter1]})
-      expect(view.indices).to.be.equal([0, 1, 2])
+      expect([...view.indices]).to.be.equal([0, 1, 2])
       view.filters = [filter2]
-      expect(view.indices).to.be.equal([1, 2, 3])
+      expect([...view.indices]).to.be.equal([1, 2, 3])
     })
 
     it("computes indices based on the intersection of filters", () => {
       const view = new CDSView({source: cds, filters: [filter1, filter2]})
-      expect(view.indices).to.be.equal([1, 2])
+      expect([...view.indices]).to.be.equal([1, 2])
     })
 
     it("computes indices ignoring null filters", () => {
       const view = new CDSView({source: cds, filters: [filter1, filter2, filter_null]})
-      expect(view.indices).to.be.equal([1, 2])
+      expect([...view.indices]).to.be.equal([1, 2])
     })
   })
 
@@ -77,9 +77,9 @@ describe("CDSView", () => {
     const new_data = {x: [1], y: [1]}
 
     const view = new CDSView({source: cds})
-    expect(view.indices).to.be.equal([])
+    expect([...view.indices]).to.be.equal([])
     cds.stream(new_data)
-    expect(view.indices).to.be.equal([0])
+    expect([...view.indices]).to.be.equal([0])
   })
 
   it("should update its indices when its source patches new data", () => {
@@ -87,9 +87,9 @@ describe("CDSView", () => {
     const group_filter = new GroupFilter({column_name: "x", group: "b"})
 
     const view = new CDSView({source: cds, filters: [group_filter]})
-    expect(view.indices).to.be.equal([])
+    expect([...view.indices]).to.be.equal([])
     cds.patch({x: [[0, "b"]]})
-    expect(view.indices).to.be.equal([0])
+    expect([...view.indices]).to.be.equal([0])
   })
 
   it("should update its indices when its source's data changes", () => {
@@ -99,8 +99,8 @@ describe("CDSView", () => {
     const group_filter = new GroupFilter({column_name: "x", group: "b"})
 
     const view = new CDSView({source: cds, filters: [group_filter]})
-    expect(view.indices).to.be.equal([])
+    expect([...view.indices]).to.be.equal([])
     cds.data = data2
-    expect(view.indices).to.be.equal([0])
+    expect([...view.indices]).to.be.equal([0])
   })
 })
diff --git a/bokehjs/test/unit/models/transforms/step_interpolator.ts b/bokehjs/test/unit/models/transforms/step_interpolator.ts
--- a/bokehjs/test/unit/models/transforms/step_interpolator.ts
+++ b/bokehjs/test/unit/models/transforms/step_interpolator.ts
@@ -1,44 +1,37 @@
 import {expect} from "assertions"
 
-import {StepInterpolator} from '@bokehjs/models/transforms/step_interpolator'
-import {ColumnDataSource} from '@bokehjs/models/sources/column_data_source'
+import {StepInterpolator} from "@bokehjs/models/transforms/step_interpolator"
+import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"
+import {StepMode} from "@bokehjs/core/enums"
 
 describe("step_interpolator_transform module", () => {
 
-  function generate_interpolator_ColumnDataSource() {
-    return new StepInterpolator({
-      x: 'var1',
-      y: 'var2',
-      data: new ColumnDataSource({
-        data: {var1: [0, 5, 15], var2: [10, 20, 30]},
-      }),
-    })
-  }
-
-  function generate_interpolator_inline() {
-    return new StepInterpolator({
-      x: [0, 5, 15],
-      y: [10, 20, 30],
-    })
-  }
-
   describe("creation with ColumnDataSource ranges", () => {
-    const transform = generate_interpolator_ColumnDataSource()
+    function step_interpolator(mode: StepMode) {
+      return new StepInterpolator({
+        mode,
+        x: "var1",
+        y: "var2",
+        data: new ColumnDataSource({
+          data: {var1: [0, 5, 15], var2: [10, 20, 30]},
+        }),
+      })
+    }
 
-    it("should return control points", () => {
-      expect(transform.compute(0)).to.be.equal(10)
-      expect(transform.compute(5)).to.be.equal(20)
-    })
+    describe("should step interpolate before", () => {
+      const transform = step_interpolator("before")
 
-    it("should step interpolate before", () => {
-      transform.mode = 'before'
+      it("should return control points", () => {
+        expect(transform.compute(0)).to.be.equal(10)
+        expect(transform.compute(5)).to.be.equal(20)
+      })
 
       it("should linearly interpolate between control points", () => {
         expect(transform.compute(2)).to.be.equal(20)
       })
 
       it("should linearly interpolate a vector of points", () => {
-        expect(transform.v_compute([0, 2, 6])).to.be.equal(new Float64Array([20, 20, 30]))
+        expect(transform.v_compute([0, 2, 6])).to.be.equal(new Float64Array([10, 20, 30]))
       })
 
       it("should map to a Float64Array", () => {
@@ -46,8 +39,13 @@ describe("step_interpolator_transform module", () => {
       })
     })
 
-    it("should step interpolate after", () => {
-      transform.mode = 'after'
+    describe("should step interpolate after", () => {
+      const transform = step_interpolator("after")
+
+      it("should return control points", () => {
+        expect(transform.compute(0)).to.be.equal(10)
+        expect(transform.compute(5)).to.be.equal(20)
+      })
 
       it("should linearly interpolate between control points", () => {
         expect(transform.compute(2)).to.be.equal(10)
@@ -62,8 +60,13 @@ describe("step_interpolator_transform module", () => {
       })
     })
 
-    it("should step interpolate center", () => {
-      transform.mode = 'center'
+    describe("should step interpolate center", () => {
+      const transform = step_interpolator("center")
+
+      it("should return control points", () => {
+        expect(transform.compute(0)).to.be.equal(10)
+        expect(transform.compute(5)).to.be.equal(20)
+      })
 
       it("should linearly interpolate between control points", () => {
         expect(transform.compute(2)).to.be.equal(10)
@@ -80,22 +83,30 @@ describe("step_interpolator_transform module", () => {
   })
 
   describe("creation with inline ranges", () => {
-    const transform = generate_interpolator_inline()
 
-    it("should return control points", () => {
-      expect(transform.compute(0)).to.be.equal(10)
-      expect(transform.compute(5)).to.be.equal(20)
-    })
+    function step_interpolator(mode: StepMode) {
+      return new StepInterpolator({
+        mode,
+        x: [0, 5, 15],
+        y: [10, 20, 30],
+      })
+    }
+
+
+    describe("should step interpolate before", () => {
+      const transform = step_interpolator("before")
 
-    it("should step interpolate before", () => {
-      transform.mode = 'before'
+      it("should return control points", () => {
+        expect(transform.compute(0)).to.be.equal(10)
+        expect(transform.compute(5)).to.be.equal(20)
+      })
 
       it("should linearly interpolate between control points", () => {
         expect(transform.compute(2)).to.be.equal(20)
       })
 
       it("should linearly interpolate a vector of points", () => {
-        expect(transform.v_compute([0, 2, 6])).to.be.equal(new Float64Array([20, 20, 30]))
+        expect(transform.v_compute([0, 2, 6])).to.be.equal(new Float64Array([10, 20, 30]))
       })
 
       it("should map to a Float64Array", () => {
@@ -103,8 +114,13 @@ describe("step_interpolator_transform module", () => {
       })
     })
 
-    it("should step interpolate after", () => {
-      transform.mode = 'after'
+    describe("should step interpolate after", () => {
+      const transform = step_interpolator("after")
+
+      it("should return control points", () => {
+        expect(transform.compute(0)).to.be.equal(10)
+        expect(transform.compute(5)).to.be.equal(20)
+      })
 
       it("should linearly interpolate between control points", () => {
         expect(transform.compute(2)).to.be.equal(10)
@@ -119,8 +135,13 @@ describe("step_interpolator_transform module", () => {
       })
     })
 
-    it("should step interpolate center", () => {
-      transform.mode = 'center'
+    describe("should step interpolate center", () => {
+      const transform = step_interpolator("center")
+
+      it("should return control points", () => {
+        expect(transform.compute(0)).to.be.equal(10)
+        expect(transform.compute(5)).to.be.equal(20)
+      })
 
       it("should linearly interpolate between control points", () => {
         expect(transform.compute(2)).to.be.equal(10)