Skip to content

Commit

Permalink
Added run-time validation of inputs in debug mode
Browse files Browse the repository at this point in the history
Fixes #315
  • Loading branch information
timocov committed Sep 21, 2020
1 parent 89ef108 commit 83ddc3e
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 10 deletions.
12 changes: 10 additions & 2 deletions src/api/create-chart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ensureNotNull } from '../helpers/assertions';
import { assert } from '../helpers/assertions';
import { DeepPartial, isString } from '../helpers/strict-type-checks';

import { ChartOptions } from '../model/chart-model';
Expand Down Expand Up @@ -33,6 +33,14 @@ export {
* @returns an interface to the created chart
*/
export function createChart(container: string | HTMLElement, options?: DeepPartial<ChartOptions>): IChartApi {
const htmlElement = ensureNotNull(isString(container) ? document.getElementById(container) : container);
let htmlElement: HTMLElement;
if (isString(container)) {
const element = document.getElementById(container);
assert(element !== null, `Cannot find element in DOM with id=${container}`);
htmlElement = element;
} else {
htmlElement = container;
}

return new ChartApi(htmlElement, options);
}
4 changes: 4 additions & 0 deletions src/api/data-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export function isWhitespaceData(data: SeriesDataItemTypeMap[SeriesType]): data
return (data as Partial<BarData>).open === undefined && (data as Partial<LineData>).value === undefined;
}

export function isFulfilledData(data: SeriesDataItemTypeMap[SeriesType]): data is (BarData | LineData | HistogramData) {
return (data as Partial<BarData>).open !== undefined || (data as Partial<LineData>).value !== undefined;
}

export interface SeriesDataItemTypeMap {
Bar: BarData | WhitespaceData;
Candlestick: BarData | WhitespaceData;
Expand Down
93 changes: 93 additions & 0 deletions src/api/data-validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { assert, ensureNever } from '../helpers/assertions';

import { SeriesMarker } from '../model/series-markers';
import { SeriesType } from '../model/series-options';

import { isFulfilledData, SeriesDataItemTypeMap, Time } from './data-consumer';
import { convertTime } from './data-layer';

export function checkItemsAreOrdered(data: readonly (SeriesMarker<Time> | SeriesDataItemTypeMap[SeriesType])[], allowDuplicates: boolean = false): void {
if (process.env.NODE_ENV === 'production') {
return;
}

if (data.length === 0) {
return;
}

let prevTime = convertTime(data[0].time).timestamp;
for (let i = 1; i < data.length; ++i) {
const currentTime = convertTime(data[i].time).timestamp;
const checkResult = allowDuplicates ? prevTime <= currentTime : prevTime < currentTime;
assert(checkResult, `data must be asc ordered by time, index=${i}, time=${currentTime}, prev time=${prevTime}`);
prevTime = currentTime;
}
}

export function checkSeriesValuesType(type: SeriesType, data: readonly SeriesDataItemTypeMap[SeriesType][]): void {
if (process.env.NODE_ENV === 'production') {
return;
}

const checker = getChecker(type);
for (const item of data) {
checker(item);
}
}

type Checker = (item: SeriesDataItemTypeMap[SeriesType]) => void;

function getChecker(type: SeriesType): Checker {
switch (type) {
case 'Bar':
case 'Candlestick':
return checkBarItem.bind(null, type);

case 'Area':
case 'Line':
case 'Histogram':
return checkLineItem.bind(null, type);

default:
ensureNever(type);
throw new Error(`unsupported series type ${type}`);
}
}

function checkBarItem(type: 'Bar' | 'Candlestick', barItem: SeriesDataItemTypeMap[typeof type]): void {
if (!isFulfilledData(barItem)) {
return;
}

assert(
// eslint-disable-next-line @typescript-eslint/tslint/config
typeof barItem.open === 'number',
`${type} series item data value of open must be a number, got=${typeof barItem.open}, value=${barItem.open}`
);
assert(
// eslint-disable-next-line @typescript-eslint/tslint/config
typeof barItem.high === 'number',
`${type} series item data value of high must be a number, got=${typeof barItem.high}, value=${barItem.high}`
);
assert(
// eslint-disable-next-line @typescript-eslint/tslint/config
typeof barItem.low === 'number',
`${type} series item data value of low must be a number, got=${typeof barItem.low}, value=${barItem.low}`
);
assert(
// eslint-disable-next-line @typescript-eslint/tslint/config
typeof barItem.close === 'number',
`${type} series item data value of close must be a number, got=${typeof barItem.close}, value=${barItem.close}`
);
}

function checkLineItem(type: 'Area' | 'Line' | 'Histogram', lineItem: SeriesDataItemTypeMap[typeof type]): void {
if (!isFulfilledData(lineItem)) {
return;
}

assert(
// eslint-disable-next-line @typescript-eslint/tslint/config
typeof lineItem.value === 'number',
`${type} series item data value must be a number, got=${typeof lineItem.value}, value=${lineItem.value}`);
}
16 changes: 15 additions & 1 deletion src/api/series-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ensureNotNull } from '../helpers/assertions';
import { assert, ensureNotNull } from '../helpers/assertions';
import { clone, merge } from '../helpers/strict-type-checks';

