From 9bcad72a24ca0851db3a33ce7470c51605f6f57a Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Wed, 3 Mar 2021 15:10:17 -0500 Subject: [PATCH] migrate mocks to test utils Signed-off-by: Ryan Vazquez --- .../src/components/BarChart/BarChart.test.tsx | 2 +- .../CostOverviewCard.test.tsx | 2 - .../ProductInsights/ProductInsights.test.tsx | 2 +- .../ProductInsightsCard.test.tsx | 2 - .../ProjectGrowthAlertCard.test.tsx | 2 +- .../UnlabeledDataflowAlertCard.test.tsx | 2 +- plugins/cost-insights/src/example/client.ts | 2 +- plugins/cost-insights/src/index.ts | 2 +- plugins/cost-insights/src/testUtils/alerts.ts | 78 ++++ plugins/cost-insights/src/testUtils/config.ts | 33 ++ .../cost-insights/src/testUtils/filters.ts | 22 + plugins/cost-insights/src/testUtils/index.ts | 8 +- .../cost-insights/src/testUtils/loading.ts | 30 ++ .../src/{utils => testUtils}/mockData.ts | 388 ++---------------- .../cost-insights/src/testUtils/products.ts | 90 ++++ .../cost-insights/src/testUtils/providers.tsx | 12 - .../cost-insights/src/testUtils/testUtils.ts | 183 +++++++++ .../cost-insights/src/utils/change.test.ts | 2 +- 18 files changed, 481 insertions(+), 381 deletions(-) create mode 100644 plugins/cost-insights/src/testUtils/alerts.ts create mode 100644 plugins/cost-insights/src/testUtils/config.ts create mode 100644 plugins/cost-insights/src/testUtils/filters.ts create mode 100644 plugins/cost-insights/src/testUtils/loading.ts rename plugins/cost-insights/src/{utils => testUtils}/mockData.ts (58%) create mode 100644 plugins/cost-insights/src/testUtils/products.ts create mode 100644 plugins/cost-insights/src/testUtils/testUtils.ts diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx index 4e40a5ddda..cd031f9d1b 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { BarChart, BarChartProps } from './BarChart'; import { ResourceData } from '../../types'; -import { createMockEntity } from '../../utils/mockData'; +import { createMockEntity } from '../../testUtils'; import { resourceSort } from '../../utils/sort'; import { renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx index 0334ec5dd0..3ff4d31135 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx @@ -24,8 +24,6 @@ import { getGroupedProjects, MockAggregatedDailyCosts, trendlineOf, -} from '../../utils/mockData'; -import { MockBillingDateProvider, MockConfigProvider, MockFilterProvider, diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx index 44481e5112..f782659ff2 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -18,8 +18,8 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { ProductInsights } from './ProductInsights'; import { ProductInsightsOptions } from '../../api'; -import { mockDefaultLoadingState } from '../../utils/mockData'; import { + mockDefaultLoadingState, MockConfigProvider, MockCostInsightsApiProvider, MockCurrencyProvider, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index 4b9acb35e6..cd171feba6 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -22,8 +22,6 @@ import { createMockEntity, mockDefaultLoadingState, MockComputeEngine, -} from '../../utils/mockData'; -import { MockConfigProvider, MockCostInsightsApiProvider, MockCurrencyProvider, diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index 5cc91611cc..52f334c4cb 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { ProjectGrowthAlertCard } from './ProjectGrowthAlertCard'; -import { createMockProjectGrowthData } from '../../utils/mockData'; import { + createMockProjectGrowthData, MockCurrencyProvider, MockConfigProvider, MockBillingDateProvider, diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx index df38c78a03..5aea9be548 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx @@ -19,7 +19,7 @@ import { UnlabeledDataflowAlertCard } from './UnlabeledDataflowAlertCard'; import { createMockUnlabeledDataflowData, createMockUnlabeledDataflowAlertProject, -} from '../../utils/mockData'; +} from '../../testUtils'; import { renderInTestApp } from '@backstage/test-utils'; const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowData( diff --git a/plugins/cost-insights/src/example/client.ts b/plugins/cost-insights/src/example/client.ts index 119c1ecd26..00d0a25233 100644 --- a/plugins/cost-insights/src/example/client.ts +++ b/plugins/cost-insights/src/example/client.ts @@ -37,7 +37,7 @@ import { getGroupedProducts, getGroupedProjects, trendlineOf, -} from '../utils/mockData'; +} from '../testUtils'; export class ExampleCostInsightsClient implements CostInsightsApi { private request(_: any, res: any): Promise { diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index b0b82eca5b..2d12eec9d9 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -23,7 +23,7 @@ export { } from './plugin'; export { ExampleCostInsightsClient } from './example'; export { BarChart, LegendItem, CostGrowth } from './components'; +export { MockConfigProvider, MockCurrencyProvider } from './testUtils'; export * from './api'; export * from './alerts'; export * from './types'; -export * from './testUtils'; diff --git a/plugins/cost-insights/src/testUtils/alerts.ts b/plugins/cost-insights/src/testUtils/alerts.ts new file mode 100644 index 0000000000..2e8e1b114a --- /dev/null +++ b/plugins/cost-insights/src/testUtils/alerts.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ProjectGrowthData, + UnlabeledDataflowAlertProject, + UnlabeledDataflowData, +} from '../types'; + +type mockAlertRenderer = (alert: T) => T; +type mockEntityRenderer = (entity: T) => T; + +export const createMockUnlabeledDataflowData = ( + callback?: mockAlertRenderer, +): UnlabeledDataflowData => { + const data: UnlabeledDataflowData = { + periodStart: '2020-05-01', + periodEnd: '2020-06-1', + projects: [], + unlabeledCost: 0, + labeledCost: 0, + }; + + if (typeof callback === 'function') { + return callback({ ...data }); + } + + return { ...data }; +}; + +export const createMockUnlabeledDataflowAlertProject = ( + callback?: mockEntityRenderer, +): UnlabeledDataflowAlertProject => { + const defaultProject: UnlabeledDataflowAlertProject = { + id: 'test-alert-project', + unlabeledCost: 2000.0, + labeledCost: 3200.0, + }; + if (typeof callback === 'function') { + return callback({ ...defaultProject }); + } + return { ...defaultProject }; +}; + +export const createMockProjectGrowthData = ( + callback?: mockAlertRenderer, +): ProjectGrowthData => { + const data: ProjectGrowthData = { + project: 'test-project-growth-alert', + periodStart: '2019-Q4', + periodEnd: '2020-Q1', + aggregation: [670532.1, 970502.8], + change: { + ratio: 0.5, + amount: 180000, + }, + products: [], + }; + + if (typeof callback === 'function') { + return callback({ ...data }); + } + + return { ...data }; +}; diff --git a/plugins/cost-insights/src/testUtils/config.ts b/plugins/cost-insights/src/testUtils/config.ts new file mode 100644 index 0000000000..7d48121506 --- /dev/null +++ b/plugins/cost-insights/src/testUtils/config.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { ConfigApi } from '@backstage/core'; +import { MockProductTypes } from './products'; + +export const MockProductsConfig: Partial = { + keys: () => Object.keys(MockProductTypes), +}; + +export const MockMetricsConfig: Partial = { + getOptionalString: () => 'daily-cost', + keys: () => ['daily-cost'], +}; + +export const MockCostInsightsConfig: Partial = { + getConfig: () => MockProductsConfig as Config, + getOptionalConfig: () => MockMetricsConfig as Config, +}; diff --git a/plugins/cost-insights/src/testUtils/filters.ts b/plugins/cost-insights/src/testUtils/filters.ts new file mode 100644 index 0000000000..e401b1f2e7 --- /dev/null +++ b/plugins/cost-insights/src/testUtils/filters.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration, ProductFilters } from '../types'; +import { MockProductTypes } from './products'; + +export const MockProductFilters: ProductFilters = Object.keys( + MockProductTypes, +).map(productType => ({ duration: Duration.P30D, productType })); diff --git a/plugins/cost-insights/src/testUtils/index.ts b/plugins/cost-insights/src/testUtils/index.ts index 22be6a24a6..a1748b15b2 100644 --- a/plugins/cost-insights/src/testUtils/index.ts +++ b/plugins/cost-insights/src/testUtils/index.ts @@ -13,5 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +export * from './alerts'; +export * from './config'; +export * from './filters'; +export * from './loading'; +export * from './mockData'; +export * from './products'; export * from './providers'; +export * from './testUtils'; diff --git a/plugins/cost-insights/src/testUtils/loading.ts b/plugins/cost-insights/src/testUtils/loading.ts new file mode 100644 index 0000000000..6c6f761ee8 --- /dev/null +++ b/plugins/cost-insights/src/testUtils/loading.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MockProducts } from './products'; +import { + DefaultLoadingAction, + getDefaultState as getDefaultLoadingState, +} from '../utils/loading'; + +export const MockDefaultLoadingActions = ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, +] as string[]).concat(MockProducts.map(product => product.kind)); + +export const mockDefaultLoadingState = getDefaultLoadingState( + MockDefaultLoadingActions, +); diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/testUtils/mockData.ts similarity index 58% rename from plugins/cost-insights/src/utils/mockData.ts rename to plugins/cost-insights/src/testUtils/mockData.ts index d4ac4cd2df..5149da278d 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/testUtils/mockData.ts @@ -14,264 +14,7 @@ * limitations under the License. */ -import dayjs from 'dayjs'; -import regression, { DataPoint } from 'regression'; -import { Config } from '@backstage/config'; -import { ConfigApi } from '@backstage/core'; -import { - ChangeStatistic, - Duration, - Entity, - Product, - ProductFilters, - ProjectGrowthData, - Trendline, - UnlabeledDataflowAlertProject, - UnlabeledDataflowData, - DateAggregation, - DEFAULT_DATE_FORMAT, -} from '../types'; -import { - DefaultLoadingAction, - getDefaultState as getDefaultLoadingState, -} from '../utils/loading'; -import { findAlways } from '../utils/assert'; -import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; - -type mockAlertRenderer = (alert: T) => T; -type mockEntityRenderer = (entity: T) => T; - -type IntervalFields = { - duration: Duration; - endDate: string; -}; - -export const createMockEntity = ( - callback?: mockEntityRenderer, -): Entity => { - const defaultEntity: Entity = { - id: 'test-entity', - aggregation: [100, 200], - entities: {}, - change: { - ratio: 0, - amount: 0, - }, - }; - - if (typeof callback === 'function') { - return callback({ ...defaultEntity }); - } - return { ...defaultEntity }; -}; - -export const createMockProduct = ( - callback?: mockEntityRenderer, -): Product => { - const defaultProduct: Product = { - kind: 'compute-engine', - name: 'Compute Engine', - }; - if (typeof callback === 'function') { - return callback({ ...defaultProduct }); - } - return { ...defaultProduct }; -}; - -export const createMockProjectGrowthData = ( - callback?: mockAlertRenderer, -): ProjectGrowthData => { - const data: ProjectGrowthData = { - project: 'test-project-growth-alert', - periodStart: '2019-Q4', - periodEnd: '2020-Q1', - aggregation: [670532.1, 970502.8], - change: { - ratio: 0.5, - amount: 180000, - }, - products: [], - }; - - if (typeof callback === 'function') { - return callback({ ...data }); - } - - return { ...data }; -}; - -export const createMockUnlabeledDataflowData = ( - callback?: mockAlertRenderer, -): UnlabeledDataflowData => { - const data: UnlabeledDataflowData = { - periodStart: '2020-05-01', - periodEnd: '2020-06-1', - projects: [], - unlabeledCost: 0, - labeledCost: 0, - }; - - if (typeof callback === 'function') { - return callback({ ...data }); - } - - return { ...data }; -}; - -export const createMockUnlabeledDataflowAlertProject = ( - callback?: mockEntityRenderer, -): UnlabeledDataflowAlertProject => { - const defaultProject: UnlabeledDataflowAlertProject = { - id: 'test-alert-project', - unlabeledCost: 2000.0, - labeledCost: 3200.0, - }; - if (typeof callback === 'function') { - return callback({ ...defaultProject }); - } - return { ...defaultProject }; -}; - -export const MockProductTypes: Record = { - 'compute-engine': 'Compute Engine', - 'cloud-dataflow': 'Cloud Dataflow', - 'cloud-storage': 'Cloud Storage', - 'big-query': 'Big Query', - 'big-table': 'BigTable', - 'cloud-pub-sub': 'Cloud Pub/Sub', -}; - -export const MockProductFilters: ProductFilters = Object.keys( - MockProductTypes, -).map(productType => ({ duration: Duration.P30D, productType })); - -export const MockProducts: Product[] = Object.keys(MockProductTypes).map( - productType => - createMockProduct(() => ({ - kind: productType, - name: MockProductTypes[productType], - })), -); - -export const MockDefaultLoadingActions = ([ - DefaultLoadingAction.UserGroups, - DefaultLoadingAction.CostInsightsInitial, - DefaultLoadingAction.CostInsightsPage, -] as string[]).concat(MockProducts.map(product => product.kind)); - -export const mockDefaultLoadingState = getDefaultLoadingState( - MockDefaultLoadingActions, -); - -export const MockComputeEngine = findAlways( - MockProducts, - p => p.kind === 'compute-engine', -); -export const MockCloudDataflow = findAlways( - MockProducts, - p => p.kind === 'cloud-dataflow', -); -export const MockCloudStorage = findAlways( - MockProducts, - p => p.kind === 'cloud-storage', -); -export const MockBigQuery = findAlways( - MockProducts, - p => p.kind === 'big-query', -); -export const MockBigtable = findAlways( - MockProducts, - p => p.kind === 'big-table', -); - -export const MockProductsConfig: Partial = { - keys: () => Object.keys(MockProductTypes), -}; - -export const MockMetricsConfig: Partial = { - getOptionalString: () => 'daily-cost', - keys: () => ['daily-cost'], -}; - -export const MockCostInsightsConfig: Partial = { - getConfig: () => MockProductsConfig as Config, - getOptionalConfig: () => MockMetricsConfig as Config, -}; - -export function trendlineOf(aggregation: DateAggregation[]): Trendline { - const data: ReadonlyArray = aggregation.map(a => [ - Date.parse(a.date) / 1000, - a.amount, - ]); - const result = regression.linear(data, { precision: 5 }); - return { - slope: result.equation[0], - intercept: result.equation[1], - }; -} - -export function changeOf(aggregation: DateAggregation[]): ChangeStatistic { - const firstAmount = aggregation.length ? aggregation[0].amount : 0; - const lastAmount = aggregation.length - ? aggregation[aggregation.length - 1].amount - : 0; - const ratio = - firstAmount !== 0 ? (lastAmount - firstAmount) / firstAmount : 0; - return { - ratio: ratio, - amount: lastAmount - firstAmount, - }; -} - -export function aggregationFor( - intervals: string, - baseline: number, -): DateAggregation[] { - const { duration, endDate } = parseIntervals(intervals); - const inclusiveEndDate = inclusiveEndDateOf(duration, endDate); - const days = dayjs(endDate).diff( - inclusiveStartDateOf(duration, inclusiveEndDate), - 'day', - ); - - function nextDelta(): number { - const varianceFromBaseline = 0.15; - // Let's give positive vibes in trendlines - higher change for positive delta with >0.5 value - const positiveTrendChance = 0.55; - const normalization = positiveTrendChance - 1; - return baseline * (Math.random() + normalization) * varianceFromBaseline; - } - - return [...Array(days).keys()].reduce( - (values: DateAggregation[], i: number): DateAggregation[] => { - const last = values.length ? values[values.length - 1].amount : baseline; - const date = dayjs(inclusiveStartDateOf(duration, inclusiveEndDate)) - .add(i, 'day') - .format(DEFAULT_DATE_FORMAT); - const amount = Math.max(0, last + nextDelta()); - values.push({ - date: date, - amount: amount, - }); - return values; - }, - [], - ); -} - -function parseIntervals(intervals: string): IntervalFields { - const match = intervals.match( - /\/(?P\d+[DM])\/(?\d{4}-\d{2}-\d{2})/, - ); - if (Object.keys(match?.groups || {}).length !== 2) { - throw new Error(`Invalid intervals: ${intervals}`); - } - const { duration, date } = match!.groups!; - return { - duration: duration as Duration, - endDate: date, - }; -} +import { DateAggregation, Entity } from '../types'; export const MockAggregatedDailyCosts: DateAggregation[] = [ { @@ -520,7 +263,7 @@ export const MockAggregatedDailyCosts: DateAggregation[] = [ }, ]; -export const SampleBigQueryInsights: Entity = { +export const MockBigQueryInsights: Entity = { id: 'bigQuery', aggregation: [10_000, 30_000], change: { @@ -560,7 +303,7 @@ export const SampleBigQueryInsights: Entity = { }, }; -export const SampleCloudDataflowInsights: Entity = { +export const MockCloudDataflowInsights: Entity = { id: 'cloudDataflow', aggregation: [100_000, 158_000], change: { @@ -579,7 +322,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [3_000, 4_000], change: { ratio: 0.333333, @@ -588,7 +331,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [7_000, 8_000], change: { ratio: 0.14285714, @@ -609,7 +352,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [20_000, 15_000], change: { ratio: -0.25, @@ -618,7 +361,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [30_000, 35_000], change: { ratio: -0.16666666666666666, @@ -627,7 +370,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: {}, }, { - id: 'Sample SKU C', + id: 'Mock SKU C', aggregation: [10_000, 20_000], change: { ratio: 1, @@ -648,7 +391,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [4_000, 4_000], change: { ratio: 0, @@ -657,7 +400,7 @@ export const SampleCloudDataflowInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [8_000, 4_000], change: { ratio: -0.5, @@ -681,7 +424,7 @@ export const SampleCloudDataflowInsights: Entity = { }, }; -export const SampleCloudStorageInsights: Entity = { +export const MockCloudStorageInsights: Entity = { id: 'cloudStorage', aggregation: [45_000, 45_000], change: { @@ -700,7 +443,7 @@ export const SampleCloudStorageInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [10_000, 11_000], change: { ratio: 0.1, @@ -709,7 +452,7 @@ export const SampleCloudStorageInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [2_000, 5_000], change: { ratio: 1.5, @@ -718,7 +461,7 @@ export const SampleCloudStorageInsights: Entity = { entities: {}, }, { - id: 'Sample SKU C', + id: 'Mock SKU C', aggregation: [3_000, 4_000], change: { ratio: 0.3333, @@ -739,7 +482,7 @@ export const SampleCloudStorageInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [12_000, 13_000], change: { ratio: 0.08333333333333333, @@ -748,7 +491,7 @@ export const SampleCloudStorageInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [16_000, 12_000], change: { ratio: -0.25, @@ -757,7 +500,7 @@ export const SampleCloudStorageInsights: Entity = { entities: {}, }, { - id: 'Sample SKU C', + id: 'Mock SKU C', aggregation: [2_000, 0], change: { ratio: -1, @@ -781,7 +524,7 @@ export const SampleCloudStorageInsights: Entity = { }, }; -export const SampleComputeEngineInsights: Entity = { +export const MockComputeEngineInsights: Entity = { id: 'computeEngine', aggregation: [80_000, 90_000], change: { @@ -800,7 +543,7 @@ export const SampleComputeEngineInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [4_000, 2_000], change: { ratio: -0.5, @@ -809,7 +552,7 @@ export const SampleComputeEngineInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [7_000, 6_000], change: { ratio: -0.14285714285714285, @@ -818,7 +561,7 @@ export const SampleComputeEngineInsights: Entity = { entities: {}, }, { - id: 'Sample SKU C', + id: 'Mock SKU C', aggregation: [9_000, 2_000], change: { ratio: -0.7777777777777778, @@ -859,7 +602,7 @@ export const SampleComputeEngineInsights: Entity = { entities: { SKU: [ { - id: 'Sample SKU A', + id: 'Mock SKU A', aggregation: [1_000, 2_000], change: { ratio: 1, @@ -868,7 +611,7 @@ export const SampleComputeEngineInsights: Entity = { entities: {}, }, { - id: 'Sample SKU B', + id: 'Mock SKU B', aggregation: [4_000, 8_000], change: { ratio: 1, @@ -877,7 +620,7 @@ export const SampleComputeEngineInsights: Entity = { entities: {}, }, { - id: 'Sample SKU C', + id: 'Mock SKU C', aggregation: [5_000, 10_000], change: { ratio: 1, @@ -921,7 +664,7 @@ export const SampleComputeEngineInsights: Entity = { }, }; -export const SampleEventsInsights: Entity = { +export const MockEventsInsights: Entity = { id: 'events', aggregation: [20_000, 10_000], change: { @@ -940,7 +683,7 @@ export const SampleEventsInsights: Entity = { entities: { product: [ { - id: 'Sample Product A', + id: 'Mock Product A', aggregation: [5_000, 2_000], change: { ratio: -0.6, @@ -949,7 +692,7 @@ export const SampleEventsInsights: Entity = { entities: {}, }, { - id: 'Sample Product B', + id: 'Mock Product B', aggregation: [7_000, 2_500], change: { ratio: -0.64285714285, @@ -958,7 +701,7 @@ export const SampleEventsInsights: Entity = { entities: {}, }, { - id: 'Sample Product C', + id: 'Mock Product C', aggregation: [3_000, 2_500], change: { ratio: -0.16666666666, @@ -979,7 +722,7 @@ export const SampleEventsInsights: Entity = { entities: { product: [ { - id: 'Sample Product A', + id: 'Mock Product A', aggregation: [2_000, 1_000], change: { ratio: -0.5, @@ -988,7 +731,7 @@ export const SampleEventsInsights: Entity = { entities: {}, }, { - id: 'Sample Product B', + id: 'Mock Product B', aggregation: [1_000, 1_500], change: { ratio: 0.5, @@ -997,7 +740,7 @@ export const SampleEventsInsights: Entity = { entities: {}, }, { - id: 'Sample Product C', + id: 'Mock Product C', aggregation: [2_000, 500], change: { ratio: -0.75, @@ -1011,72 +754,3 @@ export const SampleEventsInsights: Entity = { ], }, }; - -export function entityOf(product: string): Entity { - switch (product) { - case 'computeEngine': - return SampleComputeEngineInsights; - case 'cloudDataflow': - return SampleCloudDataflowInsights; - case 'cloudStorage': - return SampleCloudStorageInsights; - case 'bigQuery': - return SampleBigQueryInsights; - case 'events': - return SampleEventsInsights; - default: - throw new Error( - `Cannot get insights for ${product}. Make sure product matches product property in app-info.yaml`, - ); - } -} - -export const getGroupedProducts = (intervals: string) => [ - { - id: 'Cloud Dataflow', - aggregation: aggregationFor(intervals, 1_700), - }, - { - id: 'Compute Engine', - aggregation: aggregationFor(intervals, 350), - }, - { - id: 'Cloud Storage', - aggregation: aggregationFor(intervals, 1_300), - }, - { - id: 'BigQuery', - aggregation: aggregationFor(intervals, 2_000), - }, - { - id: 'Cloud SQL', - aggregation: aggregationFor(intervals, 750), - }, - { - id: 'Cloud Spanner', - aggregation: aggregationFor(intervals, 50), - }, - { - id: 'Cloud Pub/Sub', - aggregation: aggregationFor(intervals, 1_000), - }, - { - id: 'Cloud Bigtable', - aggregation: aggregationFor(intervals, 250), - }, -]; - -export const getGroupedProjects = (intervals: string) => [ - { - id: 'project-a', - aggregation: aggregationFor(intervals, 1_700), - }, - { - id: 'project-b', - aggregation: aggregationFor(intervals, 350), - }, - { - id: 'project-c', - aggregation: aggregationFor(intervals, 1_300), - }, -]; diff --git a/plugins/cost-insights/src/testUtils/products.ts b/plugins/cost-insights/src/testUtils/products.ts new file mode 100644 index 0000000000..0ecac46244 --- /dev/null +++ b/plugins/cost-insights/src/testUtils/products.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, Product } from '../types'; +import { findAlways } from '../utils/assert'; + +type mockEntityRenderer = (entity: T) => T; + +export const createMockEntity = ( + callback?: mockEntityRenderer, +): Entity => { + const defaultEntity: Entity = { + id: 'test-entity', + aggregation: [100, 200], + entities: {}, + change: { + ratio: 0, + amount: 0, + }, + }; + + if (typeof callback === 'function') { + return callback({ ...defaultEntity }); + } + return { ...defaultEntity }; +}; + +export const createMockProduct = ( + callback?: mockEntityRenderer, +): Product => { + const defaultProduct: Product = { + kind: 'compute-engine', + name: 'Compute Engine', + }; + if (typeof callback === 'function') { + return callback({ ...defaultProduct }); + } + return { ...defaultProduct }; +}; + +export const MockProductTypes: Record = { + 'compute-engine': 'Compute Engine', + 'cloud-dataflow': 'Cloud Dataflow', + 'cloud-storage': 'Cloud Storage', + 'big-query': 'Big Query', + 'big-table': 'BigTable', + 'cloud-pub-sub': 'Cloud Pub/Sub', +}; + +export const MockProducts: Product[] = Object.keys(MockProductTypes).map( + productType => + createMockProduct(() => ({ + kind: productType, + name: MockProductTypes[productType], + })), +); + +export const MockComputeEngine = findAlways( + MockProducts, + p => p.kind === 'compute-engine', +); +export const MockCloudDataflow = findAlways( + MockProducts, + p => p.kind === 'cloud-dataflow', +); +export const MockCloudStorage = findAlways( + MockProducts, + p => p.kind === 'cloud-storage', +); +export const MockBigQuery = findAlways( + MockProducts, + p => p.kind === 'big-query', +); +export const MockBigtable = findAlways( + MockProducts, + p => p.kind === 'big-table', +); diff --git a/plugins/cost-insights/src/testUtils/providers.tsx b/plugins/cost-insights/src/testUtils/providers.tsx index aae2800573..1097d86be4 100644 --- a/plugins/cost-insights/src/testUtils/providers.tsx +++ b/plugins/cost-insights/src/testUtils/providers.tsx @@ -34,18 +34,6 @@ import { import { ScrollContext, ScrollContextProps } from '../hooks/useScroll'; import { Group, Duration } from '../types'; -/* - Mock Providers and types are exposed publicly to allow users to test custom implementations - such as alerts, which may require context. - - Utility functions such as getDefaultPageFilters, etc. are intentionally - omitted as we do not want to expose explictly or implicitly internal implementations - that may be subject to change. - - Each Mock Provider provides minimal defaults which can be overridden, allowing users to define - context props only when necessary. -*/ - type PartialPropsWithChildren = PropsWithChildren>; export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; diff --git a/plugins/cost-insights/src/testUtils/testUtils.ts b/plugins/cost-insights/src/testUtils/testUtils.ts new file mode 100644 index 0000000000..37913f635e --- /dev/null +++ b/plugins/cost-insights/src/testUtils/testUtils.ts @@ -0,0 +1,183 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import dayjs from 'dayjs'; +import regression, { DataPoint } from 'regression'; +import { + ChangeStatistic, + Duration, + Entity, + Trendline, + DateAggregation, + DEFAULT_DATE_FORMAT, +} from '../types'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; +import { + MockComputeEngineInsights, + MockCloudDataflowInsights, + MockCloudStorageInsights, + MockBigQueryInsights, + MockEventsInsights, +} from './mockData'; + +type IntervalFields = { + duration: Duration; + endDate: string; +}; + +function parseIntervals(intervals: string): IntervalFields { + const match = intervals.match( + /\/(?P\d+[DM])\/(?\d{4}-\d{2}-\d{2})/, + ); + if (Object.keys(match?.groups || {}).length !== 2) { + throw new Error(`Invalid intervals: ${intervals}`); + } + const { duration, date } = match!.groups!; + return { + duration: duration as Duration, + endDate: date, + }; +} + +export function aggregationFor( + intervals: string, + baseline: number, +): DateAggregation[] { + const { duration, endDate } = parseIntervals(intervals); + const inclusiveEndDate = inclusiveEndDateOf(duration, endDate); + const days = dayjs(endDate).diff( + inclusiveStartDateOf(duration, inclusiveEndDate), + 'day', + ); + + function nextDelta(): number { + const varianceFromBaseline = 0.15; + // Let's give positive vibes in trendlines - higher change for positive delta with >0.5 value + const positiveTrendChance = 0.55; + const normalization = positiveTrendChance - 1; + return baseline * (Math.random() + normalization) * varianceFromBaseline; + } + + return [...Array(days).keys()].reduce( + (values: DateAggregation[], i: number): DateAggregation[] => { + const last = values.length ? values[values.length - 1].amount : baseline; + const date = dayjs(inclusiveStartDateOf(duration, inclusiveEndDate)) + .add(i, 'day') + .format(DEFAULT_DATE_FORMAT); + const amount = Math.max(0, last + nextDelta()); + values.push({ + date: date, + amount: amount, + }); + return values; + }, + [], + ); +} + +export function changeOf(aggregation: DateAggregation[]): ChangeStatistic { + const firstAmount = aggregation.length ? aggregation[0].amount : 0; + const lastAmount = aggregation.length + ? aggregation[aggregation.length - 1].amount + : 0; + const ratio = + firstAmount !== 0 ? (lastAmount - firstAmount) / firstAmount : 0; + return { + ratio: ratio, + amount: lastAmount - firstAmount, + }; +} + +export function trendlineOf(aggregation: DateAggregation[]): Trendline { + const data: ReadonlyArray = aggregation.map(a => [ + Date.parse(a.date) / 1000, + a.amount, + ]); + const result = regression.linear(data, { precision: 5 }); + return { + slope: result.equation[0], + intercept: result.equation[1], + }; +} + +export function entityOf(product: string): Entity { + switch (product) { + case 'computeEngine': + return MockComputeEngineInsights; + case 'cloudDataflow': + return MockCloudDataflowInsights; + case 'cloudStorage': + return MockCloudStorageInsights; + case 'bigQuery': + return MockBigQueryInsights; + case 'events': + return MockEventsInsights; + default: + throw new Error( + `Cannot get insights for ${product}. Make sure product matches product property in app-info.yaml`, + ); + } +} + +export const getGroupedProducts = (intervals: string) => [ + { + id: 'Cloud Dataflow', + aggregation: aggregationFor(intervals, 1_700), + }, + { + id: 'Compute Engine', + aggregation: aggregationFor(intervals, 350), + }, + { + id: 'Cloud Storage', + aggregation: aggregationFor(intervals, 1_300), + }, + { + id: 'BigQuery', + aggregation: aggregationFor(intervals, 2_000), + }, + { + id: 'Cloud SQL', + aggregation: aggregationFor(intervals, 750), + }, + { + id: 'Cloud Spanner', + aggregation: aggregationFor(intervals, 50), + }, + { + id: 'Cloud Pub/Sub', + aggregation: aggregationFor(intervals, 1_000), + }, + { + id: 'Cloud Bigtable', + aggregation: aggregationFor(intervals, 250), + }, +]; + +export const getGroupedProjects = (intervals: string) => [ + { + id: 'project-a', + aggregation: aggregationFor(intervals, 1_700), + }, + { + id: 'project-b', + aggregation: aggregationFor(intervals, 350), + }, + { + id: 'project-c', + aggregation: aggregationFor(intervals, 1_300), + }, +]; diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index 9516dfe270..7208e9bc4f 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -22,7 +22,7 @@ import { Duration, Cost, } from '../types'; -import { MockAggregatedDailyCosts, trendlineOf, changeOf } from './mockData'; +import { MockAggregatedDailyCosts, trendlineOf, changeOf } from '../testUtils'; const GrowthMap = { [GrowthType.Negligible]: 'negligible growth',