Skip to content

Commit d88610b

Browse files
committed
Add tests for the normalize transform
closes #473
1 parent 994b534 commit d88610b

File tree

7 files changed

+142
-7
lines changed

7 files changed

+142
-7
lines changed

src/options.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,14 @@ export function isTemporal(values) {
307307
}
308308
}
309309

310+
export function isWeaklyNumeric(values) {
311+
for (const value of values) {
312+
if (value == null) continue;
313+
if (typeof value === "number") return true; // note: includes NaN!
314+
return !isNaN(+value);
315+
}
316+
}
317+
310318
// Are these strings that might represent dates? This is stricter than ISO 8601
311319
// because we want to ignore false positives on numbers; for example, the string
312320
// "1192" is more likely to represent a number than a date even though it is

src/transforms/group.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import {
2929
range,
3030
second,
3131
percentile,
32-
isTemporal
32+
isTemporal,
33+
isWeaklyNumeric
3334
} from "../options.js";
3435
import {basic} from "./basic.js";
3536

@@ -253,7 +254,7 @@ export function maybeReduce(reduce, value) {
253254
case "proportion-facet":
254255
return reduceProportion(value, "facet");
255256
case "deviation":
256-
return reduceAccessor(deviation);
257+
return reduceNumbers(deviation);
257258
case "min":
258259
return reduceAccessor(min);
259260
case "min-index":
@@ -267,7 +268,7 @@ export function maybeReduce(reduce, value) {
267268
case "median":
268269
return reduceMaybeTemporalAccessor(median);
269270
case "variance":
270-
return reduceAccessor(variance);
271+
return reduceNumbers(variance);
271272
case "mode":
272273
return reduceAccessor(mode);
273274
case "x":
@@ -322,9 +323,19 @@ function reduceAccessor(f) {
322323
};
323324
}
324325

326+
function reduceNumbers(f) {
327+
return {
328+
reduce(I, X) {
329+
if (!isWeaklyNumeric(X)) throw new Error("non-numeric data");
330+
return f(I, (i) => X[i]);
331+
}
332+
};
333+
}
334+
325335
function reduceMaybeTemporalAccessor(f) {
326336
return {
327337
reduce(I, X) {
338+
if (!isWeaklyNumeric(X)) throw new Error("non-numeric data");
328339
const x = f(I, (i) => X[i]);
329340
return isTemporal(X) ? new Date(x) : x;
330341
}
@@ -385,7 +396,7 @@ const reduceDistinct = {
385396
}
386397
};
387398

388-
const reduceSum = reduceAccessor(sum);
399+
const reduceSum = reduceNumbers(sum);
389400

390401
function reduceProportion(value, scope) {
391402
return value == null

src/transforms/normalize.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {extent, deviation, max, mean, median, min, sum} from "d3";
22
import {defined} from "../defined.js";
3-
import {percentile, take} from "../options.js";
3+
import {isWeaklyNumeric, percentile, take} from "../options.js";
44
import {mapX, mapY} from "./map.js";
55

66
/** @jsdoc normalizeX */
@@ -46,6 +46,7 @@ export function normalize(basis) {
4646
function normalizeBasis(basis) {
4747
return {
4848
map(I, S, T) {
49+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
4950
const b = +basis(I, S);
5051
for (const i of I) {
5152
T[i] = S[i] === null ? NaN : S[i] / b;
@@ -60,6 +61,7 @@ function normalizeAccessor(f) {
6061

6162
const normalizeExtent = {
6263
map(I, S, T) {
64+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
6365
const [s1, s2] = extent(I, (i) => S[i]),
6466
d = s2 - s1;
6567
for (const i of I) {
@@ -69,13 +71,15 @@ const normalizeExtent = {
6971
};
7072

7173
const normalizeFirst = normalizeBasis((I, S) => {
74+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
7275
for (let i = 0; i < I.length; ++i) {
7376
const s = S[I[i]];
7477
if (defined(s)) return s;
7578
}
7679
});
7780

7881
const normalizeLast = normalizeBasis((I, S) => {
82+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
7983
for (let i = I.length - 1; i >= 0; --i) {
8084
const s = S[I[i]];
8185
if (defined(s)) return s;
@@ -84,6 +88,7 @@ const normalizeLast = normalizeBasis((I, S) => {
8488

8589
const normalizeDeviation = {
8690
map(I, S, T) {
91+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
8792
const m = mean(I, (i) => S[i]);
8893
const d = deviation(I, (i) => S[i]);
8994
for (const i of I) {

src/transforms/window.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {deviation, max, min, median, mode, variance} from "d3";
22
import {defined} from "../defined.js";
3-
import {percentile, take} from "../options.js";
3+
import {isWeaklyNumeric, percentile, take} from "../options.js";
44
import {warn} from "../warnings.js";
55
import {mapX, mapY} from "./map.js";
66

@@ -96,6 +96,7 @@ function reduceNumbers(f) {
9696
strict
9797
? {
9898
map(I, S, T) {
99+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
99100
const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : S[i]));
100101
let nans = 0;
101102
for (let i = 0; i < k - 1; ++i) if (isNaN(C[i])) ++nans;
@@ -108,6 +109,7 @@ function reduceNumbers(f) {
108109
}
109110
: {
110111
map(I, S, T) {
112+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
111113
const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : S[i]));
112114
for (let i = -s; i < 0; ++i) {
113115
T[I[i + s]] = f(C.subarray(0, i + k));
@@ -149,6 +151,7 @@ function reduceSum(k, s, strict) {
149151
return strict
150152
? {
151153
map(I, S, T) {
154+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
152155
let nans = 0;
153156
let sum = 0;
154157
for (let i = 0; i < k - 1; ++i) {
@@ -169,6 +172,7 @@ function reduceSum(k, s, strict) {
169172
}
170173
: {
171174
map(I, S, T) {
175+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
172176
let sum = 0;
173177
const n = I.length;
174178
for (let i = 0, j = Math.min(n, k - s - 1); i < j; ++i) {
@@ -188,6 +192,7 @@ function reduceMean(k, s, strict) {
188192
const sum = reduceSum(k, s, strict);
189193
return {
190194
map(I, S, T) {
195+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
191196
sum.map(I, S, T);
192197
for (let i = 0, n = I.length - k + 1; i < n; ++i) {
193198
T[I[i + s]] /= k;
@@ -197,6 +202,7 @@ function reduceMean(k, s, strict) {
197202
} else {
198203
return {
199204
map(I, S, T) {
205+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
200206
let sum = 0;
201207
let count = 0;
202208
const n = I.length;
@@ -248,6 +254,7 @@ function reduceDifference(k, s, strict) {
248254
return strict
249255
? {
250256
map(I, S, T) {
257+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
251258
for (let i = 0, n = I.length - k; i < n; ++i) {
252259
const a = S[I[i]];
253260
const b = S[I[i + k - 1]];
@@ -257,6 +264,7 @@ function reduceDifference(k, s, strict) {
257264
}
258265
: {
259266
map(I, S, T) {
267+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
260268
for (let i = -s, n = I.length - k + s + 1; i < n; ++i) {
261269
T[I[i + s]] = lastNumber(S, I, i, k) - firstNumber(S, I, i, k);
262270
}
@@ -268,6 +276,7 @@ function reduceRatio(k, s, strict) {
268276
return strict
269277
? {
270278
map(I, S, T) {
279+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
271280
for (let i = 0, n = I.length - k; i < n; ++i) {
272281
const a = S[I[i]];
273282
const b = S[I[i + k - 1]];
@@ -277,6 +286,7 @@ function reduceRatio(k, s, strict) {
277286
}
278287
: {
279288
map(I, S, T) {
289+
if (!isWeaklyNumeric(S)) throw new Error("non-numeric data");
280290
for (let i = -s, n = I.length - k + s + 1; i < n; ++i) {
281291
T[I[i + s]] = lastNumber(S, I, i, k) / firstNumber(S, I, i, k);
282292
}

test/transforms/normalize-test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ it("Plot.normalize deviation doesn’t crash on equal values", () => {
4141
testNormalize([1, 1], "deviation", [0, 0]);
4242
});
4343

44+
it("normalizeX throws on non-numeric values", () => {
45+
const data = [null, "A", 10, 8];
46+
testNormalizeThrows(data);
47+
testNormalizeThrows(data, "first");
48+
testNormalizeThrows(data, "last");
49+
testNormalizeThrows(data, "mean");
50+
testNormalizeThrows(data, "sum");
51+
testNormalizeThrows(data, "deviation");
52+
});
53+
4454
function testNormalize(data, basis, r) {
4555
const mark = Plot.dot(data, Plot.normalizeY(basis, {y: data}));
4656
const {
@@ -50,3 +60,8 @@ function testNormalize(data, basis, r) {
5060
} = mark.initialize();
5161
assert.deepStrictEqual(Y, r);
5262
}
63+
64+
function testNormalizeThrows(data, basis) {
65+
const mark = Plot.dot(data, Plot.normalizeX({x: data, basis}));
66+
assert.throws(() => mark.initialize());
67+
}

test/transforms/reduce-test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ it("baked-in reducers reduce as expected", () => {
1010
testReducer(data, "min", 0);
1111
testReducer(data, "sum", 21);
1212
testReducer(data, "variance", 10.7);
13+
testReducer(data, "mode", 0);
14+
});
15+
16+
it("baked-in non-numeric reducers throw on non-numeric data", () => {
17+
const data = ["A", "B", "C", "B"];
18+
testReducer(data, "min", "A");
19+
testReducer(data, "max", "C");
20+
testReducer(data, "mode", "B");
1321
});
1422

1523
it("function reducers reduce as expected", () => {
@@ -18,12 +26,39 @@ it("function reducers reduce as expected", () => {
1826
testReducer(data, (v) => v.join(", "), "0, 1, 2, 4, 5, 9");
1927
});
2028

29+
it.only("baked-in numeric reducers throw on non-numeric data", () => {
30+
const data = [null, "A", 1, 2, 3];
31+
testReducerThrows(data, "deviation");
32+
testReducerThrows(data, "mean");
33+
testReducerThrows(data, "median");
34+
testReducerThrows(data, "sum");
35+
testReducerThrows(data, "variance");
36+
});
37+
38+
it("baked-in numeric reducers accept and return temporal data", () => {
39+
const data = [null, new Date(2001, 0, 1), new Date(2002, 0, 3), 0];
40+
testReducer(data, "mean", new Date("1991-01-01T23:20:00.000Z"));
41+
testReducer(data, "median", new Date("2000-12-31T23:00:00.000Z"));
42+
});
43+
44+
it("baked-in numeric reducers accept temporal data", () => {
45+
const data = [null, new Date(2001, 0, 1), new Date(2002, 0, 2)];
46+
testReducer(data, "deviation", 22360413477.393482);
47+
testReducer(data, "sum", 1988229600000);
48+
testReducer(data, "variance", 499988090880000000000);
49+
});
50+
2151
function testReducer(data, x, r) {
22-
const mark = Plot.dot(data, Plot.groupZ({x}, {x: (d) => d}));
52+
const mark = Plot.dot(data, Plot.groupZ({x}, {x: data}));
2353
const {
2454
channels: {
2555
x: {value: X}
2656
}
2757
} = mark.initialize();
2858
assert.deepStrictEqual(X, [r]);
2959
}
60+
61+
function testReducerThrows(data, x) {
62+
const mark = Plot.dot(data, Plot.groupZ({x}, {x: data}));
63+
assert.throws(() => mark.initialize());
64+
}

test/transforms/window-test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,54 @@ it(`windowX({reduce: "last", k}) does not coerce to numbers`, () => {
311311
const m3 = applyTransform(Plot.windowX({reduce: "last", k: 3, x: (d) => d}), data);
312312
assert.deepStrictEqual(m3.x.transform(), ["B", "A", "A", "C", "C", "A", "B", "B", "B", "B"]);
313313
});
314+
315+
/* eslint-disable no-sparse-arrays */
316+
/* eslint-disable comma-dangle */
317+
it("window computes a moving average", () => {
318+
const data = range(6);
319+
const m1 = Plot.windowX({k: 1, x: (d) => d, strict: true});
320+
m1.transform(data, [range(data.length)]);
321+
assert.deepStrictEqual(m1.x.transform(), [0, 1, 2, 3, 4, 5]);
322+
const m2 = Plot.windowX({k: 2, x: (d) => d, strict: true});
323+
m2.transform(data, [range(data.length)]);
324+
assert.deepStrictEqual(m2.x.transform(), [0.5, 1.5, 2.5, 3.5, 4.5, ,]);
325+
const m3 = Plot.windowX({k: 3, x: (d) => d, strict: true});
326+
m3.transform(data, [range(data.length)]);
327+
assert.deepStrictEqual(m3.x.transform(), [, 1, 2, 3, 4, ,]);
328+
const m4 = Plot.windowX({k: 4, x: (d) => d, strict: true});
329+
m4.transform(data, [range(data.length)]);
330+
assert.deepStrictEqual(m4.x.transform(), [, 1.5, 2.5, 3.5, , ,]);
331+
});
332+
333+
it("window skips NaN", () => {
334+
const data = [1, 1, 1, null, 1, 1, 1, 1, 1, NaN, 1, 1, 1];
335+
const m3 = Plot.windowX({k: 3, x: (d) => d, strict: true});
336+
m3.transform(data, [range(data.length)]);
337+
assert.deepStrictEqual(m3.x.transform(), [, 1, NaN, NaN, NaN, 1, 1, 1, NaN, NaN, NaN, 1, ,]);
338+
});
339+
340+
it("window treats null as NaN", () => {
341+
const data = [1, 1, 1, null, 1, 1, 1, 1, 1, null, 1, 1, 1];
342+
const m3 = Plot.windowX({k: 3, x: (d) => d, strict: true});
343+
m3.transform(data, [range(data.length)]);
344+
assert.deepStrictEqual(m3.x.transform(), [, 1, NaN, NaN, NaN, 1, 1, 1, NaN, NaN, NaN, 1, ,]);
345+
});
346+
347+
it("window respects anchor", () => {
348+
const data = [0, 1, 2, 3, 4, 5];
349+
const mc = Plot.windowX({k: 3, x: (d) => d, strict: true});
350+
mc.transform(data, [range(data.length)]);
351+
assert.deepStrictEqual(mc.x.transform(), [, 1, 2, 3, 4, ,]);
352+
const ml = Plot.windowX({k: 3, anchor: "start", strict: true, x: (d) => d});
353+
ml.transform(data, [range(data.length)]);
354+
assert.deepStrictEqual(ml.x.transform(), [1, 2, 3, 4, , ,]);
355+
const mt = Plot.windowX({k: 3, anchor: "end", strict: true, x: (d) => d});
356+
mt.transform(data, [range(data.length)]);
357+
assert.deepStrictEqual(mt.x.transform(), [, , 1, 2, 3, 4]);
358+
});
359+
360+
it("window throws on non-numeric data", () => {
361+
const data = [null, "A", 1, 2, 3, 4, 5];
362+
const mc = Plot.windowX({k: 3, x: (d) => d});
363+
assert.throws(() => mc.transform(data, [range(data.length)]));
364+
});

0 commit comments

Comments
 (0)