// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import {_count as count, Layer} from '@deck.gl/core'; import type {DefaultProps} from '@deck.gl/core'; import type {LayerTestCase, LayerClass} from './lifecycle-test'; // eslint-disable-next-line @typescript-eslint/no-empty-function function noop() {} function defaultAssert(condition: any, comment: string): void { if (!condition) { throw new Error(comment); } } // Automatically generate testLayer test cases export function generateLayerTests({ // eslint-disable-next-line @typescript-eslint/no-shadow Layer, sampleProps = {}, assert = defaultAssert, onBeforeUpdate = noop, onAfterUpdate = noop, runDefaultAsserts = true }: { Layer: LayerClass; /** * Override default props during the test */ sampleProps?: Partial; assert?: (condition: any, comment: string) => void; onBeforeUpdate?: LayerTestCase['onBeforeUpdate']; onAfterUpdate?: LayerTestCase['onAfterUpdate']; /** * Test some typical assumptions after layer updates * For primitive layers, assert that layer has model(s). * For composite layers, assert that layer has sublayer(s). * @default true */ runDefaultAsserts?: boolean; }): LayerTestCase[] { assert(Layer.layerName, 'Layer should have display name'); function wrapTestCaseTitle(title: string): string { return `${Layer.layerName}#${title}`; } const testCases: LayerTestCase[] = [ { title: 'Empty props', props: {} }, { title: 'Null data', // @ts-expect-error null may not be an expected data type updateProps: {data: null} }, { title: 'Sample data', updateProps: sampleProps } ]; try { // Calling constructor for the first time resolves default props // eslint-disable-next-line new Layer({}); } catch (error: unknown) { assert(false, `Construct ${Layer.layerName} throws: ${(error as Error).message}`); } // @ts-expect-error Access hidden properties const {_propTypes: propTypes, _mergedDefaultProps: defaultProps} = Layer; // Test alternative data formats testCases.push(...makeAltDataTestCases(sampleProps, propTypes)); for (const propName in Layer.defaultProps) { if (!(propName in sampleProps)) { // Do not override user provided props - they may be layer-specific const newTestCase = makeAltPropTestCase({propName, propTypes, defaultProps, sampleProps, assert}) || []; testCases.push(...newTestCase); } } testCases.forEach(testCase => { testCase.title = wrapTestCaseTitle(testCase.title); const beforeFunc = testCase.onBeforeUpdate || noop; const afterFunc = testCase.onAfterUpdate || noop; testCase.onBeforeUpdate = params => { // Generated callback beforeFunc(params); // User callback onBeforeUpdate(params); }; testCase.onAfterUpdate = params => { // Generated callback afterFunc(params); // User callback onAfterUpdate(params); // Default assert if (runDefaultAsserts) { if (params.layer.isComposite) { const {data} = params.layer.props; if (data && typeof data === 'object' && count(data)) { assert(params.subLayers.length, 'Layer should have sublayers'); } } else { assert(params.layer.getModels().length, 'Layer should have models'); } } }; }); return testCases; } function makeAltPropTestCase({ propName, propTypes, defaultProps, sampleProps, assert }: { propName: string; propTypes: DefaultProps; defaultProps: LayerT['props']; sampleProps: Partial; assert: (condition: any, comment: string) => void; }): LayerTestCase[] | null { const newProps = {...sampleProps}; const propDef = propTypes[propName]; if (!propDef) { return null; } switch (propDef.type) { case 'boolean': newProps[propName] = !defaultProps[propName]; return [ { title: `${propName}: ${String(newProps[propName])}`, props: newProps } ]; case 'number': if ('max' in propDef) { newProps[propName] = propDef.max; } else if ('min' in propDef) { newProps[propName] = propDef.min; } else { newProps[propName] = defaultProps[propName] + 1; } return [ { title: `${propName}: ${String(newProps[propName])}`, props: newProps } ]; case 'accessor': { if (typeof defaultProps[propName] === 'function') { return null; } let callCount = 0; newProps[propName] = () => { callCount++; return defaultProps[propName]; }; newProps.updateTriggers = { [propName]: 'function' }; const onBeforeUpdate = () => (callCount = 0); const onAfterUpdate = () => assert(callCount > 0, 'accessor function is called'); return [ { title: `${propName}: () => ${defaultProps[propName]}`, props: newProps, onBeforeUpdate, onAfterUpdate }, { title: `${propName}: updateTrigger`, updateProps: { updateTriggers: { [propName]: 'function+trigger' } } as Partial, onBeforeUpdate, onAfterUpdate } ]; } default: return null; } } function makeAltDataTestCases( props: Partial, propTypes: DefaultProps ): LayerTestCase[] { const originalData = props.data; if (!Array.isArray(originalData)) { return []; } // partial update const partialUpdateProps: Partial = { data: originalData.slice(), _dataDiff: () => [{startRow: 0, endRow: 2}] }; // data should support any iterable const genIterableProps: Partial = { data: new Set(originalData), _dataDiff: null }; // data in non-iterable form const nonIterableProps: Partial = { data: { length: originalData.length } }; for (const propName in props) { // @ts-ignore propName cannot be used as index if (propTypes[propName].type === 'accessor') { // @ts-ignore propName cannot be used as index nonIterableProps[propName] = (_, info) => props[propName](originalData[info.index], info); } } return [ { title: 'Partial update', updateProps: partialUpdateProps }, { title: 'Generic iterable data', updateProps: genIterableProps }, { title: 'non-iterable data', updateProps: nonIterableProps } ]; }