mirror of
https://github.com/apache/superset.git
synced 2026-04-25 11:04:48 +00:00
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
|
|
import { ColorBreakpointType } from './types';
|
|
|
|
describe('getColorBreakpointsBuckets', () => {
|
|
it('returns correct buckets for multiple breakpoints', () => {
|
|
const color_breakpoints: ColorBreakpointType[] = [
|
|
{ minValue: 0, maxValue: 10, color: { r: 255, g: 0, b: 0, a: 100 } },
|
|
{ minValue: 11, maxValue: 20, color: { r: 0, g: 255, b: 0, a: 100 } },
|
|
{ minValue: 21, maxValue: 30, color: { r: 0, g: 0, b: 255, a: 100 } },
|
|
];
|
|
const result = getColorBreakpointsBuckets(color_breakpoints);
|
|
expect(result).toEqual({
|
|
'0 - 10': { color: [255, 0, 0], enabled: true },
|
|
'11 - 20': { color: [0, 255, 0], enabled: true },
|
|
'21 - 30': { color: [0, 0, 255], enabled: true },
|
|
});
|
|
});
|
|
|
|
it('returns empty object if color_breakpoints is empty', () => {
|
|
const result = getColorBreakpointsBuckets([]);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('returns empty object if color_breakpoints is missing', () => {
|
|
const result = getColorBreakpointsBuckets({} as any);
|
|
expect(result).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('getBreakPoints', () => {
|
|
const accessor = (d: any) => d.value;
|
|
|
|
describe('automatic breakpoint generation', () => {
|
|
it('generates correct number of breakpoints for given buckets', () => {
|
|
const features = [{ value: 0 }, { value: 50 }, { value: 100 }];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toHaveLength(6); // n buckets = n+1 breakpoints
|
|
expect(breakPoints.every(bp => typeof bp === 'string')).toBe(true);
|
|
});
|
|
|
|
it('ensures data range is fully covered', () => {
|
|
// Test various data ranges to ensure min/max are always included
|
|
const testCases = [
|
|
{ data: [0, 100], buckets: 5 },
|
|
{ data: [0.1, 99.9], buckets: 4 },
|
|
{ data: [-50, 50], buckets: 10 },
|
|
{ data: [3.2, 38.7], buckets: 5 }, // Original max bug case
|
|
{ data: [3.14, 100], buckets: 5 }, // Min rounding bug case (3.14 -> 3)
|
|
{ data: [2.345, 10], buckets: 4 }, // Min rounding bug case (2.345 -> 2.35)
|
|
{ data: [0.0001, 0.0009], buckets: 3 }, // Very small numbers
|
|
{ data: [1000000, 9000000], buckets: 8 }, // Large numbers
|
|
];
|
|
|
|
testCases.forEach(({ data, buckets }) => {
|
|
const [min, max] = data;
|
|
const features = [{ value: min }, { value: max }];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: String(buckets) },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
// Critical: min and max must be within the breakpoint range
|
|
expect(firstBp).toBeLessThanOrEqual(min);
|
|
expect(lastBp).toBeGreaterThanOrEqual(max);
|
|
expect(breakPoints).toHaveLength(buckets + 1);
|
|
});
|
|
});
|
|
|
|
it('handles uniform distribution correctly', () => {
|
|
const features = [
|
|
{ value: 0 },
|
|
{ value: 25 },
|
|
{ value: 50 },
|
|
{ value: 75 },
|
|
{ value: 100 },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '4' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
// Check that breakpoints are evenly spaced
|
|
const numericBreakPoints = breakPoints.map(parseFloat);
|
|
const deltas = [];
|
|
for (let i = 1; i < numericBreakPoints.length; i += 1) {
|
|
deltas.push(numericBreakPoints[i] - numericBreakPoints[i - 1]);
|
|
}
|
|
|
|
// All deltas should be approximately equal
|
|
const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
|
deltas.forEach(delta => {
|
|
expect(delta).toBeCloseTo(avgDelta, 1);
|
|
});
|
|
});
|
|
|
|
it('handles single value datasets', () => {
|
|
const features = [{ value: 42 }, { value: 42 }, { value: 42 }];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
expect(firstBp).toBeLessThanOrEqual(42);
|
|
expect(lastBp).toBeGreaterThanOrEqual(42);
|
|
});
|
|
|
|
it('preserves appropriate precision for different scales', () => {
|
|
const testCases = [
|
|
{ data: [0, 1], expectedMaxPrecision: 1 }, // 0.0, 0.2, 0.4...
|
|
{ data: [0, 0.1], expectedMaxPrecision: 2 }, // 0.00, 0.02...
|
|
{ data: [0, 0.01], expectedMaxPrecision: 3 }, // 0.000, 0.002...
|
|
{ data: [0, 1000], expectedMaxPrecision: 0 }, // 0, 200, 400...
|
|
];
|
|
|
|
testCases.forEach(({ data, expectedMaxPrecision }) => {
|
|
const [min, max] = data;
|
|
const features = [{ value: min }, { value: max }];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
breakPoints.forEach(bp => {
|
|
const decimalPlaces = (bp.split('.')[1] || '').length;
|
|
expect(decimalPlaces).toBeLessThanOrEqual(expectedMaxPrecision);
|
|
});
|
|
});
|
|
});
|
|
|
|
it('handles negative values correctly', () => {
|
|
const features = [
|
|
{ value: -100 },
|
|
{ value: -50 },
|
|
{ value: 0 },
|
|
{ value: 50 },
|
|
{ value: 100 },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const numericBreakPoints = breakPoints.map(parseFloat);
|
|
expect(numericBreakPoints[0]).toBeLessThanOrEqual(-100);
|
|
expect(
|
|
numericBreakPoints[numericBreakPoints.length - 1],
|
|
).toBeGreaterThanOrEqual(100);
|
|
|
|
// Verify ascending order
|
|
for (let i = 1; i < numericBreakPoints.length; i += 1) {
|
|
expect(numericBreakPoints[i]).toBeGreaterThan(
|
|
numericBreakPoints[i - 1],
|
|
);
|
|
}
|
|
});
|
|
|
|
it('handles mixed integer and decimal values', () => {
|
|
const features = [
|
|
{ value: 1 },
|
|
{ value: 2.5 },
|
|
{ value: 3.7 },
|
|
{ value: 5 },
|
|
{ value: 8.2 },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '4' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
expect(firstBp).toBeLessThanOrEqual(1);
|
|
expect(lastBp).toBeGreaterThanOrEqual(8.2);
|
|
});
|
|
|
|
it('uses floor/ceil for boundary breakpoints to ensure inclusion', () => {
|
|
// Test that Math.floor and Math.ceil are used for boundaries
|
|
// This ensures all data points fall within the breakpoint range
|
|
|
|
const testCases = [
|
|
{ minValue: 3.14, maxValue: 100, buckets: 5 },
|
|
{ minValue: 2.345, maxValue: 10.678, buckets: 4 },
|
|
{ minValue: 1.67, maxValue: 5.33, buckets: 3 },
|
|
{ minValue: 0.123, maxValue: 0.987, buckets: 5 },
|
|
];
|
|
|
|
testCases.forEach(({ minValue, maxValue, buckets }) => {
|
|
const features = [{ value: minValue }, { value: maxValue }];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: String(buckets) },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
// First breakpoint should be floored (always <= minValue)
|
|
expect(firstBp).toBeLessThanOrEqual(minValue);
|
|
|
|
// Last breakpoint should be ceiled (always >= maxValue)
|
|
expect(lastBp).toBeGreaterThanOrEqual(maxValue);
|
|
|
|
// All values should be within range
|
|
expect(minValue).toBeGreaterThanOrEqual(firstBp);
|
|
expect(maxValue).toBeLessThanOrEqual(lastBp);
|
|
});
|
|
});
|
|
|
|
it('prevents minimum value exclusion edge case', () => {
|
|
// Specific edge case test for minimum value exclusion
|
|
// Tests the exact scenario where rounding would exclude the min value
|
|
|
|
const features = [
|
|
{ value: 3.14 }, // This would round to 3 at precision 0
|
|
{ value: 50 },
|
|
{ value: 100 },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
|
|
// The first breakpoint must be <= 3.14 (floor behavior)
|
|
expect(firstBp).toBeLessThanOrEqual(3.14);
|
|
|
|
// Verify that 3.14 is not excluded
|
|
expect(3.14).toBeGreaterThanOrEqual(firstBp);
|
|
|
|
// The first breakpoint should be a clean floor value
|
|
expect(breakPoints[0]).toMatch(/^3(\.0*)?$/);
|
|
});
|
|
|
|
it('prevents maximum value exclusion edge case', () => {
|
|
// Specific edge case test for maximum value exclusion
|
|
// Tests the exact scenario where rounding would exclude the max value
|
|
|
|
const features = [
|
|
{ value: 0 },
|
|
{ value: 20 },
|
|
{ value: 38.7 }, // Original bug case
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
// The last breakpoint must be >= 38.7 (ceil behavior)
|
|
expect(lastBp).toBeGreaterThanOrEqual(38.7);
|
|
|
|
// Verify that 38.7 is not excluded
|
|
expect(38.7).toBeLessThanOrEqual(lastBp);
|
|
|
|
// The last breakpoint should be a clean ceil value
|
|
expect(breakPoints[breakPoints.length - 1]).toMatch(/^39(\.0*)?$/);
|
|
});
|
|
});
|
|
|
|
describe('custom breakpoints', () => {
|
|
it('uses custom breakpoints when provided', () => {
|
|
const features = [{ value: 5 }, { value: 15 }, { value: 25 }];
|
|
const customBreakPoints = ['0', '10', '20', '30', '40'];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: customBreakPoints, num_buckets: '' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual(['0', '10', '20', '30', '40']);
|
|
});
|
|
|
|
it('sorts custom breakpoints in ascending order', () => {
|
|
const features = [{ value: 5 }];
|
|
const customBreakPoints = ['30', '10', '0', '20'];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: customBreakPoints, num_buckets: '' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual(['0', '10', '20', '30']);
|
|
});
|
|
|
|
it('ignores num_buckets when custom breakpoints are provided', () => {
|
|
const features = [{ value: 5 }];
|
|
const customBreakPoints = ['0', '50', '100'];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: customBreakPoints, num_buckets: '10' }, // num_buckets should be ignored
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual(['0', '50', '100']);
|
|
expect(breakPoints).toHaveLength(3); // not 11
|
|
});
|
|
});
|
|
|
|
describe('edge cases and error handling', () => {
|
|
it('returns empty array when features are undefined', () => {
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
undefined as any,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array when features is null', () => {
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
null as any,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array when all values are undefined', () => {
|
|
const features = [
|
|
{ value: undefined },
|
|
{ value: undefined },
|
|
{ value: undefined },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual([]);
|
|
});
|
|
|
|
it('handles empty features array', () => {
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
[],
|
|
accessor,
|
|
);
|
|
|
|
expect(breakPoints).toEqual([]);
|
|
});
|
|
|
|
it('handles string values that can be parsed as numbers', () => {
|
|
const features = [
|
|
{ value: '10.5' },
|
|
{ value: '20.3' },
|
|
{ value: '30.7' },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '3' },
|
|
features,
|
|
(d: any) =>
|
|
typeof d.value === 'string' ? parseFloat(d.value) : d.value,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
expect(firstBp).toBeLessThanOrEqual(10.5);
|
|
expect(lastBp).toBeGreaterThanOrEqual(30.7);
|
|
});
|
|
|
|
it('uses default number of buckets when not specified', () => {
|
|
const features = [{ value: 0 }, { value: 100 }];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
// Should use DEFAULT_NUM_BUCKETS (10)
|
|
expect(breakPoints).toHaveLength(11); // 10 buckets = 11 breakpoints
|
|
});
|
|
|
|
it('handles Infinity and -Infinity values', () => {
|
|
const features = [
|
|
{ value: -Infinity },
|
|
{ value: 0 },
|
|
{ value: Infinity },
|
|
];
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
// Should return empty array when Infinity values are present
|
|
expect(breakPoints).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('breakpoint boundaries validation', () => {
|
|
it('ensures no data points fall outside breakpoint range', () => {
|
|
// Generate random test data
|
|
const generateRandomData = (count: number, min: number, max: number) => {
|
|
const data = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
data.push({ value: Math.random() * (max - min) + min });
|
|
}
|
|
return data;
|
|
};
|
|
|
|
// Test with various random datasets
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const features = generateRandomData(20, -1000, 1000);
|
|
const minValue = Math.min(...features.map(f => f.value));
|
|
const maxValue = Math.max(...features.map(f => f.value));
|
|
|
|
const breakPoints = getBreakPoints(
|
|
{ break_points: [], num_buckets: '5' },
|
|
features,
|
|
accessor,
|
|
);
|
|
|
|
const firstBp = parseFloat(breakPoints[0]);
|
|
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
|
|
|
|
// Every data point should fall within the breakpoint range
|
|
features.forEach(feature => {
|
|
expect(feature.value).toBeGreaterThanOrEqual(firstBp);
|
|
expect(feature.value).toBeLessThanOrEqual(lastBp);
|
|
});
|
|
|
|
// The range should be as tight as possible while including all data
|
|
expect(firstBp).toBeLessThanOrEqual(minValue);
|
|
expect(lastBp).toBeGreaterThanOrEqual(maxValue);
|
|
}
|
|
});
|
|
});
|
|
});
|