import { BarPrice } from '../model/bar';
Expand All @@ -19,6 +19,7 @@ import { TimeScaleVisibleRange } from '../model/time-scale-visible-range';
import { IPriceScaleApiProvider } from './chart-api';
import { DataUpdatesConsumer, SeriesDataItemTypeMap, Time } from './data-consumer';
import { convertTime } from './data-layer';
import { checkItemsAreOrdered, checkSeriesValuesType } from './data-validators';
import { IPriceLine } from './iprice-line';
import { IPriceScaleApi } from './iprice-scale-api';
import { BarsInfo, IPriceFormatter, ISeriesApi } from './iseries-api';
Expand Down Expand Up @@ -124,14 +125,21 @@ export class SeriesApi<TSeriesType extends SeriesType> implements ISeriesApi<TSe
}

public setData(data: SeriesDataItemTypeMap[TSeriesType][]): void {
checkItemsAreOrdered(data);
checkSeriesValuesType(this._series.seriesType(), data);

this._dataUpdatesConsumer.applyNewData(this._series, data);
}

public update(bar: SeriesDataItemTypeMap[TSeriesType]): void {
checkSeriesValuesType(this._series.seriesType(), [bar]);

this._dataUpdatesConsumer.updateData(this._series, bar);
}

public setMarkers(data: SeriesMarker<Time>[]): void {
checkItemsAreOrdered(data, true);

const convertedMarkers = data.map<SeriesMarker<TimePoint>>((marker: SeriesMarker<Time>) => ({
...marker,
time: convertTime(marker.time),
Expand All @@ -154,6 +162,12 @@ export class SeriesApi<TSeriesType extends SeriesType> implements ISeriesApi<TSe

public createPriceLine(options: PriceLineOptions): IPriceLine {
const strictOptions = merge(clone(priceLineOptionsDefaults), options) as PriceLineOptions;

if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line @typescript-eslint/tslint/config
assert(typeof strictOptions.price === 'number', `the type of 'price' price line's property should be number, got '${typeof strictOptions.price}'`);
}

const priceLine = this._series.createPriceLine(strictOptions);
return new PriceLine(priceLine);
}
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @param condition - Result of the assertion evaluation
* @param message - Text to include in the exception message
*/
export function assert(condition: boolean, message?: string): void {
export function assert(condition: boolean, message?: string): asserts condition {
if (!condition) {
throw new Error('Assertion failed' + (message ? ': ' + message : ''));
}
Expand Down
102 changes: 102 additions & 0 deletions tests/e2e/graphics/test-cases/data-validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
function runTestCase(container) {
try {
LightweightCharts.createChart('non-existed-id');
console.assert(false, 'should fail if passed container id does not exist');
} catch (e) {
// passed
}

const chart = LightweightCharts.createChart(container);
const lineSeries = chart.addLineSeries();
const barSeries = chart.addBarSeries();

try {
lineSeries.setData([
{ time: 1 },
{ time: 0, value: 0 },
]);

console.assert(false, 'should fail if series data is not ordered');
} catch (e) {
// passed
}

try {
lineSeries.setMarkers([
{ time: 1 },
{ time: 0, value: 0 },
]);

console.assert(false, 'should fail if series markers is not ordered');
} catch (e) {
// passed
}

try {
lineSeries.setData([
{ time: 0 },
{ time: 0, value: 0 },
{ time: 1, value: 1 },
]);

console.assert(false, 'should fail if series data has duplicates');
} catch (e) {
// passed
}

try {
lineSeries.setData([
{ time: 0, value: '0' },
]);

console.assert(false, 'should fail if series data item value type is not number');
} catch (e) {
// passed
}

try {
barSeries.setData([
{ time: 0, open: 0, high: '1', low: 0, close: '0' },
]);

console.assert(false, 'should fail if series data item value type is not numbers');
} catch (e) {
// passed
}

try {
lineSeries.setData([
{ time: '0' },
]);

console.assert(false, 'should fail if series data item has invalid time');
} catch (e) {
// passed
}

try {
lineSeries.setData([
{ time: '2020-1-1' },
]);

console.assert(false, 'should fail if series data item has invalid business day string format');
} catch (e) {
// passed
}

// should pass - several markers could be on the same bar
lineSeries.setMarkers([
{
color: 'green',
position: 'belowBar',
shape: 'arrowDown',
time: 0,
},
{
color: 'green',
position: 'aboveBar',
shape: 'arrowUp',
time: 0,
},
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ function runTestCase(container) {
mainSeries.setData(data);

const markers = [
{ time: data[data.length - 10].time, position: 'inBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 20].time, position: 'inBar', color: 'red', shape: 'arrowDown' },
{ time: data[data.length - 30].time, position: 'inBar', color: 'red', shape: 'circle' },
{ time: data[data.length - 40].time, position: 'inBar', color: 'red', shape: 'square' },
{ time: data[data.length - 30].time, position: 'inBar', color: 'red', shape: 'circle' },
{ time: data[data.length - 20].time, position: 'inBar', color: 'red', shape: 'arrowDown' },
{ time: data[data.length - 10].time, position: 'inBar', color: 'red', shape: 'arrowUp' },
];

mainSeries.setMarkers(markers);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ function runTestCase(container) {
mainSeries.setData(data);

const markers = [
{ time: data[data.length - 10].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 20].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 30].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 40].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 30].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 20].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
{ time: data[data.length - 10].time, position: 'belowBar', color: 'red', shape: 'arrowUp' },
];

mainSeries.setMarkers(markers);
Expand Down

0 comments on commit 83ddc3e

Please sign in to comment.