[ML] Column Charts Unit/Functional Tests (#88176)

- Unit and functional tests for the data grid mini histograms.
- canvasElement service.
This commit is contained in:
Walter Rafelsberger 2021-01-20 13:01:33 +01:00 committed by GitHub
parent 7e1549d889
commit 2ec6f6c6b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 372 additions and 32 deletions

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getFieldType } from './use_column_chart';
describe('getFieldType()', () => {
it('should return the Kibana field type for a given EUI data grid schema', () => {
expect(getFieldType('text')).toBe('string');
expect(getFieldType('datetime')).toBe('date');
expect(getFieldType('numeric')).toBe('number');
expect(getFieldType('boolean')).toBe('boolean');
expect(getFieldType('json')).toBe('object');
expect(getFieldType('non-aggregatable')).toBe(undefined);
});
});

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import '@testing-library/jest-dom/extend-expect';
import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import {
getFieldType,
getLegendText,
getXScaleType,
isNumericChartData,
isOrdinalChartData,
isUnsupportedChartData,
useColumnChart,
NumericChartData,
OrdinalChartData,
UnsupportedChartData,
} from './use_column_chart';
describe('getFieldType()', () => {
it('should return the Kibana field type for a given EUI data grid schema', () => {
expect(getFieldType('text')).toBe('string');
expect(getFieldType('datetime')).toBe('date');
expect(getFieldType('numeric')).toBe('number');
expect(getFieldType('boolean')).toBe('boolean');
expect(getFieldType('json')).toBe('object');
expect(getFieldType('non-aggregatable')).toBe(undefined);
});
});
describe('getXScaleType()', () => {
it('should return the corresponding x axis scale type for a Kibana field type', () => {
expect(getXScaleType(KBN_FIELD_TYPES.BOOLEAN)).toBe('ordinal');
expect(getXScaleType(KBN_FIELD_TYPES.IP)).toBe('ordinal');
expect(getXScaleType(KBN_FIELD_TYPES.STRING)).toBe('ordinal');
expect(getXScaleType(KBN_FIELD_TYPES.DATE)).toBe('time');
expect(getXScaleType(KBN_FIELD_TYPES.NUMBER)).toBe('linear');
expect(getXScaleType(undefined)).toBe(undefined);
});
});
const validNumericChartData: NumericChartData = {
data: [],
id: 'the-id',
interval: 10,
stats: [0, 0],
type: 'numeric',
};
const validOrdinalChartData: OrdinalChartData = {
cardinality: 10,
data: [],
id: 'the-id',
type: 'ordinal',
};
const validUnsupportedChartData: UnsupportedChartData = { id: 'the-id', type: 'unsupported' };
describe('isNumericChartData()', () => {
it('should return true for valid numeric chart data', () => {
expect(isNumericChartData(validNumericChartData)).toBe(true);
});
it('should return false for invalid numeric chart data', () => {
expect(isNumericChartData(undefined)).toBe(false);
expect(isNumericChartData({})).toBe(false);
expect(isNumericChartData({ data: [] })).toBe(false);
expect(isNumericChartData(validOrdinalChartData)).toBe(false);
expect(isNumericChartData(validUnsupportedChartData)).toBe(false);
});
});
describe('isOrdinalChartData()', () => {
it('should return true for valid ordinal chart data', () => {
expect(isOrdinalChartData(validOrdinalChartData)).toBe(true);
});
it('should return false for invalid ordinal chart data', () => {
expect(isOrdinalChartData(undefined)).toBe(false);
expect(isOrdinalChartData({})).toBe(false);
expect(isOrdinalChartData({ data: [] })).toBe(false);
expect(isOrdinalChartData(validNumericChartData)).toBe(false);
expect(isOrdinalChartData(validUnsupportedChartData)).toBe(false);
});
});
describe('isUnsupportedChartData()', () => {
it('should return true for unsupported chart data', () => {
expect(isUnsupportedChartData(validUnsupportedChartData)).toBe(true);
});
it('should return false for invalid unsupported chart data', () => {
expect(isUnsupportedChartData(undefined)).toBe(false);
expect(isUnsupportedChartData({})).toBe(false);
expect(isUnsupportedChartData({ data: [] })).toBe(false);
expect(isUnsupportedChartData(validNumericChartData)).toBe(false);
expect(isUnsupportedChartData(validOrdinalChartData)).toBe(false);
});
});
describe('getLegendText()', () => {
it('should return the chart legend text for unsupported chart types', () => {
expect(getLegendText(validUnsupportedChartData)).toBe('Chart not supported.');
});
it('should return the chart legend text for empty datasets', () => {
expect(getLegendText(validNumericChartData)).toBe('0 documents contain field.');
});
it('should return the chart legend text for boolean chart types', () => {
const { getByText } = render(
<>
{getLegendText({
cardinality: 2,
data: [
{ key: 'true', key_as_string: 'true', doc_count: 10 },
{ key: 'false', key_as_string: 'false', doc_count: 20 },
],
id: 'the-id',
type: 'boolean',
})}
</>
);
expect(getByText('true')).toBeInTheDocument();
expect(getByText('false')).toBeInTheDocument();
});
it('should return the chart legend text for ordinal chart data with less than max categories', () => {
expect(getLegendText({ ...validOrdinalChartData, data: [{ key: 'cat', doc_count: 10 }] })).toBe(
'10 categories'
);
});
it('should return the chart legend text for ordinal chart data with more than max categories', () => {
expect(
getLegendText({
...validOrdinalChartData,
cardinality: 30,
data: [{ key: 'cat', doc_count: 10 }],
})
).toBe('top 20 of 30 categories');
});
it('should return the chart legend text for numeric datasets', () => {
expect(
getLegendText({
...validNumericChartData,
data: [{ key: 1, doc_count: 10 }],
stats: [1, 100],
})
).toBe('1 - 100');
expect(
getLegendText({
...validNumericChartData,
data: [{ key: 1, doc_count: 10 }],
stats: [100, 100],
})
).toBe('100');
expect(
getLegendText({
...validNumericChartData,
data: [{ key: 1, doc_count: 10 }],
stats: [1.2345, 6.3456],
})
).toBe('1.23 - 6.35');
});
});
describe('useColumnChart()', () => {
it('should return the column chart hook data', () => {
const { result } = renderHook(() =>
useColumnChart(validNumericChartData, { id: 'the-id', schema: 'numeric' })
);
expect(result.current.data).toStrictEqual([]);
expect(result.current.legendText).toBe('0 documents contain field.');
expect(result.current.xScaleType).toBe('linear');
});
});

