Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/cycle_analytics')
-rw-r--r--spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap9
-rw-r--r--spec/frontend/cycle_analytics/base_spec.js197
-rw-r--r--spec/frontend/cycle_analytics/mock_data.js175
-rw-r--r--spec/frontend/cycle_analytics/path_navigation_spec.js148
-rw-r--r--spec/frontend/cycle_analytics/store/actions_spec.js191
-rw-r--r--spec/frontend/cycle_analytics/store/getters_spec.js16
-rw-r--r--spec/frontend/cycle_analytics/store/mutations_spec.js34
-rw-r--r--spec/frontend/cycle_analytics/utils_spec.js129
8 files changed, 790 insertions, 109 deletions
diff --git a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap
new file mode 100644
index 00000000000..1af612ed029
--- /dev/null
+++ b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
+
+exports[`Value stream analytics component isEmptyStage = true with a selectedStageError renders the empty stage with \`There is too much data to calculate\` message 1`] = `"<gl-empty-state-stub title=\\"There is too much data to calculate\\" svgpath=\\"path/to/no/data\\" description=\\"\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
+
+exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`;
+
+exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js
new file mode 100644
index 00000000000..2f85cc04051
--- /dev/null
+++ b/spec/frontend/cycle_analytics/base_spec.js
@@ -0,0 +1,197 @@
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import BaseComponent from '~/cycle_analytics/components/base.vue';
+import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
+import initState from '~/cycle_analytics/store/state';
+import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data';
+
+const noDataSvgPath = 'path/to/no/data';
+const noAccessSvgPath = 'path/to/no/access';
+
+Vue.use(Vuex);
+
+let wrapper;
+
+function createStore({ initialState = {} }) {
+ return new Vuex.Store({
+ state: {
+ ...initState(),
+ permissions: {
+ [selectedStage.id]: true,
+ },
+ ...initialState,
+ },
+ getters: {
+ pathNavigationData: () => [],
+ },
+ });
+}
+
+function createComponent({ initialState } = {}) {
+ return extendedWrapper(
+ shallowMount(BaseComponent, {
+ store: createStore({ initialState }),
+ propsData: {
+ noDataSvgPath,
+ noAccessSvgPath,
+ },
+ }),
+ );
+}
+
+const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+const findPathNavigation = () => wrapper.findComponent(PathNavigation);
+const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
+const findStageTable = () => wrapper.findByTestId('vsa-stage-table');
+const findEmptyStage = () => wrapper.findComponent(GlEmptyState);
+const findStageEvents = () => wrapper.findByTestId('stage-table-events');
+
+describe('Value stream analytics component', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: {
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+ selectedStageEvents,
+ selectedStage,
+ selectedStageError: '',
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders the path navigation component', () => {
+ expect(findPathNavigation().exists()).toBe(true);
+ });
+
+ it('renders the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(true);
+ });
+
+ it('renders the stage table', () => {
+ expect(findStageTable().exists()).toBe(true);
+ });
+
+ it('renders the stage table events', () => {
+ expect(findEmptyStage().exists()).toBe(false);
+ expect(findStageEvents().exists()).toBe(true);
+ });
+
+ it('does not render the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ describe('isLoading = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { isLoading: true },
+ });
+ });
+
+ it('renders the path navigation component with prop `loading` set to true', () => {
+ expect(findPathNavigation().html()).toMatchSnapshot();
+ });
+
+ it('does not render the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(false);
+ });
+
+ it('does not render the stage table', () => {
+ expect(findStageTable().exists()).toBe(false);
+ });
+
+ it('renders the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('isLoadingStage = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { isLoadingStage: true },
+ });
+ });
+
+ it('renders the stage table with a loading icon', () => {
+ const tableWrapper = findStageTable();
+ expect(tableWrapper.exists()).toBe(true);
+ expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('isEmptyStage = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { selectedStage, isEmptyStage: true },
+ });
+ });
+
+ it('renders the empty stage with `Not enough data` message', () => {
+ expect(findEmptyStage().html()).toMatchSnapshot();
+ });
+
+ describe('with a selectedStageError', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: {
+ selectedStage,
+ isEmptyStage: true,
+ selectedStageError: 'There is too much data to calculate',
+ },
+ });
+ });
+
+ it('renders the empty stage with `There is too much data to calculate` message', () => {
+ expect(findEmptyStage().html()).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('without enough permissions', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: {
+ permissions: {
+ [selectedStage.id]: false,
+ },
+ },
+ });
+ });
+
+ it('renders the empty stage with `You need permission` message', () => {
+ expect(findEmptyStage().html()).toMatchSnapshot();
+ });
+ });
+
+ describe('without a selected stage', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { selectedStage: null, isEmptyStage: true },
+ });
+ });
+
+ it('renders the stage table', () => {
+ expect(findStageTable().exists()).toBe(true);
+ });
+
+ it('does not render the path navigation component', () => {
+ expect(findPathNavigation().exists()).toBe(false);
+ });
+
+ it('does not render the stage table events', () => {
+ expect(findStageEvents().exists()).toBe(false);
+ });
+
+ it('does not render the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js
index 091b574821d..242ea1932fb 100644
--- a/spec/frontend/cycle_analytics/mock_data.js
+++ b/spec/frontend/cycle_analytics/mock_data.js
@@ -1,5 +1,11 @@
+import { DEFAULT_VALUE_STREAM } from '~/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+export const getStageByTitle = (stages, title) =>
+ stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {};
+
+export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging'];
+
export const summary = [
{ value: '20', title: 'New Issues' },
{ value: null, title: 'Commits' },
@@ -8,6 +14,7 @@ export const summary = [
];
const issueStage = {
+ id: 'issue',
title: 'Issue',
name: 'issue',
legend: '',
@@ -16,30 +23,34 @@ const issueStage = {
};
const planStage = {
+ id: 'plan',
title: 'Plan',
name: 'plan',
legend: '',
description: 'Time before an issue starts implementation',
- value: 'about 21 hours',
+ value: 75600,
};
const codeStage = {
+ id: 'code',
title: 'Code',
name: 'code',
legend: '',
description: 'Time until first merge request',
- value: '2 days',
+ value: 172800,
};
const testStage = {
+ id: 'test',
title: 'Test',
name: 'test',
legend: '',
description: 'Total test time for all commits/merges',
- value: 'about 5 hours',
+ value: 17550,
};
const reviewStage = {
+ id: 'review',
title: 'Review',
name: 'review',
legend: '',
@@ -48,11 +59,12 @@ const reviewStage = {
};
const stagingStage = {
+ id: 'staging',
title: 'Staging',
name: 'staging',
legend: '',
description: 'From merge request merge until deploy to production',
- value: '2 days',
+ value: 172800,
};
export const selectedStage = {
@@ -84,54 +96,6 @@ export const rawData = {
};
export const convertedData = {
- stages: [
- selectedStage,
- {
- ...planStage,
- active: false,
- isUserAllowed: true,
- emptyStageText:
- 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
- component: 'stage-plan-component',
- slug: 'plan',
- },
- {
- ...codeStage,
- active: false,
- isUserAllowed: true,
- emptyStageText:
- 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
- component: 'stage-code-component',
- slug: 'code',
- },
- {
- ...testStage,
- active: false,
- isUserAllowed: true,
- emptyStageText:
- 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
- component: 'stage-test-component',
- slug: 'test',
- },
- {
- ...reviewStage,
- active: false,
- isUserAllowed: true,
- emptyStageText:
- 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
- component: 'stage-review-component',
- slug: 'review',
- },
- {
- ...stagingStage,
- active: false,
- isUserAllowed: true,
- emptyStageText:
- 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
- component: 'stage-staging-component',
- slug: 'staging',
- },
- ],
summary: [
{ value: '20', title: 'New Issues' },
{ value: '-', title: 'Commits' },
@@ -184,3 +148,110 @@ export const rawEvents = [
export const convertedEvents = rawEvents.map((ev) =>
convertObjectPropsToCamelCase(ev, { deep: true }),
);
+
+export const pathNavIssueMetric = 172800;
+
+export const rawStageMedians = [
+ { id: 'issue', value: 172800 },
+ { id: 'plan', value: 86400 },
+ { id: 'review', value: 1036800 },
+ { id: 'code', value: 129600 },
+ { id: 'test', value: 259200 },
+ { id: 'staging', value: 388800 },
+];
+
+export const stageMedians = {
+ issue: 172800,
+ plan: 86400,
+ review: 1036800,
+ code: 129600,
+ test: 259200,
+ staging: 388800,
+};
+
+export const allowedStages = [issueStage, planStage, codeStage];
+
+export const transformedProjectStagePathData = [
+ {
+ metric: 172800,
+ selected: true,
+ stageCount: undefined,
+ icon: null,
+ id: 'issue',
+ title: 'Issue',
+ name: 'issue',
+ legend: '',
+ description: 'Time before an issue gets scheduled',
+ value: null,
+ },
+ {
+ metric: 86400,
+ selected: false,
+ stageCount: undefined,
+ icon: null,
+ id: 'plan',
+ title: 'Plan',
+ name: 'plan',
+ legend: '',
+ description: 'Time before an issue starts implementation',
+ value: 75600,
+ },
+ {
+ metric: 129600,
+ selected: false,
+ stageCount: undefined,
+ icon: null,
+ id: 'code',
+ title: 'Code',
+ name: 'code',
+ legend: '',
+ description: 'Time until first merge request',
+ value: 172800,
+ },
+];
+
+export const selectedValueStream = DEFAULT_VALUE_STREAM;
+
+export const rawValueStreamStages = [
+ {
+ title: 'Issue',
+ hidden: false,
+ legend: '',
+ description: 'Time before an issue gets scheduled',
+ id: 'issue',
+ custom: false,
+ start_event_html_description:
+ '\u003cp data-sourcepos="1:1-1:13" dir="auto"\u003eIssue created\u003c/p\u003e',
+ end_event_html_description:
+ '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e',
+ },
+ {
+ title: 'Plan',
+ hidden: false,
+ legend: '',
+ description: 'Time before an issue starts implementation',
+ id: 'plan',
+ custom: false,
+ start_event_html_description:
+ '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e',
+ end_event_html_description:
+ '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e',
+ },
+ {
+ title: 'Code',
+ hidden: false,
+ legend: '',
+ description: 'Time until first merge request',
+ id: 'code',
+ custom: false,
+ start_event_html_description:
+ '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e',
+ end_event_html_description:
+ '\u003cp data-sourcepos="1:1-1:21" dir="auto"\u003eMerge request created\u003c/p\u003e',
+ },
+];
+
+export const valueStreamStages = rawValueStreamStages.map((s) => ({
+ ...convertObjectPropsToCamelCase(s, { deep: true }),
+ component: `stage-${s.id}-component`,
+}));
diff --git a/spec/frontend/cycle_analytics/path_navigation_spec.js b/spec/frontend/cycle_analytics/path_navigation_spec.js
new file mode 100644
index 00000000000..c6d72d3b571
--- /dev/null
+++ b/spec/frontend/cycle_analytics/path_navigation_spec.js
@@ -0,0 +1,148 @@
+import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import Component from '~/cycle_analytics/components/path_navigation.vue';
+import { transformedProjectStagePathData, selectedStage } from './mock_data';
+
+describe('Project PathNavigation', () => {
+ let wrapper = null;
+ let trackingSpy = null;
+
+ const createComponent = (props) => {
+ return extendedWrapper(
+ mount(Component, {
+ propsData: {
+ stages: transformedProjectStagePathData,
+ selectedStage,
+ loading: false,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ const findPathNavigation = () => {
+ return wrapper.findByTestId('gl-path-nav');
+ };
+
+ const findPathNavigationItems = () => {
+ return findPathNavigation().findAll('li');
+ };
+
+ const findPathNavigationTitles = () => {
+ return findPathNavigation()
+ .findAll('li button')
+ .wrappers.map((w) => w.html());
+ };
+
+ const clickItemAt = (index) => {
+ findPathNavigationItems().at(index).find('button').trigger('click');
+ };
+
+ const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper);
+ const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('displays correctly', () => {
+ it('has the correct props', () => {
+ expect(wrapper.find(GlPath).props('items')).toMatchObject(transformedProjectStagePathData);
+ });
+
+ it('contains all the expected stages', () => {
+ const stageContent = findPathNavigationTitles();
+ transformedProjectStagePathData.forEach((stage, index) => {
+ expect(stageContent[index]).toContain(stage.title);
+ });
+ });
+
+ describe('loading', () => {
+ describe('is false', () => {
+ it('displays the gl-path component', () => {
+ expect(wrapper.find(GlPath).exists()).toBe(true);
+ });
+
+ it('hides the gl-skeleton-loading component', () => {
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
+ });
+
+ it('renders each stage', () => {
+ const result = findPathNavigationTitles();
+ expect(result.length).toBe(transformedProjectStagePathData.length);
+ });
+
+ it('renders each stage with its median', () => {
+ const result = findPathNavigationTitles();
+ transformedProjectStagePathData.forEach(({ title, metric }, index) => {
+ expect(result[index]).toContain(title);
+ expect(result[index]).toContain(metric);
+ });
+ });
+
+ describe('popovers', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stages: transformedProjectStagePathData });
+ });
+
+ it('renders popovers for all stages', () => {
+ pathItemContent().forEach((stage) => {
+ expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
+ });
+ });
+
+ it('shows the median stage time for the first stage item', () => {
+ expect(firstPopover().text()).toContain('Stage time (median)');
+ });
+ });
+ });
+
+ describe('is true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ loading: true });
+ });
+
+ it('hides the gl-path component', () => {
+ expect(wrapper.find(GlPath).exists()).toBe(false);
+ });
+
+ it('displays the gl-skeleton-loading component', () => {
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('event handling', () => {
+ it('emits the selected event', () => {
+ expect(wrapper.emitted('selected')).toBeUndefined();
+
+ clickItemAt(0);
+ clickItemAt(1);
+ clickItemAt(2);
+
+ expect(wrapper.emitted().selected).toEqual([
+ [transformedProjectStagePathData[0]],
+ [transformedProjectStagePathData[1]],
+ [transformedProjectStagePathData[2]],
+ ]);
+ });
+
+ it('sends tracking information', () => {
+ clickItemAt(0);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_path_navigation', {
+ extra: { stage_id: selectedStage.slug },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js
index 630c5100754..4f37e1266fb 100644
--- a/spec/frontend/cycle_analytics/store/actions_spec.js
+++ b/spec/frontend/cycle_analytics/store/actions_spec.js
@@ -3,10 +3,27 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/cycle_analytics/store/actions';
import httpStatusCodes from '~/lib/utils/http_status';
-import { selectedStage } from '../mock_data';
+import { selectedStage, selectedValueStream } from '../mock_data';
const mockRequestPath = 'some/cool/path';
+const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
const mockStartDate = 30;
+const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData'];
+const mockInitializeActionCommit = {
+ payload: { requestPath: mockRequestPath },
+ type: 'INITIALIZE_VSA',
+};
+const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' };
+const mockRequestedDataMutations = [
+ {
+ payload: true,
+ type: 'SET_LOADING',
+ },
+ {
+ payload: false,
+ type: 'SET_LOADING',
+ },
+];
describe('Project Value Stream Analytics actions', () => {
let state;
@@ -22,27 +39,26 @@ describe('Project Value Stream Analytics actions', () => {
state = {};
});
- it.each`
- action | type | payload | expectedActions
- ${'initializeVsa'} | ${'INITIALIZE_VSA'} | ${{ requestPath: mockRequestPath }} | ${['fetchCycleAnalyticsData']}
- ${'setDateRange'} | ${'SET_DATE_RANGE'} | ${{ startDate: 30 }} | ${[]}
- ${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${{ selectedStage }} | ${[]}
- `(
- '$action should dispatch $expectedActions and commit $type',
- ({ action, type, payload, expectedActions }) =>
+ const mutationTypes = (arr) => arr.map(({ type }) => type);
+
+ describe.each`
+ action | payload | expectedActions | expectedMutations
+ ${'initializeVsa'} | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions} | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]}
+ ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockRequestedDataActions} | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]}
+ ${'setSelectedStage'} | ${{ selectedStage }} | ${['fetchStageData']} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
+ ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
+ `('$action', ({ action, payload, expectedActions, expectedMutations }) => {
+ const types = mutationTypes(expectedMutations);
+
+ it(`will dispatch ${expectedActions} and commit ${types}`, () =>
testAction({
action: actions[action],
state,
payload,
- expectedMutations: [
- {
- type,
- payload,
- },
- ],
+ expectedMutations,
expectedActions: expectedActions.map((a) => ({ type: a })),
- }),
- );
+ }));
+ });
describe('fetchCycleAnalyticsData', () => {
beforeEach(() => {
@@ -60,7 +76,7 @@ describe('Project Value Stream Analytics actions', () => {
{ type: 'REQUEST_CYCLE_ANALYTICS_DATA' },
{ type: 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS' },
],
- expectedActions: [{ type: 'setSelectedStage' }, { type: 'fetchStageData' }],
+ expectedActions: [],
}));
describe('with a failing request', () => {
@@ -85,7 +101,7 @@ describe('Project Value Stream Analytics actions', () => {
});
describe('fetchStageData', () => {
- const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}.json`;
+ const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`;
beforeEach(() => {
state = {
@@ -106,6 +122,32 @@ describe('Project Value Stream Analytics actions', () => {
expectedActions: [],
}));
+ describe('with a successful request, but an error in the payload', () => {
+ const tooMuchDataError = 'Too much data';
+
+ beforeEach(() => {
+ state = {
+ requestPath: mockRequestPath,
+ startDate: mockStartDate,
+ selectedStage,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockStagePath).reply(httpStatusCodes.OK, { error: tooMuchDataError });
+ });
+
+ it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageData,
+ state,
+ payload: { error: tooMuchDataError },
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_DATA' },
+ { type: 'RECEIVE_STAGE_DATA_ERROR', payload: tooMuchDataError },
+ ],
+ expectedActions: [],
+ }));
+ });
+
describe('with a failing request', () => {
beforeEach(() => {
state = {
@@ -127,4 +169,115 @@ describe('Project Value Stream Analytics actions', () => {
}));
});
});
+
+ describe('fetchValueStreams', () => {
+ const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/;
+
+ beforeEach(() => {
+ state = {
+ fullPath: mockFullPath,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
+ });
+
+ it(`commits the 'REQUEST_VALUE_STREAMS' mutation`, () =>
+ testAction({
+ action: actions.fetchValueStreams,
+ state,
+ payload: {},
+ expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }],
+ expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_VALUE_STREAMS_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchValueStreams,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_VALUE_STREAMS' },
+ { type: 'RECEIVE_VALUE_STREAMS_ERROR', payload: httpStatusCodes.BAD_REQUEST },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
+
+ describe('receiveValueStreamsSuccess', () => {
+ const mockValueStream = {
+ id: 'mockDefault',
+ name: 'mock default',
+ };
+ const mockValueStreams = [mockValueStream, selectedValueStream];
+ it('with data, will set the first value stream', () => {
+ testAction({
+ action: actions.receiveValueStreamsSuccess,
+ state,
+ payload: mockValueStreams,
+ expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: mockValueStreams }],
+ expectedActions: [{ type: 'setSelectedValueStream', payload: mockValueStream }],
+ });
+ });
+
+ it('without data, will set the default value stream', () => {
+ testAction({
+ action: actions.receiveValueStreamsSuccess,
+ state,
+ payload: [],
+ expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: [] }],
+ expectedActions: [{ type: 'setSelectedValueStream', payload: selectedValueStream }],
+ });
+ });
+ });
+
+ describe('fetchValueStreamStages', () => {
+ const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/;
+
+ beforeEach(() => {
+ state = {
+ fullPath: mockFullPath,
+ selectedValueStream,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
+ });
+
+ it(`commits the 'REQUEST_VALUE_STREAM_STAGES' and 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' mutations`, () =>
+ testAction({
+ action: actions.fetchValueStreamStages,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_VALUE_STREAM_STAGES' },
+ { type: 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchValueStreamStages,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_VALUE_STREAM_STAGES' },
+ { type: 'RECEIVE_VALUE_STREAM_STAGES_ERROR', payload: httpStatusCodes.BAD_REQUEST },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
});
diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js
new file mode 100644
index 00000000000..5745e9d7902
--- /dev/null
+++ b/spec/frontend/cycle_analytics/store/getters_spec.js
@@ -0,0 +1,16 @@
+import * as getters from '~/cycle_analytics/store/getters';
+import {
+ allowedStages,
+ stageMedians,
+ transformedProjectStagePathData,
+ selectedStage,
+} from '../mock_data';
+
+describe('Value stream analytics getters', () => {
+ describe('pathNavigationData', () => {
+ it('returns the transformed data', () => {
+ const state = { stages: allowedStages, medians: stageMedians, selectedStage };
+ expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData);
+ });
+ });
+});
diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js
index 08c70af6ef6..88e1a13f506 100644
--- a/spec/frontend/cycle_analytics/store/mutations_spec.js
+++ b/spec/frontend/cycle_analytics/store/mutations_spec.js
@@ -1,6 +1,15 @@
import * as types from '~/cycle_analytics/store/mutation_types';
import mutations from '~/cycle_analytics/store/mutations';
-import { selectedStage, rawEvents, convertedEvents, rawData, convertedData } from '../mock_data';
+import {
+ selectedStage,
+ rawEvents,
+ convertedEvents,
+ rawData,
+ convertedData,
+ selectedValueStream,
+ rawValueStreamStages,
+ valueStreamStages,
+} from '../mock_data';
let state;
const mockRequestPath = 'fake/request/path';
@@ -17,15 +26,15 @@ describe('Project Value Stream Analytics mutations', () => {
it.each`
mutation | stateKey | value
- ${types.SET_SELECTED_STAGE} | ${'isLoadingStage'} | ${false}
+ ${types.REQUEST_VALUE_STREAMS} | ${'valueStreams'} | ${[]}
+ ${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'valueStreams'} | ${[]}
+ ${types.REQUEST_VALUE_STREAM_STAGES} | ${'stages'} | ${[]}
+ ${types.RECEIVE_VALUE_STREAM_STAGES_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
- ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'stages'} | ${[]}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'hasError'} | ${false}
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${'isLoading'} | ${false}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${'hasError'} | ${false}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'isLoading'} | ${false}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'hasError'} | ${true}
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isEmptyStage'} | ${false}
${types.REQUEST_STAGE_DATA} | ${'hasError'} | ${false}
@@ -44,12 +53,15 @@ describe('Project Value Stream Analytics mutations', () => {
});
it.each`
- mutation | payload | stateKey | value
- ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath}
- ${types.SET_SELECTED_STAGE} | ${selectedStage} | ${'selectedStage'} | ${selectedStage}
- ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData}
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'stages'} | ${convertedData.stages}
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
+ mutation | payload | stateKey | value
+ ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath}
+ ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData}
+ ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
+ ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
+ ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
+ ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
+ ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
+ ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js
index 73e26e1cdcc..15137bb0571 100644
--- a/spec/frontend/cycle_analytics/utils_spec.js
+++ b/spec/frontend/cycle_analytics/utils_spec.js
@@ -1,5 +1,22 @@
-import { decorateEvents, decorateData } from '~/cycle_analytics/utils';
-import { selectedStage, rawData, convertedData, rawEvents } from './mock_data';
+import {
+ decorateEvents,
+ decorateData,
+ transformStagesForPathNavigation,
+ timeSummaryForPathNavigation,
+ medianTimeToParsedSeconds,
+ formatMedianValues,
+ filterStagesByHiddenStatus,
+} from '~/cycle_analytics/utils';
+import {
+ selectedStage,
+ rawData,
+ convertedData,
+ rawEvents,
+ allowedStages,
+ stageMedians,
+ pathNavIssueMetric,
+ rawStageMedians,
+} from './mock_data';
describe('Value stream analytics utils', () => {
describe('decorateEvents', () => {
@@ -36,17 +53,6 @@ describe('Value stream analytics utils', () => {
expect(result.summary).toEqual(convertedData.summary);
});
- it('returns the stages data', () => {
- expect(result.stages).toEqual(convertedData.stages);
- });
-
- it('returns each of the default value stream stages', () => {
- const stages = result.stages.map(({ name }) => name);
- ['issue', 'plan', 'code', 'test', 'review', 'staging'].forEach((stageName) => {
- expect(stages).toContain(stageName);
- });
- });
-
it('returns `-` for summary data that has no value', () => {
const singleSummaryResult = decorateData({
stats: [],
@@ -55,23 +61,92 @@ describe('Value stream analytics utils', () => {
});
expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]);
});
+ });
+
+ describe('transformStagesForPathNavigation', () => {
+ const stages = allowedStages;
+ const response = transformStagesForPathNavigation({
+ stages,
+ medians: stageMedians,
+ selectedStage,
+ });
+
+ describe('transforms the data as expected', () => {
+ it('returns an array of stages', () => {
+ expect(Array.isArray(response)).toBe(true);
+ expect(response.length).toBe(stages.length);
+ });
+
+ it('selects the correct stage', () => {
+ const selected = response.filter((stage) => stage.selected === true)[0];
+
+ expect(selected.title).toBe(selectedStage.title);
+ });
+
+ it('includes the correct metric for the associated stage', () => {
+ const issue = response.filter((stage) => stage.name === 'issue')[0];
- it('returns additional fields for each stage', () => {
- const singleStageResult = decorateData({
- stats: [{ name: 'issue', value: null }],
- permissions: { issue: false },
+ expect(issue.metric).toBe(pathNavIssueMetric);
});
- const stage = singleStageResult.stages[0];
- const txt =
- 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.';
-
- expect(stage).toMatchObject({
- active: false,
- isUserAllowed: false,
- emptyStageText: txt,
- slug: 'issue',
- component: 'stage-issue-component',
+ });
+ });
+
+ describe('timeSummaryForPathNavigation', () => {
+ it.each`
+ unit | value | result
+ ${'months'} | ${1.5} | ${'1.5M'}
+ ${'weeks'} | ${1.25} | ${'1.5w'}
+ ${'days'} | ${2} | ${'2d'}
+ ${'hours'} | ${10} | ${'10h'}
+ ${'minutes'} | ${20} | ${'20m'}
+ ${'seconds'} | ${10} | ${'<1m'}
+ ${'seconds'} | ${0} | ${'-'}
+ `('will format $value $unit to $result', ({ unit, value, result }) => {
+ expect(timeSummaryForPathNavigation({ [unit]: value })).toBe(result);
+ });
+ });
+
+ describe('medianTimeToParsedSeconds', () => {
+ it.each`
+ value | result
+ ${1036800} | ${'1w'}
+ ${259200} | ${'3d'}
+ ${172800} | ${'2d'}
+ ${86400} | ${'1d'}
+ ${1000} | ${'16m'}
+ ${61} | ${'1m'}
+ ${59} | ${'<1m'}
+ ${0} | ${'-'}
+ `('will correctly parse $value seconds into $result', ({ value, result }) => {
+ expect(medianTimeToParsedSeconds(value)).toBe(result);
+ });
+ });
+
+ describe('formatMedianValues', () => {
+ const calculatedMedians = formatMedianValues(rawStageMedians);
+
+ it('returns an object with each stage and their median formatted for display', () => {
+ rawStageMedians.forEach(({ id, value }) => {
+ expect(calculatedMedians).toMatchObject({ [id]: medianTimeToParsedSeconds(value) });
});
});
});
+
+ describe('filterStagesByHiddenStatus', () => {
+ const hiddenStages = [{ title: 'three', hidden: true }];
+ const visibleStages = [
+ { title: 'one', hidden: false },
+ { title: 'two', hidden: false },
+ ];
+ const mockStages = [...visibleStages, ...hiddenStages];
+
+ it.each`
+ isHidden | result
+ ${false} | ${visibleStages}
+ ${undefined} | ${hiddenStages}
+ ${true} | ${hiddenStages}
+ `('with isHidden=$isHidden returns matching stages', ({ isHidden, result }) => {
+ expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result);
+ });
+ });
});