diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-02 12:09:46 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-02 12:09:46 +0300 |
commit | b2180a27bcf74e622df4d7fb173306d80b973a6c (patch) | |
tree | b0966750894f10d6592a4c578d5687c169cd5e41 /spec/frontend/cycle_analytics | |
parent | b3e13e0dfd7e26ed569aa9b46f4ec55b41a62411 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend/cycle_analytics')
-rw-r--r-- | spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap | 7 | ||||
-rw-r--r-- | spec/frontend/cycle_analytics/base_spec.js | 173 | ||||
-rw-r--r-- | spec/frontend/cycle_analytics/mock_data.js | 80 | ||||
-rw-r--r-- | spec/frontend/cycle_analytics/path_navigation_spec.js | 136 | ||||
-rw-r--r-- | spec/frontend/cycle_analytics/store/getters_spec.js | 16 | ||||
-rw-r--r-- | spec/frontend/cycle_analytics/utils_spec.js | 108 |
6 files changed, 514 insertions, 6 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..2684381c078 --- /dev/null +++ b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap @@ -0,0 +1,7 @@ +// 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 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..5fe1d4b69c4 --- /dev/null +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -0,0 +1,173 @@ +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(), + ...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, + }, + }); + }); + + 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('without enough permissions', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { selectedStage: { ...selectedStage, isUserAllowed: 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..ab8bac1011e 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,5 +1,10 @@ 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 +13,7 @@ export const summary = [ ]; const issueStage = { + id: 'issue', title: 'Issue', name: 'issue', legend: '', @@ -16,30 +22,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 +58,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 = { @@ -184,3 +195,64 @@ 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, + }, +]; 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..182b76a1453 --- /dev/null +++ b/spec/frontend/cycle_analytics/path_navigation_spec.js @@ -0,0 +1,136 @@ +import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +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; + + 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(); + }); + + afterEach(() => { + 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]], + ]); + }); + }); +}); 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/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 73e26e1cdcc..2d9d2f5b5b1 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', () => { @@ -74,4 +91,91 @@ describe('Value stream analytics utils', () => { }); }); }); + + 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]; + + expect(issue.metric).toBe(pathNavIssueMetric); + }); + }); + }); + + 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); + }); + }); }); |