View file

@ -25,7 +25,7 @@ const BAR_COLOR_BLUR = euiPaletteColorBlind({ rotations: 2 })[10];
const MAX_CHART_COLUMNS = 20;
type XScaleType = 'ordinal' | 'time' | 'linear' | undefined;
const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => {
export const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => {
switch (kbnFieldType) {
case KBN_FIELD_TYPES.BOOLEAN:
case KBN_FIELD_TYPES.IP:
@ -71,7 +71,7 @@ interface NumericDataItem {
doc_count: number;
}
interface NumericChartData {
export interface NumericChartData {
data: NumericDataItem[];
id: string;
interval: number;
@ -81,11 +81,13 @@ interface NumericChartData {
export const isNumericChartData = (arg: any): arg is NumericChartData => {
return (
typeof arg === 'object' &&
arg.hasOwnProperty('data') &&
arg.hasOwnProperty('id') &&
arg.hasOwnProperty('interval') &&
arg.hasOwnProperty('stats') &&
arg.hasOwnProperty('type')
arg.hasOwnProperty('type') &&
arg.type === 'numeric'
);
};
@ -96,28 +98,30 @@ export interface OrdinalDataItem {
}
export interface OrdinalChartData {
type: 'ordinal' | 'boolean';
cardinality: number;
data: OrdinalDataItem[];
id: string;
type: 'ordinal' | 'boolean';
}
export const isOrdinalChartData = (arg: any): arg is OrdinalChartData => {
return (
typeof arg === 'object' &&
arg.hasOwnProperty('data') &&
arg.hasOwnProperty('cardinality') &&
arg.hasOwnProperty('id') &&
arg.hasOwnProperty('type')
arg.hasOwnProperty('type') &&
(arg.type === 'ordinal' || arg.type === 'boolean')
);
};
interface UnsupportedChartData {
export interface UnsupportedChartData {
id: string;
type: 'unsupported';
}
export const isUnsupportedChartData = (arg: any): arg is UnsupportedChartData => {
return arg.hasOwnProperty('type') && arg.type === 'unsupported';
return typeof arg === 'object' && arg.hasOwnProperty('type') && arg.type === 'unsupported';
};
export type ChartDataItem = NumericDataItem | OrdinalDataItem;

View file

@ -155,7 +155,15 @@ export default function ({ getService }: FtrProviderContext) {
},
histogramCharts: [
{ chartAvailable: false, id: 'category', legend: 'Chart not supported.' },
{ chartAvailable: true, id: 'currency', legend: '1 category' },
{
chartAvailable: true,
id: 'currency',
legend: '1 category',
colorStats: [
{ key: '#000000', value: 10 },
{ key: '#54B399', value: 90 },
],
},
{
chartAvailable: false,
id: 'customer_birth_date',
@ -163,11 +171,43 @@ export default function ({ getService }: FtrProviderContext) {
},
{ chartAvailable: false, id: 'customer_first_name', legend: 'Chart not supported.' },
{ chartAvailable: false, id: 'customer_full_name', legend: 'Chart not supported.' },
{ chartAvailable: true, id: 'customer_gender', legend: '2 categories' },
{ chartAvailable: true, id: 'customer_id', legend: 'top 20 of 46 categories' },
{
chartAvailable: true,
id: 'customer_gender',
legend: '2 categories',
colorStats: [
{ key: '#000000', value: 15 },
{ key: '#54B399', value: 85 },
],
},
{
chartAvailable: true,
id: 'customer_id',
legend: 'top 20 of 46 categories',
colorStats: [
{ key: '#54B399', value: 35 },
{ key: '#000000', value: 60 },
],
},
{ chartAvailable: false, id: 'customer_last_name', legend: 'Chart not supported.' },
{ chartAvailable: true, id: 'customer_phone', legend: '1 category' },
{ chartAvailable: true, id: 'day_of_week', legend: '7 categories' },
{
chartAvailable: true,
id: 'customer_phone',
legend: '1 category',
colorStats: [
{ key: '#000000', value: 10 },
{ key: '#54B399', value: 90 },
],
},
{
chartAvailable: true,
id: 'day_of_week',
legend: '7 categories',
colorStats: [
{ key: '#000000', value: 20 },
{ key: '#54B399', value: 75 },
],
},
],
},
} as PivotTransformTestData,

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { rgb, nest } from 'd3';
interface ColorStat {
key: string;
value: number;
withinTolerance?: boolean;
}
type ColorStats = ColorStat[];
/**
* Returns if a given value is within the tolerated range of an expected value
*
* @param actualValue
* @param expectedValue
* @param toleranceRange
* @returns if actualValue is within the tolerance of expectedValue
*/
function isValueWithinTolerance(actualValue: number, expectedValue: number, toleranceRange = 10) {
const lower = expectedValue - toleranceRange / 2;
const upper = expectedValue + toleranceRange / 2;
return lower <= actualValue && upper >= actualValue;
}
import { FtrProviderContext } from '../ftr_provider_context';
export async function CanvasElementProvider({ getService }: FtrProviderContext) {
const { driver } = await getService('__webdriver__').init();
return new (class CanvasElementService {
/**
* Gets the image data of a canvas element
* @param selector querySelector to access the canvas element.
*
* @returns {Promise<number[]>} a single level array of number where every 4 numbers represent a RGBA value.
*/
public async getImageData(selector: string): Promise<number[]> {
return await driver.executeScript(
`
const el = document.querySelector('${selector}');
const ctx = el.getContext('2d');
return ctx.getImageData(0, 0, el.width, el.height).data;
`
);
}
/**
* Returns color statistics for image data derived from a 2D Canvas element.
*
* @param selector querySelector to access the canvas element.
* @param expectedColorStats - optional stats to compare against and check if the percentage is within the tolerance.
* @param threshold - colors below this percentage threshold will be filtered from the returned list of colors
* @returns an array of colors and their percentage of appearance in the given image data
*/
public async getColorStats(
selector: string,
expectedColorStats?: ColorStats,
threshold = 5
): Promise<ColorStats> {
const imageData = await this.getImageData(selector);
// transform the array of RGBA numbers to an array of hex values
const colors: string[] = [];
for (let i = 0; i < imageData.length; i += 4) {
// uses d3's `rgb` method create a color object, `toString()` returns the hex value
colors.push(
rgb(imageData[i], imageData[i + 1], imageData[i + 2])
.toString()
.toUpperCase()
);
}
const expectedColorStatsMap =
expectedColorStats !== undefined
? expectedColorStats.reduce((p, c) => {
p[c.key] = c.value;
return p;
}, {} as Record<string, number>)
: {};
function getPixelPercentage(pixelsNum: number): number {
return Math.round((pixelsNum / colors.length) * 100);
}
// - d3's nest/key/entries methods will group the array of hex values so we can count
// the number of times a color appears in the image.
// - then we'll filter all colors below the given threshold
// - last step is to return the ColorStat object which includes the color,
// the percentage it shows up in the image and optionally the check if it's within
// the tolerance of the expected value.
return nest<string>()
.key((d) => d)
.entries(colors)
.filter((s) => getPixelPercentage(s.values.length) >= threshold)
.map((s) => {
const value = getPixelPercentage(s.values.length);
return {
key: s.key,
value,
...(expectedColorStats !== undefined
? { withinTolerance: isValueWithinTolerance(value, expectedColorStatsMap[s.key]) }
: {}),
};
});
}
})();
}

View file

@ -42,6 +42,7 @@ import { PipelineEditorProvider } from './pipeline_editor';
import { RandomProvider } from './random';
// @ts-ignore not ts yet
import { AceEditorProvider } from './ace_editor';
import { CanvasElementProvider } from './canvas_element';
// @ts-ignore not ts yet
import { GrokDebuggerProvider } from './grok_debugger';
// @ts-ignore not ts yet
@ -93,6 +94,7 @@ export const services = {
pipelineEditor: PipelineEditorProvider,
random: RandomProvider,
aceEditor: AceEditorProvider,
canvasElement: CanvasElementProvider,
grokDebugger: GrokDebuggerProvider,
userMenu: UserMenuProvider,
uptime: UptimeProvider,

View file

@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export function TransformWizardProvider({ getService }: FtrProviderContext) {
const aceEditor = getService('aceEditor');
const canvasElement = getService('canvasElement');
const testSubjects = getService('testSubjects');
const comboBox = getService('comboBox');
const retry = getService('retry');
@ -184,7 +185,12 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
},
async assertIndexPreviewHistogramCharts(
expectedHistogramCharts: Array<{ chartAvailable: boolean; id: string; legend: string }>
expectedHistogramCharts: Array<{
chartAvailable: boolean;
id: string;
legend: string;
colorStats?: any[];
}>
) {
// For each chart, get the content of each header cell and assert
// the legend text and column id and if the chart should be present or not.
@ -194,6 +200,22 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
if (expected.chartAvailable) {
await testSubjects.existOrFail(`mlDataGridChart-${index}-histogram`);
if (expected.colorStats !== undefined) {
const actualColorStats = await canvasElement.getColorStats(
`[data-test-subj="mlDataGridChart-${index}-histogram"] .echCanvasRenderer`,
expected.colorStats
);
expect(actualColorStats.every((d) => d.withinTolerance)).to.eql(
true,
`Color stats for column '${
expected.id
}' should be within tolerance. Expected: '${JSON.stringify(
expected.colorStats
)}' (got '${JSON.stringify(actualColorStats)}')`
);
}
} else {
await testSubjects.missingOrFail(`mlDataGridChart-${index}-histogram`);
}
@ -201,7 +223,7 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
const actualLegend = await testSubjects.getVisibleText(`mlDataGridChart-${index}-legend`);
expect(actualLegend).to.eql(
expected.legend,
`Legend text for column '${index}' should be '${expected.legend}' (got '${actualLegend}')`
`Legend text for column '${expected.id}' should be '${expected.legend}' (got '${actualLegend}')`
);
const actualId = await testSubjects.getVisibleText(`mlDataGridChart-${index}-id`);