summaryrefslogtreecommitdiff
path: root/spec/frontend/analytics
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/analytics')
-rw-r--r--spec/frontend/analytics/instance_statistics/apollo_mock_data.js30
-rw-r--r--spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap161
-rw-r--r--spec/frontend/analytics/instance_statistics/components/app_spec.js34
-rw-r--r--spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js54
-rw-r--r--spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js189
-rw-r--r--spec/frontend/analytics/instance_statistics/components/users_chart_spec.js200
-rw-r--r--spec/frontend/analytics/instance_statistics/mock_data.js42
-rw-r--r--spec/frontend/analytics/instance_statistics/utils_spec.js84
-rw-r--r--spec/frontend/analytics/shared/components/metric_card_spec.js129
9 files changed, 923 insertions, 0 deletions
diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js
new file mode 100644
index 00000000000..2e4eaf3fc96
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js
@@ -0,0 +1,30 @@
+const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null };
+
+export function getApolloResponse(options = {}) {
+ const {
+ pipelinesTotal = [],
+ pipelinesSucceeded = [],
+ pipelinesFailed = [],
+ pipelinesCanceled = [],
+ pipelinesSkipped = [],
+ hasNextPage = false,
+ } = options;
+ return {
+ data: {
+ pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal },
+ pipelinesSucceeded: {
+ pageInfo: { ...defaultPageInfo, hasNextPage },
+ nodes: pipelinesSucceeded,
+ },
+ pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed },
+ pipelinesCanceled: {
+ pageInfo: { ...defaultPageInfo, hasNextPage },
+ nodes: pipelinesCanceled,
+ },
+ pipelinesSkipped: {
+ pageInfo: { ...defaultPageInfo, hasNextPage },
+ nodes: pipelinesSkipped,
+ },
+ },
+ };
+}
diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap
new file mode 100644
index 00000000000..0b3b685a9f2
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap
@@ -0,0 +1,161 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
+Array [
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 21,
+ ],
+ Array [
+ "2020-07-01",
+ 10,
+ ],
+ Array [
+ "2020-08-01",
+ 5,
+ ],
+ ],
+ "name": "Total",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 21,
+ ],
+ Array [
+ "2020-07-01",
+ 10,
+ ],
+ Array [
+ "2020-08-01",
+ 5,
+ ],
+ ],
+ "name": "Succeeded",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 22,
+ ],
+ Array [
+ "2020-07-01",
+ 41,
+ ],
+ Array [
+ "2020-08-01",
+ 5,
+ ],
+ ],
+ "name": "Failed",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 21,
+ ],
+ Array [
+ "2020-07-01",
+ 10,
+ ],
+ Array [
+ "2020-08-01",
+ 5,
+ ],
+ ],
+ "name": "Canceled",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 21,
+ ],
+ Array [
+ "2020-07-01",
+ 10,
+ ],
+ Array [
+ "2020-08-01",
+ 5,
+ ],
+ ],
+ "name": "Skipped",
+ },
+]
+`;
+
+exports[`PipelinesChart with data passes the data to the line chart 1`] = `
+Array [
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 22,
+ ],
+ Array [
+ "2020-07-01",
+ 41,
+ ],
+ ],
+ "name": "Total",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 21,
+ ],
+ Array [
+ "2020-07-01",
+ 10,
+ ],
+ ],
+ "name": "Succeeded",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 21,
+ ],
+ Array [
+ "2020-07-01",
+ 10,
+ ],
+ ],
+ "name": "Failed",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 22,
+ ],
+ Array [
+ "2020-07-01",
+ 41,
+ ],
+ ],
+ "name": "Canceled",
+ },
+ Object {
+ "data": Array [
+ Array [
+ "2020-06-01",
+ 22,
+ ],
+ Array [
+ "2020-07-01",
+ 41,
+ ],
+ ],
+ "name": "Skipped",
+ },
+]
+`;
diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js
new file mode 100644
index 00000000000..df13c9f82a9
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
+import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
+import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
+import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
+
+describe('InstanceStatisticsApp', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(InstanceStatisticsApp);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays the instance counts component', () => {
+ expect(wrapper.find(InstanceCounts).exists()).toBe(true);
+ });
+
+ it('displays the pipelines chart component', () => {
+ expect(wrapper.find(PipelinesChart).exists()).toBe(true);
+ });
+
+ it('displays the users chart component', () => {
+ expect(wrapper.find(UsersChart).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
new file mode 100644
index 00000000000..12b5e14b9c4
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import { mockInstanceCounts } from '../mock_data';
+
+describe('InstanceCounts', () => {
+ let wrapper;
+
+ const createComponent = ({ loading = false, data = {} } = {}) => {
+ const $apollo = {
+ queries: {
+ counts: {
+ loading,
+ },
+ },
+ };
+
+ wrapper = shallowMount(InstanceCounts, {
+ mocks: { $apollo },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findMetricCard = () => wrapper.find(MetricCard);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ createComponent({ loading: true });
+ });
+
+ it('displays the metric card with isLoading=true', () => {
+ expect(findMetricCard().props('isLoading')).toBe(true);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(() => {
+ createComponent({ data: { counts: mockInstanceCounts } });
+ });
+
+ it('passes the counts data to the metric card', () => {
+ expect(findMetricCard().props('metrics')).toEqual(mockInstanceCounts);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js
new file mode 100644
index 00000000000..a06d66f783e
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js
@@ -0,0 +1,189 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlLineChart } from '@gitlab/ui/dist/charts';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
+import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import { mockCountsData1, mockCountsData2 } from '../mock_data';
+import { getApolloResponse } from '../apollo_mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('PipelinesChart', () => {
+ let wrapper;
+ let queryHandler;
+
+ const createApolloProvider = pipelineStatsHandler => {
+ return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]);
+ };
+
+ const createComponent = apolloProvider => {
+ return shallowMount(PipelinesChart, {
+ localVue,
+ apolloProvider,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findLoader = () => wrapper.find(ChartSkeletonLoader);
+ const findChart = () => wrapper.find(GlLineChart);
+ const findAlert = () => wrapper.find(GlAlert);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ queryHandler = jest.fn().mockReturnValue(new Promise(() => {}));
+ const apolloProvider = createApolloProvider(queryHandler);
+ wrapper = createComponent(apolloProvider);
+ });
+
+ it('requests data', () => {
+ expect(queryHandler).toBeCalledTimes(1);
+ });
+
+ it('displays the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('hides the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+
+ it('does not show an error', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('without data', () => {
+ beforeEach(() => {
+ const emptyResponse = getApolloResponse();
+ queryHandler = jest.fn().mockResolvedValue(emptyResponse);
+ const apolloProvider = createApolloProvider(queryHandler);
+ wrapper = createComponent(apolloProvider);
+ });
+
+ it('renders an no data message', () => {
+ expect(findAlert().text()).toBe('There is no data available.');
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(() => {
+ const response = getApolloResponse({
+ pipelinesTotal: mockCountsData1,
+ pipelinesSucceeded: mockCountsData2,
+ pipelinesFailed: mockCountsData2,
+ pipelinesCanceled: mockCountsData1,
+ pipelinesSkipped: mockCountsData1,
+ });
+ queryHandler = jest.fn().mockResolvedValue(response);
+ const apolloProvider = createApolloProvider(queryHandler);
+ wrapper = createComponent(apolloProvider);
+ });
+
+ it('requests data', () => {
+ expect(queryHandler).toBeCalledTimes(1);
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(true);
+ });
+
+ it('passes the data to the line chart', () => {
+ expect(findChart().props('data')).toMatchSnapshot();
+ });
+
+ it('does not show an error', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when fetching more data', () => {
+ const recordedAt = '2020-08-01';
+ describe('when the fetchMore query returns data', () => {
+ beforeEach(async () => {
+ const newData = { recordedAt, count: 5 };
+ const firstResponse = getApolloResponse({
+ pipelinesTotal: mockCountsData2,
+ pipelinesSucceeded: mockCountsData2,
+ pipelinesFailed: mockCountsData1,
+ pipelinesCanceled: mockCountsData2,
+ pipelinesSkipped: mockCountsData2,
+ hasNextPage: true,
+ });
+ const secondResponse = getApolloResponse({
+ pipelinesTotal: [newData],
+ pipelinesSucceeded: [newData],
+ pipelinesFailed: [newData],
+ pipelinesCanceled: [newData],
+ pipelinesSkipped: [newData],
+ hasNextPage: false,
+ });
+ queryHandler = jest
+ .fn()
+ .mockResolvedValueOnce(firstResponse)
+ .mockResolvedValueOnce(secondResponse);
+ const apolloProvider = createApolloProvider(queryHandler);
+ wrapper = createComponent(apolloProvider);
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('requests data twice', () => {
+ expect(queryHandler).toBeCalledTimes(2);
+ });
+
+ it('passes the data to the line chart', () => {
+ expect(findChart().props('data')).toMatchSnapshot();
+ });
+ });
+
+ describe('when the fetchMore query throws an error', () => {
+ beforeEach(async () => {
+ const response = getApolloResponse({
+ pipelinesTotal: mockCountsData2,
+ pipelinesSucceeded: mockCountsData2,
+ pipelinesFailed: mockCountsData1,
+ pipelinesCanceled: mockCountsData2,
+ pipelinesSkipped: mockCountsData2,
+ hasNextPage: true,
+ });
+ queryHandler = jest.fn().mockResolvedValue(response);
+ const apolloProvider = createApolloProvider(queryHandler);
+ wrapper = createComponent(apolloProvider);
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore')
+ .mockImplementation(jest.fn().mockRejectedValue());
+ await wrapper.vm.$nextTick();
+ });
+
+ it('calls fetchMore', () => {
+ expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('show an error message', () => {
+ expect(findAlert().text()).toBe(
+ 'Could not load the pipelines chart. Please refresh the page to try again.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js
new file mode 100644
index 00000000000..7509c1e6626
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js
@@ -0,0 +1,200 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import { useFakeDate } from 'helpers/fake_date';
+import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql';
+import { mockCountsData2, roundedSortedCountsMonthlyChartData2, mockPageInfo } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('UsersChart', () => {
+ let wrapper;
+ let queryHandler;
+
+ const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({
+ data: {
+ users: {
+ pageInfo: { ...mockPageInfo, hasNextPage },
+ nodes: users,
+ loading,
+ },
+ },
+ });
+
+ const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => {
+ const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users });
+ if (loading) {
+ return jest.fn().mockReturnValue(new Promise(() => {}));
+ }
+ if (hasNextPage) {
+ return jest
+ .fn()
+ .mockResolvedValueOnce(apolloQueryResponse)
+ .mockResolvedValueOnce(
+ mockApolloResponse({
+ loading,
+ hasNextPage: false,
+ users: [{ recordedAt: '2020-07-21', count: 5 }],
+ }),
+ );
+ }
+ return jest.fn().mockResolvedValue(apolloQueryResponse);
+ };
+
+ const createComponent = ({
+ loadingError = false,
+ loading = false,
+ users = [],
+ hasNextPage = false,
+ } = {}) => {
+ queryHandler = mockQueryResponse({ users, loading, hasNextPage });
+
+ return shallowMount(UsersChart, {
+ props: {
+ startDate: useFakeDate(2020, 9, 26),
+ endDate: useFakeDate(2020, 10, 1),
+ totalDataPoints: mockCountsData2.length,
+ },
+ localVue,
+ apolloProvider: createMockApollo([[usersQuery, queryHandler]]),
+ data() {
+ return { loadingError };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findLoader = () => wrapper.find(ChartSkeletonLoader);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findChart = () => wrapper.find(GlAreaChart);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ loading: true });
+ });
+
+ it('displays the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('hides the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('without data', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ users: [] });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders an no data message', () => {
+ expect(findAlert().text()).toBe('There is no data available.');
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ users: mockCountsData2 });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(true);
+ });
+
+ it('passes the data to the line chart', () => {
+ expect(findChart().props('data')).toEqual([
+ { data: roundedSortedCountsMonthlyChartData2, name: 'Total users' },
+ ]);
+ });
+ });
+
+ describe('with errors', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ loadingError: true });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders an error message', () => {
+ expect(findAlert().text()).toBe(
+ 'Could not load the user chart. Please refresh the page to try again.',
+ );
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('when fetching more data', () => {
+ describe('when the fetchMore query returns data', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ users: mockCountsData2,
+ hasNextPage: true,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('requests data twice', () => {
+ expect(queryHandler).toBeCalledTimes(2);
+ });
+
+ it('calls fetchMore', () => {
+ expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when the fetchMore query throws an error', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ users: mockCountsData2,
+ hasNextPage: true,
+ });
+
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore')
+ .mockImplementation(jest.fn().mockRejectedValue());
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls fetchMore', () => {
+ expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders an error message', () => {
+ expect(findAlert().text()).toBe(
+ 'Could not load the user chart. Please refresh the page to try again.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js
new file mode 100644
index 00000000000..b737db4c55f
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/mock_data.js
@@ -0,0 +1,42 @@
+export const mockInstanceCounts = [
+ { key: 'projects', value: 10, label: 'Projects' },
+ { key: 'groups', value: 20, label: 'Group' },
+];
+
+export const mockCountsData1 = [
+ { recordedAt: '2020-07-23', count: 52 },
+ { recordedAt: '2020-07-22', count: 40 },
+ { recordedAt: '2020-07-21', count: 31 },
+ { recordedAt: '2020-06-14', count: 23 },
+ { recordedAt: '2020-06-12', count: 20 },
+];
+
+export const countsMonthlyChartData1 = [
+ ['2020-07-01', 41], // average of 2020-07-x items
+ ['2020-06-01', 21.5], // average of 2020-06-x items
+];
+
+export const mockCountsData2 = [
+ { recordedAt: '2020-07-28', count: 10 },
+ { recordedAt: '2020-07-27', count: 9 },
+ { recordedAt: '2020-06-26', count: 14 },
+ { recordedAt: '2020-06-25', count: 23 },
+ { recordedAt: '2020-06-24', count: 25 },
+];
+
+export const countsMonthlyChartData2 = [
+ ['2020-07-01', 9.5], // average of 2020-07-x items
+ ['2020-06-01', 20.666666666666668], // average of 2020-06-x items
+];
+
+export const roundedSortedCountsMonthlyChartData2 = [
+ ['2020-06-01', 21], // average of 2020-06-x items
+ ['2020-07-01', 10], // average of 2020-07-x items
+];
+
+export const mockPageInfo = {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ endCursor: null,
+};
diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/instance_statistics/utils_spec.js
new file mode 100644
index 00000000000..d480238419b
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/utils_spec.js
@@ -0,0 +1,84 @@
+import {
+ getAverageByMonth,
+ extractValues,
+ sortByDate,
+} from '~/analytics/instance_statistics/utils';
+import {
+ mockCountsData1,
+ mockCountsData2,
+ countsMonthlyChartData1,
+ countsMonthlyChartData2,
+} from './mock_data';
+
+describe('getAverageByMonth', () => {
+ it('collects data into average by months', () => {
+ expect(getAverageByMonth(mockCountsData1)).toStrictEqual(countsMonthlyChartData1);
+ expect(getAverageByMonth(mockCountsData2)).toStrictEqual(countsMonthlyChartData2);
+ });
+
+ it('it transforms a data point to the first of the month', () => {
+ const item = mockCountsData1[0];
+ const firstOfTheMonth = item.recordedAt.replace(/-[0-9]{2}$/, '-01');
+ expect(getAverageByMonth([item])).toStrictEqual([[firstOfTheMonth, item.count]]);
+ });
+
+ it('it uses sane defaults', () => {
+ expect(getAverageByMonth()).toStrictEqual([]);
+ });
+
+ it('it errors when passing null', () => {
+ expect(() => {
+ getAverageByMonth(null);
+ }).toThrow();
+ });
+
+ describe('when shouldRound = true', () => {
+ const options = { shouldRound: true };
+
+ it('rounds the averages', () => {
+ const roundedData1 = countsMonthlyChartData1.map(([date, avg]) => [date, Math.round(avg)]);
+ const roundedData2 = countsMonthlyChartData2.map(([date, avg]) => [date, Math.round(avg)]);
+ expect(getAverageByMonth(mockCountsData1, options)).toStrictEqual(roundedData1);
+ expect(getAverageByMonth(mockCountsData2, options)).toStrictEqual(roundedData2);
+ });
+ });
+});
+
+describe('extractValues', () => {
+ it('extracts only requested values', () => {
+ const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
+ expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' });
+ });
+
+ it('is able to extract multiple values', () => {
+ const data = {
+ fooBar: { baz: 'quis' },
+ fooBaz: { baz: 'quis' },
+ fooQuis: { baz: 'quis' },
+ };
+ expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({
+ bazBar: 'quis',
+ bazBaz: 'quis',
+ bazQuis: 'quis',
+ });
+ });
+
+ it('returns empty data set when keys are not found', () => {
+ const data = { foo: { baz: 'quis' }, ignored: 'ignored' };
+ expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({});
+ });
+
+ it('returns empty data when params are missing', () => {
+ expect(extractValues()).toEqual({});
+ });
+});
+
+describe('sortByDate', () => {
+ it('sorts the array by date', () => {
+ expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse());
+ });
+
+ it('does not modify the original array', () => {
+ expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1);
+ });
+});
diff --git a/spec/frontend/analytics/shared/components/metric_card_spec.js b/spec/frontend/analytics/shared/components/metric_card_spec.js
new file mode 100644
index 00000000000..e89d499ed9b
--- /dev/null
+++ b/spec/frontend/analytics/shared/components/metric_card_spec.js
@@ -0,0 +1,129 @@
+import { mount } from '@vue/test-utils';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+
+const metrics = [
+ { key: 'first_metric', value: 10, label: 'First metric', unit: 'days', link: 'some_link' },
+ { key: 'second_metric', value: 20, label: 'Yet another metric' },
+ { key: 'third_metric', value: null, label: 'Null metric without value', unit: 'parsecs' },
+ { key: 'fourth_metric', value: '-', label: 'Metric without value', unit: 'parsecs' },
+];
+
+const defaultProps = {
+ title: 'My fancy title',
+ isLoading: false,
+ metrics,
+};
+
+describe('MetricCard', () => {
+ let wrapper;
+
+ const factory = (props = defaultProps) => {
+ wrapper = mount(MetricCard, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findTitle = () => wrapper.find({ ref: 'title' });
+ const findLoadingIndicator = () => wrapper.find(GlSkeletonLoading);
+ const findMetricsWrapper = () => wrapper.find({ ref: 'metricsWrapper' });
+ const findMetricItem = () => wrapper.findAll({ ref: 'metricItem' });
+ const findTooltip = () => wrapper.find('[data-testid="tooltip"]');
+
+ describe('template', () => {
+ it('renders the title', () => {
+ factory();
+
+ expect(findTitle().text()).toContain('My fancy title');
+ });
+
+ describe('when isLoading is true', () => {
+ beforeEach(() => {
+ factory({ isLoading: true });
+ });
+
+ it('displays a loading indicator', () => {
+ expect(findLoadingIndicator().exists()).toBe(true);
+ });
+
+ it('does not display the metrics container', () => {
+ expect(findMetricsWrapper().exists()).toBe(false);
+ });
+ });
+
+ describe('when isLoading is false', () => {
+ beforeEach(() => {
+ factory({ isLoading: false });
+ });
+
+ it('does not display a loading indicator', () => {
+ expect(findLoadingIndicator().exists()).toBe(false);
+ });
+
+ it('displays the metrics container', () => {
+ expect(findMetricsWrapper().exists()).toBe(true);
+ });
+
+ it('renders two metrics', () => {
+ expect(findMetricItem()).toHaveLength(metrics.length);
+ });
+
+ describe('with tooltip text', () => {
+ const tooltipText = 'This is a tooltip';
+ const tooltipMetric = {
+ key: 'fifth_metric',
+ value: '-',
+ label: 'Metric with tooltip',
+ unit: 'parsecs',
+ tooltipText,
+ };
+
+ beforeEach(() => {
+ factory({
+ isLoading: false,
+ metrics: [tooltipMetric],
+ });
+ });
+
+ it('will render a tooltip', () => {
+ const tt = getBinding(findTooltip().element, 'gl-tooltip');
+ expect(tt.value.title).toEqual(tooltipText);
+ });
+ });
+
+ describe.each`
+ columnIndex | label | value | unit | link
+ ${0} | ${'First metric'} | ${10} | ${' days'} | ${'some_link'}
+ ${1} | ${'Yet another metric'} | ${20} | ${''} | ${null}
+ ${2} | ${'Null metric without value'} | ${'-'} | ${''} | ${null}
+ ${3} | ${'Metric without value'} | ${'-'} | ${''} | ${null}
+ `('metric columns', ({ columnIndex, label, value, unit, link }) => {
+ it(`renders ${value}${unit} ${label} with URL ${link}`, () => {
+ const allMetricItems = findMetricItem();
+ const metricItem = allMetricItems.at(columnIndex);
+ const text = metricItem.text();
+
+ expect(text).toContain(`${value}${unit}`);
+ expect(text).toContain(label);
+
+ if (link) {
+ expect(metricItem.find('a').attributes('href')).toBe(link);
+ } else {
+ expect(metricItem.find('a').exists()).toBe(false);
+ }
+ });
+ });
+ });
+ });
+});