mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] Column Charts Unit/Functional Tests (#88176)
- Unit and functional tests for the data grid mini histograms. - canvasElement service.
This commit is contained in:
parent
7e1549d889
commit
2ec6f6c6b2
7 changed files with 372 additions and 32 deletions
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
112
x-pack/test/functional/services/canvas_element.ts
Normal file
112
x-pack/test/functional/services/canvas_element.ts
Normal 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]) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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`);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue