chartjs-plugin-trendline
Version:
Trendline for Chart.js
486 lines (429 loc) • 23.6 kB
JavaScript
import { addFitter } from './trendline';
import 'jest-canvas-mock';
import { LineFitter } from '../utils/lineFitter';
import * as drawingUtils from '../utils/drawing';
import * as labelUtils from './label';
jest.mock('../utils/lineFitter');
jest.mock('../utils/drawing', () => ({
drawTrendline: jest.fn(),
fillBelowTrendline: jest.fn(),
setLineStyle: jest.fn(),
}));
jest.mock('./label', () => ({ addTrendlineLabel: jest.fn() }));
describe('addFitter', () => {
let mockCtx;
let mockDatasetMeta;
let mockDataset;
let mockXScale;
let mockYScale;
let mockLineFitterInstance;
beforeEach(() => {
jest.clearAllMocks();
mockLineFitterInstance = {
add: jest.fn(),
f: jest.fn(x => x * 2 + 1),
slope: jest.fn(() => 2),
intercept: jest.fn(() => 1),
fo: jest.fn(() => 50),
scale: jest.fn(() => 1),
minx: undefined,
maxx: undefined,
count: 0,
sumx: 0, sumy: 0, sumx2: 0, sumxy: 0,
};
LineFitter.mockImplementation(() => mockLineFitterInstance);
mockCtx = {
save: jest.fn(), translate: jest.fn(), rotate: jest.fn(), fillText: jest.fn(),
measureText: jest.fn(() => ({ width: 50 })), font: '', fillStyle: '',
strokeStyle: '', lineWidth: 0, beginPath: jest.fn(), moveTo: jest.fn(),
lineTo: jest.fn(), stroke: jest.fn(), restore: jest.fn(),
};
mockDatasetMeta = {
controller: {
chart: {
scales: { 'y': { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) } },
options: { parsing: { xAxisKey: 'x', yAxisKey: 'y' } },
chartArea: { top: 50, bottom: 450, left: 50, right: 750, width: 700, height: 400 },
data: { labels: [] }
}
},
data: [{x:0, y:0}]
};
mockXScale = {
getPixelForValue: jest.fn(val => val * 10),
getValueForPixel: jest.fn(pixel => pixel / 10),
options: { type: 'linear' }
};
mockYScale = { getPixelForValue: jest.fn(val => val * 10), getValueForPixel: jest.fn(pixel => pixel / 10) };
mockDataset = {
data: [ { x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 } ],
yAxisID: 'y',
borderColor: 'blue', borderWidth: 2,
trendlineLinear: {
colorMin: 'red', colorMax: 'red', width: 3, lineStyle: 'dashed',
fillColor: false, trendoffset: 0, projection: false,
xAxisKey: 'x', yAxisKey: 'y',
label: { display: true, text: 'My Trend', color: 'black', offset: 5, displayValue: true, percentage: false, font: { family: 'Arial', size: 12 } }
}
};
});
test('Scenario 1: Simple linear data, no projection, no offset', () => {
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 30;
mockLineFitterInstance.count = 3;
mockLineFitterInstance.f = jest.fn(x => {
if (x === 10) return 21;
if (x === 30) return 61;
return 2*x+1;
});
mockXScale.getPixelForValue = jest.fn(val => {
if (val === 10) return 100;
if (val === 30) return 300;
return val*10;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 21) return 210;
if (val === 61) return 610;
return val*10;
});
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
// Expecting clipped coordinates based on previous log analysis {"x1":100,"y1":210,"x2":220,"y2":450}
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
expect(labelUtils.addTrendlineLabel).toHaveBeenCalledTimes(1);
});
test('Scenario 2: Data with null or undefined values', () => {
mockDataset.data = [ { x: 10, y: 30 }, null, { x: 20, y: 50 }, undefined, { x: 30, y: 70 } ];
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 30;
mockLineFitterInstance.count = 3;
mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : 2 * x + 1)));
mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : val*10) ));
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : val*10)));
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
// Expecting clipped coordinates as per Scenario 1, since data and chartArea are similar enough
// for the same clipping to occur on the unclipped (100,210)-(300,610) line.
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
expect(labelUtils.addTrendlineLabel).toHaveBeenCalledTimes(1);
});
test('Scenario 3: trendlineLinear.projection is true', () => {
mockDataset.trendlineLinear.projection = true;
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.count = 3;
mockLineFitterInstance.slope = jest.fn(() => 2);
mockLineFitterInstance.intercept = jest.fn(() => 1);
mockLineFitterInstance.f = jest.fn(x => mockLineFitterInstance.slope() * x + mockLineFitterInstance.intercept());
// Expected data values for intersections based on y=2x+1 and chartArea {t:50,b:450,l:50,r:750}, scales val = px/10
// For the filter in trendline.js: actualChartMinY = 5, actualChartMaxY = 45.
// Valid intersection points (data values): (5,11) and (22,45)
mockXScale.getPixelForValue = jest.fn(val => {
if (val === 5) return 50;
if (val === 22) return 220;
return NaN;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 11) return 110;
if (val === 45) return 450;
return NaN;
});
mockXScale.getValueForPixel = jest.fn(pixel => {
if (pixel === mockDatasetMeta.controller.chart.chartArea.left) return 5;
if (pixel === mockDatasetMeta.controller.chart.chartArea.right) return 75;
return NaN;
});
// This mock ensures that chartMinY/MaxY are correctly ordered for the filter
// based on the corrected logic in trendline.js (Math.min/max of top/bottom data values)
mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => {
if (pixel === mockDatasetMeta.controller.chart.chartArea.top) return 5;
if (pixel === mockDatasetMeta.controller.chart.chartArea.bottom) return 45;
return NaN;
});
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 50, y1: 110, x2: 220, y2: 450 }));
});
test('Scenario 3b: trendlineLinear.projection is true (negative slope)', () => {
mockDataset.trendlineLinear.projection = true;
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.count = 3;
mockLineFitterInstance.slope = jest.fn(() => -2);
mockLineFitterInstance.intercept = jest.fn(() => 100);
mockLineFitterInstance.f = jest.fn(x => mockLineFitterInstance.slope() * x + mockLineFitterInstance.intercept());
// Expected data values for intersections based on y=-2x+100
// For the filter in trendline.js: actualChartMinY = 5, actualChartMaxY = 45.
// Valid intersection points (data values): (27.5,45) and (47.5,5)
mockXScale.getPixelForValue = jest.fn(val => {
if (val === 27.5) return 275;
if (val === 47.5) return 475;
return NaN;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 45) return 450;
if (val === 5) return 50;
return NaN;
});
mockXScale.getValueForPixel = jest.fn(pixel => (pixel === 50 ? 5 : (pixel === 750 ? 75 : NaN)));
mockDatasetMeta.controller.chart.scales.y.getValueForPixel = jest.fn(pixel => (pixel === 50 ? 5 : (pixel === 450 ? 45 : NaN)));
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 275, y1: 450, x2: 475, y2: 50 }));
});
test('Scenario 4: trendoffset (positive)', () => {
mockDataset.trendlineLinear.trendoffset = 1;
mockDataset.data = [{ x: 5, y: 10 }, { x: 10, y: 30 }, { x: 20, y: 50 }];
mockDatasetMeta.data = [{x:5, y:10}];
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 20;
mockLineFitterInstance.count = 2;
mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 20 ? 41 : NaN)));
mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 20 ? 200 : NaN)));
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 41 ? 410 : NaN)));
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 200, y2: 410 }));
});
test('Scenario 5: trendoffset (negative)', () => {
mockDataset.trendlineLinear.trendoffset = -1;
mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 }];
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 20;
mockLineFitterInstance.count = 2;
mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 20 ? 41 : NaN)));
mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 20 ? 200 : NaN)));
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 41 ? 410 : NaN)));
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 200, y2: 410 }));
});
test('Scenario 5b: trendoffset (negative) to exclude all but one point', () => {
mockDataset.trendlineLinear.trendoffset = -2;
mockDataset.data = [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }];
mockDatasetMeta.data = [{x:0, y:0}];
mockLineFitterInstance.minx = 0;
mockLineFitterInstance.maxx = 0;
mockLineFitterInstance.count = 1;
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(0,0);
expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
});
test('Scenario 5c: trendoffset (negative) where it makes firstIndex search start beyond array bounds', () => {
mockDataset.trendlineLinear.trendoffset = -3;
mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }, { x: 30, y: 70 }];
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.count = 3;
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 30;
mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : NaN)));
mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : NaN)));
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : NaN)));
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(3);
// Expect clipped coordinates based on Scenario 1's log output
expect(drawingUtils.drawTrendline).toHaveBeenCalledWith(expect.objectContaining({ x1: 100, y1: 210, x2: 220, y2: 450 }));
});
test('Scenario 6: fillColor is true', () => {
mockDataset.trendlineLinear.fillColor = 'rgba(0,0,255,0.1)';
mockDatasetMeta.data = [{x:10, y:30}];
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 30;
mockLineFitterInstance.count = 3;
mockLineFitterInstance.f = jest.fn(x => (x === 10 ? 21 : (x === 30 ? 61 : NaN)));
mockXScale.getPixelForValue = jest.fn(val => (val === 10 ? 100 : (val === 30 ? 300 : NaN)));
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => (val === 21 ? 210 : (val === 61 ? 610 : NaN)));
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledTimes(1);
// Expect clipped coordinates based on Scenario 1's log output
expect(drawingUtils.fillBelowTrendline).toHaveBeenCalledWith(
mockCtx, 100, 210, 220, 450,
mockDatasetMeta.controller.chart.chartArea.bottom,
mockDataset.trendlineLinear.fillColor
);
});
test('Handles time scale data correctly', () => {
mockXScale.options.type = 'timeseries';
const date1 = new Date('2023-01-01T00:00:00.000Z');
const date2 = new Date('2023-01-02T00:00:00.000Z');
const date1Str = date1.toISOString();
const date2Str = date2.toISOString();
mockDataset.data = [ { x: date1Str, y: 10 }, { t: date2Str, y: 20 } ];
mockDataset.trendlineLinear.xAxisKey = 'x';
mockDatasetMeta.data = [{ x: date1Str, y: 10 }];
const date1Ts = date1.getTime();
const date2Ts = date2.getTime();
mockLineFitterInstance.minx = date1Ts;
mockLineFitterInstance.maxx = date2Ts;
mockLineFitterInstance.count = 2;
mockLineFitterInstance.f = jest.fn(t => {
if (t === date1Ts) return 1.0;
if (t === date2Ts) return 2.0;
return NaN;
});
mockXScale.getPixelForValue = jest.fn(t => {
if (t === date1Ts) return 50;
if (t === date2Ts) return 150;
return NaN;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 1.0) return 10;
if (val === 2.0) return 20;
return NaN;
});
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date1.getTime(), 10);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date2.getTime(), 20);
// The line (50,10) to (150,20) is outside chartArea.top=50, so it should be clipped out.
expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
});
test('Handles data where x or y might be missing for object-type data but not timeseries', () => {
mockXScale.options.type = 'linear';
mockDataset.data = [ { x: 10, y: 30 }, { x: 20 }, { y: 70 }, { value: 90 } ];
mockDatasetMeta.data = mockDataset.data;
mockLineFitterInstance.count = 1;
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 10;
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(10, 30);
expect(drawingUtils.setLineStyle).not.toHaveBeenCalled();
expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
});
test('Uses parsingOptions for xAxisKey and yAxisKey if specific ones are not provided', () => {
delete mockDataset.trendlineLinear.xAxisKey;
delete mockDataset.trendlineLinear.yAxisKey;
mockDatasetMeta.controller.chart.options.parsing = { xAxisKey: 'customX', yAxisKey: 'customY' };
mockDataset.data = [{ customX: 5, customY: 15 }];
mockDatasetMeta.data = [{customX: 5}];
mockLineFitterInstance.count = 1;
mockLineFitterInstance.minx = 5;
mockLineFitterInstance.maxx = 5;
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(1);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(5, 15);
expect(drawingUtils.setLineStyle).not.toHaveBeenCalled();
expect(drawingUtils.drawTrendline).not.toHaveBeenCalled();
expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled();
});
test('Configuration without label property (issue #118)', () => {
// Test case for user's exact configuration that was failing
mockDataset.trendlineLinear = {
lineStyle: 'dotted',
width: 2
};
mockDataset.data = [10, 20, 30, 40, 50];
mockDatasetMeta.data = [10, 20, 30, 40, 50];
mockLineFitterInstance.minx = 0;
mockLineFitterInstance.maxx = 4;
mockLineFitterInstance.count = 5;
mockLineFitterInstance.f = jest.fn(x => {
if (x === 0) return 100;
if (x === 4) return 200;
return x * 25 + 100;
});
mockXScale.getPixelForValue = jest.fn(val => {
if (val === 0) return 100;
if (val === 4) return 400;
return val * 100 + 100;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 100) return 200;
if (val === 200) return 300;
return val * 1 + 100;
});
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(5);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(0, 10);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(1, 20);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(2, 30);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(3, 40);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(4, 50);
expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dotted');
expect(drawingUtils.drawTrendline).toHaveBeenCalled();
expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
});
test('Configuration without label property but with other properties', () => {
// Test minimal configuration similar to issue #118
mockDataset.trendlineLinear = {
colorMin: 'red',
width: 3,
lineStyle: 'dashed'
};
mockDataset.data = [{ x: 10, y: 30 }, { x: 20, y: 50 }];
mockDatasetMeta.data = [{ x: 10, y: 30 }];
mockLineFitterInstance.minx = 10;
mockLineFitterInstance.maxx = 20;
mockLineFitterInstance.count = 2;
mockLineFitterInstance.f = jest.fn(x => {
if (x === 10) return 30;
if (x === 20) return 50;
return x * 2 + 10;
});
mockXScale.getPixelForValue = jest.fn(val => {
if (val === 10) return 100;
if (val === 20) return 200;
return val * 10;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 30) return 200;
if (val === 50) return 300;
return val * 10;
});
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(2);
expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dashed');
expect(drawingUtils.drawTrendline).toHaveBeenCalled();
expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
});
test('Time scale with array of numbers and no label property (issue #118)', () => {
// Test case for time scale with array data (like lineChartTypeTime.html)
mockXScale.options.type = 'time';
mockDataset.trendlineLinear = {
lineStyle: 'dotted',
width: 2
};
mockDataset.data = [75, 64, 52, 23, 44]; // Just numbers like in the example
mockDatasetMeta.data = [75, 64, 52, 23, 44];
mockDatasetMeta.controller.chart.data.labels = [
'2025-03-01T00:00:00',
'2025-03-02T00:00:00',
'2025-03-03T00:00:00',
'2025-03-04T00:00:00',
'2025-03-05T00:00:00'
];
const date1 = new Date('2025-03-01T00:00:00').getTime();
const date5 = new Date('2025-03-05T00:00:00').getTime();
mockLineFitterInstance.minx = date1;
mockLineFitterInstance.maxx = date5;
mockLineFitterInstance.count = 5;
mockLineFitterInstance.f = jest.fn(x => {
if (x === date1) return 75;
if (x === date5) return 44;
return 50; // approximation
});
mockXScale.getPixelForValue = jest.fn(val => {
if (val === date1) return 100;
if (val === date5) return 300;
return 200;
});
mockDatasetMeta.controller.chart.scales.y.getPixelForValue = jest.fn(val => {
if (val === 75) return 200;
if (val === 44) return 250;
return 225;
});
addFitter(mockDatasetMeta, mockCtx, mockDataset, mockXScale, mockYScale);
expect(mockLineFitterInstance.add).toHaveBeenCalledTimes(5);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(date1, 75);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-02T00:00:00').getTime(), 64);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-03T00:00:00').getTime(), 52);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-04T00:00:00').getTime(), 23);
expect(mockLineFitterInstance.add).toHaveBeenCalledWith(new Date('2025-03-05T00:00:00').getTime(), 44);
expect(drawingUtils.setLineStyle).toHaveBeenCalledWith(mockCtx, 'dotted');
expect(drawingUtils.drawTrendline).toHaveBeenCalled();
expect(labelUtils.addTrendlineLabel).not.toHaveBeenCalled(); // No label should be added
});
});