diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue index debdbeadc3..071892dc3f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue @@ -96,6 +96,9 @@ }, computed: { showRequiredLabel() { + if (this.audioVideoUpload) { + return false; + } return ( this.selectedDuration !== DurationDropdownMap.EXACT_TIME && this.selectedCompletion === CompletionDropdownMap.completeDuration @@ -121,7 +124,7 @@ return this.convertToMinutes(this.value); }, set(value) { - this.handleValidateMinutes(value); + this.handleUpdatedInput(value); }, }, maxRange() { @@ -152,20 +155,17 @@ }, }, created() { - this.handleValidateMinutes = debounce(this.validateMinutes, 500); + this.handleUpdatedInput = debounce(this.handleInput, 500); }, methods: { convertToMinutes(seconds) { return Math.floor(seconds / 60); }, - validateMinutes(value) { - if (this.selectedDuration === DurationDropdownMap.EXACT_TIME) { - this.$emit('input', value * 60); - } else { - if (value >= this.minRange && value <= this.maxRange) { - this.$emit('input', value * 60); - } - } + convertToSeconds(minutes) { + return minutes * 60; + }, + handleInput(value) { + this.$emit('input', this.convertToSeconds(value)); }, }, $trs: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue index 8a1571b454..5cc78523ac 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue @@ -2,7 +2,7 @@
- + - import ActivityDuration from './ActivityDuration.vue'; import MasteryCriteriaGoal from './MasteryCriteriaGoal'; + import ActivityDuration from './ActivityDuration.vue'; import MasteryCriteriaMofNFields from './MasteryCriteriaMofNFields'; import { CompletionCriteriaModels, @@ -146,7 +148,7 @@ }, practiceQuizzesAllowed: { type: Boolean, - default: false, + default: true, }, value: { type: Object, @@ -161,8 +163,8 @@ // required state because we need to know the completion to determine durationDropdown model currentCompletionDropdown: null, mastery_model: null, - m: 3, - n: 5, + m: null, + n: null, }; }, computed: { @@ -176,62 +178,83 @@ } return false; }, - showCompletionDropdown() { + hideCompletionDropdown() { /* This condition can be removed once practice quizzes are fully implemented in 0.16 - */ - return !(this.kind === ContentKindsNames.EXERCISE && !this.practiceQuizzesAllowed); + Named "hide" instead of "show" because "show" is the default behavior + */ + return this.practiceQuizzesAllowed; }, audioVideoResource() { return this.kind === ContentKindsNames.AUDIO || this.kind === ContentKindsNames.VIDEO; }, hideDurationDropdown() { // named "hide" instead of "show" because "show" is the default behavior - return ( - this.currentCompletionDropdown === CompletionDropdownMap.reference || - (this.value.model === CompletionCriteriaModels.REFERENCE && - !this.currentCompletionDropdown && - this.audioVideoResource) || - //should be hidden if model is reference and we're getting this from the BE - this.currentCompletionDropdown === CompletionDropdownMap.determinedByResource - ); + if (this.value) { + if (this.kind === ContentKindsNames.H5P) { + return true; + } + return ( + this.currentCompletionDropdown === CompletionDropdownMap.reference || + (this.value.model === CompletionCriteriaModels.REFERENCE && + !this.currentCompletionDropdown && + this.audioVideoResource) || + //should be hidden if model is reference and we're getting this from the BE + this.currentCompletionDropdown === CompletionDropdownMap.determinedByResource + ); + } + return false; }, showReferenceHint() { /* The reference hint should be shown only when "Reference" is selected */ - if (this.kind === ContentKindsNames.H5P || this.kind === ContentKindsNames.HTML5) { - if (this.currentCompletionDropdown === CompletionDropdownMap.determinedByResource) { - return false; + if (this.value) { + if (this.kind === ContentKindsNames.H5P || this.kind === ContentKindsNames.HTML5) { + if (this.currentCompletionDropdown === CompletionDropdownMap.determinedByResource) { + return false; + } + if ( + this.value.model === CompletionCriteriaModels.REFERENCE && + this.currentCompletionDropdown === CompletionDropdownMap.completeDuration + ) { + return true; + } } - if ( - this.value.model === CompletionCriteriaModels.REFERENCE && - this.currentCompletionDropdown === CompletionDropdownMap.completeDuration - ) { - return true; + if (this.audioVideoResource) { + return this.value.model === CompletionCriteriaModels.REFERENCE; } + return ( + this.value.model === CompletionCriteriaModels.REFERENCE && + this.currentCompletionDropdown !== CompletionDropdownMap.completeDuration + ); } - return ( - (this.durationDropdown === DurationDropdownMap.REFERENCE && - this.currentCompletionDropdown === CompletionDropdownMap.allContent) || - this.currentCompletionDropdown === CompletionDropdownMap.reference || - (this.value.model === CompletionCriteriaModels.REFERENCE && - this.currentCompletionDropdown !== CompletionDropdownMap.completeDuration) - ); + return false; }, showActivityDurationInput() { /* The `ActivityDuration` component should visible when: - Long activity, short activity, or exact time are chosen if it is not an AV resource - Long activity or short activity are chosen if it is an AV resource + - Long activity, short activity, or exact time are chosen in HTML5 */ - const switchingFromReferenceBetweenAllContentViewedAndCompleteDuration = - this.value.suggested_duration === null || this.value.suggested_duration_type === null; + if (this.value) { + const switchingFromReferenceBetweenAllContentViewedAndCompleteDuration = + this.value.suggested_duration === null || this.value.suggested_duration_type === null; - if (!this.audioVideoResource) { - return !( - this.value.model === CompletionCriteriaModels.REFERENCE || - switchingFromReferenceBetweenAllContentViewedAndCompleteDuration - ); + if (!this.audioVideoResource) { + if (this.kind === ContentKindsNames.HTML5 || this.kind === ContentKindsNames.H5P) { + if (this.value.model !== CompletionCriteriaModels.REFERENCE) { + if (!this.currentCompletionDropdown) { + return true; + } + return this.currentCompletionDropdown === CompletionDropdownMap.completeDuration; + } + } + return !( + this.value.model === CompletionCriteriaModels.REFERENCE || + switchingFromReferenceBetweenAllContentViewedAndCompleteDuration + ); + } } return this.audioVideoResource && this.value.model !== CompletionCriteriaModels.REFERENCE; }, @@ -259,10 +282,15 @@ } if (this.kind === ContentKindsNames.HTML5) { - if (!this.value['model']) { + if ( + !this.value['model'] || + this.value.model === CompletionCriteriaModels.APPROX_TIME || + this.value.model === CompletionCriteriaModels.TIME || + this.value.model === CompletionCriteriaModels.REFERENCE + ) { return CompletionDropdownMap.completeDuration; } - return this.value.model; + return CompletionDropdownMap.determinedByResource; } if (this.kind === ContentKindsNames.H5P) { @@ -330,6 +358,16 @@ } } + // FOR H5P/HTML5 + if (this.kind === ContentKindsNames.HTML5 || this.kind === ContentKindsNames.H5P) { + if (value === CompletionDropdownMap.determinedByResource) { + update.completion_criteria = { + model: this.value.model, + threshold: this.value.threshold, + }; + } + } + // FOR EXERCISES if (this.kind === ContentKindsNames.EXERCISE) { if (value === CompletionDropdownMap.practiceQuiz) { @@ -477,7 +515,8 @@ completionDropdownIsAllContentViewed() { return ( (this.completionDropdown === null && - this.currentCompletionDropdown === CompletionDropdownMap.nt && + this.currentCompletionDropdown === CompletionDropdownMap.allContent) || + (this.completionDropdown === CompletionDropdownMap.allContent && this.currentCompletionDropdown === null) || (this.completionDropdown === CompletionDropdownMap.allContent && this.currentCompletionDropdown === CompletionDropdownMap.allContent) @@ -485,40 +524,42 @@ }, durationDropdown: { get() { - const defaultStateForAudioVideo = - this.value.suggested_duration === null && - this.value.suggested_duration_type === null && - this.audioVideoResource; - if ( - this.value.model === CompletionCriteriaModels.REFERENCE || - (this.currentCompletionDropdown === CompletionDropdownMap.completeDuration && - this.currentDurationDropdown === DurationDropdownMap.REFERENCE) - ) { - return DurationDropdownMap.REFERENCE; - } else if (this.value.model === CompletionCriteriaModels.PAGES) { - if (this.isLongActivity) { - return DurationDropdownMap.LONG_ACTIVITY; - } - if (this.isShortActivity) { - return DurationDropdownMap.SHORT_ACTIVITY; - } - if (this.isExactTime) { - return DurationDropdownMap.EXACT_TIME; - } - } else if ( - this.value.model === CompletionCriteriaModels.TIME || - defaultStateForAudioVideo - ) { - return DurationDropdownMap.EXACT_TIME; - } else { - if (this.isLongActivity) { - return DurationDropdownMap.LONG_ACTIVITY; - } - if (this.isShortActivity) { - return DurationDropdownMap.SHORT_ACTIVITY; - } - if (this.isExactTime) { + if (this.value) { + const defaultStateForAudioVideo = + this.value.suggested_duration === null && + !this.value.suggested_duration_type && + this.audioVideoResource; + if ( + this.value.model === CompletionCriteriaModels.REFERENCE || + (this.currentCompletionDropdown === CompletionDropdownMap.completeDuration && + this.currentDurationDropdown === DurationDropdownMap.REFERENCE) + ) { + return DurationDropdownMap.REFERENCE; + } else if (this.value.model === CompletionCriteriaModels.PAGES) { + if (this.isLongActivity) { + return DurationDropdownMap.LONG_ACTIVITY; + } + if (this.isShortActivity) { + return DurationDropdownMap.SHORT_ACTIVITY; + } + if (this.isExactTime) { + return DurationDropdownMap.EXACT_TIME; + } + } else if ( + this.value.model === CompletionCriteriaModels.TIME || + defaultStateForAudioVideo + ) { return DurationDropdownMap.EXACT_TIME; + } else { + if (this.isLongActivity) { + return DurationDropdownMap.LONG_ACTIVITY; + } + if (this.isShortActivity) { + return DurationDropdownMap.SHORT_ACTIVITY; + } + if (this.isExactTime) { + return DurationDropdownMap.EXACT_TIME; + } } } return ''; @@ -543,21 +584,17 @@ } if (duration === DurationDropdownMap.SHORT_ACTIVITY) { update.suggested_duration_type = SuggestedDurationTypesMap.APPROX_TIME; - const roundedValue = Math.round(this.value.suggested_duration / 300) * 300; - if (roundedValue > SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue <= 0) { - update.suggested_duration = DEFAULT_SHORT_ACTIVITY; - } else { - update.suggested_duration = roundedValue; - } + update.suggested_duration = this.handleMinutesInputFromActivityDuration( + this.value.suggested_duration, + duration + ); } if (duration === DurationDropdownMap.LONG_ACTIVITY) { update.suggested_duration_type = SuggestedDurationTypesMap.APPROX_TIME; - const roundedValue = Math.round(this.value.suggested_duration / 600) * 600; - if (roundedValue < SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue > 7200) { - update.suggested_duration = DEFAULT_LONG_ACTIVITY; - } else { - update.suggested_duration = roundedValue; - } + update.suggested_duration = this.handleMinutesInputFromActivityDuration( + this.value.suggested_duration, + duration + ); } update.completion_criteria = { model: CompletionCriteriaModels.PAGES, @@ -566,16 +603,16 @@ } else if ( this.isSwitchingFromAllContentToCompleteDuration || this.requiresAudioVideoDefault || - this.completionDropdownIsCompleteDuration + this.completionDropdownIsCompleteDuration || + this.kind === ContentKindsNames.HTML5 || + this.kind === ContentKindsNames.H5P ) { if (duration === DurationDropdownMap.SHORT_ACTIVITY) { update.suggested_duration_type = SuggestedDurationTypesMap.APPROX_TIME; - const roundedValue = Math.round(this.value.suggested_duration / 300) * 300; - if (roundedValue > SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue <= 0) { - update.suggested_duration = DEFAULT_SHORT_ACTIVITY; - } else { - update.suggested_duration = roundedValue; - } + update.suggested_duration = this.handleMinutesInputFromActivityDuration( + this.value.suggested_duration, + duration + ); update.completion_criteria = { model: CompletionCriteriaModels.APPROX_TIME, threshold: update.suggested_duration, @@ -583,12 +620,10 @@ } if (duration === DurationDropdownMap.LONG_ACTIVITY) { update.suggested_duration_type = SuggestedDurationTypesMap.APPROX_TIME; - const roundedValue = Math.round(this.value.suggested_duration / 600) * 600; - if (roundedValue < SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue > 7200) { - update.suggested_duration = DEFAULT_LONG_ACTIVITY; - } else { - update.suggested_duration = roundedValue; - } + update.suggested_duration = this.handleMinutesInputFromActivityDuration( + this.value.suggested_duration, + duration + ); update.completion_criteria = { model: CompletionCriteriaModels.APPROX_TIME, threshold: update.suggested_duration, @@ -606,13 +641,10 @@ if (this.value.model === CompletionCriteriaModels.MASTERY) { if (duration === DurationDropdownMap.SHORT_ACTIVITY) { - update.suggested_duration_type = SuggestedDurationTypesMap.APPROX_TIME; - const roundedValue = Math.round(this.value.suggested_duration / 300) * 300; - if (roundedValue > SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue <= 0) { - update.suggested_duration = DEFAULT_SHORT_ACTIVITY; - } else { - update.suggested_duration = roundedValue; - } + update.suggested_duration = this.handleMinutesInputFromActivityDuration( + this.value.suggested_duration, + duration + ); update.completion_criteria = { model: this.value.model, threshold: this.value.threshold, @@ -640,6 +672,7 @@ }; } } + this.handleInput(update); }, }, @@ -682,11 +715,13 @@ audio: [CompletionDropdownMap.completeDuration, CompletionDropdownMap.reference], video: [CompletionDropdownMap.completeDuration, CompletionDropdownMap.reference], }; - - return CompletionOptionsDropdownMap[this.kind].map(model => ({ - text: this.$tr(model), - value: CompletionDropdownMap[model], - })); + if (this.kind) { + return CompletionOptionsDropdownMap[this.kind].map(model => ({ + text: this.$tr(model), + value: CompletionDropdownMap[model], + })); + } + return []; }, allPossibleDurationOptions() { //this is used because of this Vuetify issue for dropdowns with multiple values: https://github.com/vuetifyjs/vuetify/issues/11529 @@ -743,17 +778,22 @@ } }, completionRules() { - return this.required ? getCompletionValidators().map(translateValidator) : []; + if (this.kind) { + return this.required ? getCompletionValidators().map(translateValidator) : []; + } + return false; }, durationRules() { const defaultStateForDocument = this.currentCompletionDropdown === null; - const allContentViewedIsChosenInCompletionDropdown = - this.currentCompletionDropdown === CompletionDropdownMap.allContent || - (this.value.model === CompletionCriteriaModels.PAGES && - this.currentCompletionDropdown === CompletionDropdownMap.allContent); + if (this.value) { + const allContentViewedIsChosenInCompletionDropdown = + this.currentCompletionDropdown === CompletionDropdownMap.allContent || + (this.value.model === CompletionCriteriaModels.PAGES && + this.currentCompletionDropdown === CompletionDropdownMap.allContent); - if (defaultStateForDocument || allContentViewedIsChosenInCompletionDropdown) { - return []; + if (defaultStateForDocument || allContentViewedIsChosenInCompletionDropdown) { + return []; + } } return getDurationValidators().map(translateValidator); }, @@ -762,6 +802,27 @@ trackClick(label) { this.$analytics.trackClick('channel_editor_modal_details', label); }, + handleMinutesInputFromActivityDuration(minutes, duration) { + let suggested_duration; + let roundedValue; + if (duration === DurationDropdownMap.SHORT_ACTIVITY) { + roundedValue = Math.round(minutes / 300) * 300; + if (roundedValue > SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue <= 0) { + suggested_duration = DEFAULT_SHORT_ACTIVITY; + } else { + suggested_duration = roundedValue; + } + } + if (duration === DurationDropdownMap.LONG_ACTIVITY) { + roundedValue = Math.round(minutes / 600) * 600; + if (roundedValue < SHORT_LONG_ACTIVITY_MIDPOINT || roundedValue > 7200) { + suggested_duration = DEFAULT_LONG_ACTIVITY; + } else { + suggested_duration = roundedValue; + } + } + return suggested_duration; + }, handleInput({ completion_criteria, suggested_duration, @@ -787,6 +848,7 @@ if (suggested_duration_type === undefined) { data['suggested_duration_type'] = this.value['suggested_duration_type']; } + this.$emit('input', data); }, isUnique(value) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue index f56ba17306..60832864e6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaGoal.vue @@ -13,6 +13,7 @@ :placeholder="placeholder" :required="required" :readonly="readonly" + :disabled="disabled" :rules="masteryRules" menu-props="offsetY" class="mb-2" @@ -53,6 +54,10 @@ type: Boolean, default: true, }, + disabled: { + type: Boolean, + default: false, + }, readonly: { type: Boolean, default: false, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue index 9ba5a5cf2e..dcb8ddaf94 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/MasteryCriteriaMofNFields.vue @@ -13,6 +13,7 @@ :required="mRequired" :placeholder="mPlaceholder" :readonly="readonly" + :disabled="disabled" :rules="mRules" @keypress="isIntegerInput($event)" @paste="isIntegerPaste($event)" @@ -33,6 +34,7 @@ :required="nRequired" :readonly="readonly" :placeholder="nPlaceholder" + :disabled="disabled" :rules="nRules" @keypress="isIntegerInput($event)" @paste="isIntegerPaste($event)" @@ -78,6 +80,10 @@ type: Boolean, default: false, }, + disabled: { + type: Boolean, + default: false, + }, mRequired: { type: Boolean, default: true, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/activityDuration.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/activityDuration.spec.js new file mode 100644 index 0000000000..31dcb2414b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/activityDuration.spec.js @@ -0,0 +1,185 @@ +import Vue from 'vue'; +import Vuetify from 'vuetify'; +import { shallowMount, mount } from '@vue/test-utils'; +import ActivityDuration from '../ActivityDuration.vue'; + +Vue.use(Vuetify); + +describe('ActivityDuration', () => { + it('smoke test', () => { + const wrapper = shallowMount(ActivityDuration); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe(`minutes input`, () => { + const shortActivityMin = 1; + const shortActivityMax = 30; + const longActivityMin = 31; + const longActivityMax = 120; + describe(`default state for audio/video resources`, () => { + it(`should display a static upload time when 'Exact time to complete' for audio/video resources as initial state`, () => { + const defaultValue = '17:12'; + const wrapper = shallowMount(ActivityDuration); + expect(wrapper.vm.defaultUploadTime).toEqual(defaultValue); + }); + it(`should display the file's time at upload when 'Exact time to complete' is chosen in Completion dropdown`, () => { + // TODO: defaultValue will need to be changed when file-upload-duration is implemented + const defaultValue = '17:12'; + const wrapper = shallowMount(ActivityDuration, { + propsData: { duration: 123 }, + }); + expect(wrapper.props('duration')).toEqual(123); + expect(wrapper.vm.defaultUploadTime).not.toEqual(defaultValue); + expect(wrapper.vm.defaultUploadTime).toEqual(123); + }); + it(`should display a "stand-in" at upload if file's time at upload is not available when 'Exact time to complete' is chosen in Completion dropdown`, () => { + // TODO: defaultValue will need to be changed when file-upload-duration is implemented + const defaultValue = '17:12'; + const wrapper = shallowMount(ActivityDuration, { + propsData: { duration: null }, + }); + expect(wrapper.props('duration')).toEqual(null); + expect(wrapper.vm.defaultUploadTime).toEqual(defaultValue); + expect(wrapper.vm.defaultUploadTime).not.toEqual(null); + }); + }); + + describe(`convert seconds to minutes for display`, () => { + it(`should display the seconds passed down from parent as minutes`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { value: 600 }, + }); + expect(wrapper.vm.minutes).toBe(10); + wrapper.setProps({ value: 4821 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.minutes).toBe(80); + }); + }); + }); + + describe(`convert minutes to seconds to emit to parent`, () => { + it(`should emit time to parent`, () => { + const seconds = 2400; + const wrapper = mount(ActivityDuration); + wrapper.vm.minutes = 40; + // Ensure debounced function gets called + wrapper.vm.handleUpdatedInput.flush(); + + return Vue.nextTick().then(() => { + const emittedTime = wrapper.emitted('input').pop()[0]; + expect(emittedTime).toEqual(seconds); + }); + }); + }); + + describe(`in 'Short activity'`, () => { + const wrapper = mount(ActivityDuration, { + propsData: { + selectedDuration: 'shortActivity', + }, + }); + it(`should increment by 5-minute intervals`, () => { + expect(wrapper.html()).toContain(`step="5"`); + expect(wrapper.html()).not.toContain(`step="10"`); + }); + it(`minimum accepted input should be 1 minute`, () => { + expect(wrapper.vm.minRange).toBe(shortActivityMin); + expect(wrapper.vm.minRange).not.toBe(longActivityMin); + }); + it(`maximum accepted input should be 30 minutes`, () => { + expect(wrapper.vm.maxRange).toBe(shortActivityMax); + expect(wrapper.vm.maxRange).not.toBe(longActivityMax); + }); + }); + + describe(`in 'Long activity'`, () => { + const wrapper = mount(ActivityDuration, { + propsData: { + selectedDuration: 'longActivity', + }, + }); + it(`should increment by 10-minute intervals`, () => { + expect(wrapper.html()).toContain(`step="10"`); + expect(wrapper.html()).not.toContain(`step="5"`); + }); + it(`minimum accepted input should be 31 minutes`, () => { + expect(wrapper.vm.minRange).not.toBe(shortActivityMin); + expect(wrapper.vm.minRange).toBe(longActivityMin); + }); + it(`maximum accepted input should be 120 minutes`, () => { + expect(wrapper.vm.maxRange).not.toBe(shortActivityMax); + expect(wrapper.vm.maxRange).toBe(longActivityMax); + }); + }); + + describe(`hints`, () => { + const optionalHint = + '(Optional) Duration until resource is marked as complete. This value will not be shown to learners.'; + const requiredHint = + 'Duration until resource is marked as complete. This value will not be shown to learners.'; + describe(`audio/video resource`, () => { + it(`should display optional hint when 'Short activity' is chosen`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { + audioVideoUpload: true, + selectedDuration: 'shortActivity', + }, + }); + expect(wrapper.html()).toContain(optionalHint); + }); + it(`should display optional hint when 'Long activity' is chosen`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { + audioVideoUpload: true, + selectedDuration: 'longActivity', + }, + }); + expect(wrapper.html()).toContain(optionalHint); + }); + it(`should not display any hints when 'Exact time to complete' is chosen`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { + audioVideoUpload: true, + selectedDuration: 'exactTime', + }, + }); + expect(wrapper.html()).not.toContain(requiredHint); + expect(wrapper.html()).not.toContain(optionalHint); + }); + }); + describe(`all other resources`, () => { + it(`should display optional hint when 'All content viewed' is selected in the Completion dropdown and 'Short/Long activity' is chosen`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { + audioVideoUpload: false, + selectedCompletion: 'allContent', + selectedDuration: 'shortActivity', + }, + }); + expect(wrapper.html()).toContain(optionalHint); + }); + it(`should display required hint when 'Complete duration' is selected in the Completion dropdown and 'Short/Long activity' is chosen`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { + audioVideoUpload: false, + selectedCompletion: 'completeDuration', + selectedDuration: 'shortActivity', + }, + }); + expect(wrapper.html()).not.toContain('(Optional)'); + expect(wrapper.html()).toContain(requiredHint); + }); + it(`should not display any hints when 'Exact time to complete' is chosen`, () => { + const wrapper = shallowMount(ActivityDuration, { + propsData: { + audioVideoUpload: false, + selectedDuration: 'exactTime', + }, + }); + expect(wrapper.html()).not.toContain(requiredHint); + expect(wrapper.html()).not.toContain(optionalHint); + }); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/completionOptions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/completionOptions.spec.js new file mode 100644 index 0000000000..6166085567 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/completionOptions.spec.js @@ -0,0 +1,786 @@ +import Vue from 'vue'; +import Vuetify from 'vuetify'; +import { shallowMount, mount } from '@vue/test-utils'; +import CompletionOptions from '../CompletionOptions.vue'; + +Vue.use(Vuetify); + +describe('CompletionOptions', () => { + it('smoke test', () => { + const wrapper = shallowMount(CompletionOptions); + expect(wrapper.isVueInstance()).toBe(true); + }); + describe(`completion dropdown`, () => { + it(`renders the completion dropdown`, () => { + const wrapper = mount(CompletionOptions); + const dropdown = wrapper.find({ ref: 'completion' }); + expect(dropdown.exists()).toBe(true); + }); + describe(`initial, default states`, () => { + describe(`audio/video`, () => { + it(`'Complete duration' should be displayed by default`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { model: null }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Complete duration' should be displayed if 'Exact time'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { model: 'time', threshold: 600, suggested_duration_type: 'time' }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Complete duration' should be displayed if 'Short activity'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { + model: 'approx_time', + threshold: 600, + suggested_duration_type: 'approx_time', + }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Complete duration' should be displayed if 'Long activity'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { + model: 'approx_time', + threshold: 6000, + suggested_duration_type: 'approx_time', + }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Reference' should be displayed if the model in the backend is 'reference'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { model: 'reference' }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('reference'); + }); + }); + describe(`document`, () => { + it(`'All content viewed' should be displayed by default`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: null }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('allContent'); + }); + it(`'All content viewed' should be displayed if the model in the backend is 'reference'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'reference' }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('allContent'); + }); + it(`'All content viewed' should be displayed if the model in the backend is 'pages'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'pages', threshold: '100%' }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('allContent'); + }); + it(`'Complete duration' should be displayed if 'exact time'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'time', + threshold: 1234, + suggested_duration: 1234, + suggested_duration_type: 'time', + }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Complete duration' should be displayed if 'short activity'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'approx_time', + threshold: 1234, + suggested_duration: 1234, + suggested_duration_type: 'approx_time', + }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Complete duration' should be displayed if 'long activity'`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'time', + threshold: 1234567, + suggested_duration: 1234567, + suggested_duration_type: 'time', + }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + }); + describe(`exercise`, () => { + it(`'Practice until goal is met' should be displayed by default if 'practice quiz' is enabled `, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: null, threshold: { m: null, n: null } }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('goal'); + }); + it(`Completion dropdown should not be displayed if 'practice quiz' is not enabled while 'Practice until goal is met' is set in background`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: null, threshold: { m: null, n: null }, modality: 'QUIZ' }, + practiceQuizzesAllowed: false, + }, + }); + expect(wrapper.find({ ref: 'completion' }).exists()).toBe(false); + expect(wrapper.vm.completionDropdown).toBe('goal'); + }); + }); + describe(`html5 or h5p`, () => { + it(`'Complete duration' should be displayed by default for html5`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'html5', + value: { model: null }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('completeDuration'); + }); + it(`'Determined by this resource' should be displayed if there is no model in the backend for h5p`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'h5p', + value: { model: null }, + }, + }); + expect(wrapper.vm.completionDropdown).toBe('determinedByResource'); + }); + }); + }); + describe(`changing states`, () => { + describe('emitted events', () => { + it('input should be emitted when completion dropdown is updated', async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: null }, + }, + }); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'allContent'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('input')).toBeTruthy(); + }); + }); + describe(`audio/video`, () => { + it(`'Duration dropdown' is not visible and reference hint is visible when 'Reference' is selected`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { model: 'reference' }, + }, + }); + expect(wrapper.vm.showReferenceHint).toBe(true); + expect(wrapper.find({ ref: 'duration' }).exists()).toBe(false); + }); + }); + describe(`exercise`, () => { + it(`Goal and MofN components should not be displayed when switching to PQ from PUGIM`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 }, modality: 'QUIZ' }, + }, + }); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'practiceQuiz'); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.showMasteryCriteriaGoalDropdown).toBe(false); + expect(wrapper.vm.showMofN).toBe(false); + }); + }); + }); + }); + describe(`duration dropdown`, () => { + it(`renders the duration dropdown`, () => { + const wrapper = mount(CompletionOptions); + const dropdown = wrapper.find({ ref: 'duration' }); + expect(dropdown.exists()).toBe(true); + }); + describe(`default states`, () => { + it(`'Exact time to complete' should be displayed by default for audio or video`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { suggested_duration: null }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('exactTime'); + }); + it(`duration dropdown is empty by default for documents`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { suggested_duration: null }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe(''); + }); + it(`duration dropdown is empty by default for exercises`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 }, suggested_duration: null }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe(''); + }); + it(`'Reference' is disabled for exercises`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 } }, + }, + }); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'completeDuration'); + await wrapper.vm.$nextTick(); + + const clickableDurationDropdown = wrapper.vm.selectableDurationOptions; + expect(clickableDurationDropdown.length).toBe(3); + }); + it(`duration dropdown is empty by default for html5`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'html5', + value: { suggested_duration: null }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe(''); + }); + it(`duration dropdown is hidden by default for h5p`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'h5p', + value: { suggested_duration: null }, + }, + }); + const dropdown = wrapper.find({ ref: 'duration' }); + expect(dropdown.exists()).toBe(false); + }); + }); + describe('emitted events', () => { + it('input should be emitted when duration dropdown is updated', async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: null }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('input')).toBeTruthy(); + }); + }); + describe(`audio/video`, () => { + it(`minutes input is displayed when 'Short activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { model: null }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Long activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'audio', + value: { model: null }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'longActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + }); + describe(`document`, () => { + describe(`'All content viewed' is selected as completion`, () => { + it(`minutes input is displayed when 'Short activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'pages' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Long activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'pages' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'longActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Exact time' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'pages' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is hidden and reference hint is displayed when 'Reference' is selected`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'reference' }, + }, + }); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(false); + expect(wrapper.vm.showActivityDurationInput).toBe(false); + expect(wrapper.vm.showReferenceHint).toBe(true); + }); + }); + describe(`'Complete duration' is selected as completion`, () => { + it(`minutes input is displayed when 'Short activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'approx_time' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Long activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'approx_time' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'longActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Exact time' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'time' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`'Reference' is disabled`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { model: 'time' }, + }, + }); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'completeDuration'); + await wrapper.vm.$nextTick(); + + const clickableDurationDropdown = wrapper.vm.selectableDurationOptions; + const reference = clickableDurationDropdown.filter( + option => option.value === 'reference' + ); + expect(clickableDurationDropdown.length).toBe(4); + expect(reference[0].disabled).toBe(true); + }); + }); + describe(`switching between 'All content viewed (ACV)' and 'Complete duration (CD)'`, () => { + it(`Duration dropdown and minutes input stay the same when switching betweeen 'Short activity' in ACV and CD`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'approx_time', + suggested_duration: 1200, + suggested_duration_type: 'approx_time', + }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('shortActivity'); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'allContent'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.durationDropdown).toBe('shortActivity'); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + expect(wrapper.vm.minutes).toBe(1200); + }); + it(`Duration dropdown and minutes input stay the same when switching betweeen 'Long activity' in ACV and CD`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'approx_time', + suggested_duration: 6000, + suggested_duration_type: 'approx_time', + }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('longActivity'); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'allContent'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.durationDropdown).toBe('longActivity'); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + expect(wrapper.vm.minutes).toBe(6000); + }); + it(`Duration dropdown and minutes input stay the same when switching betweeen 'Exact time' in ACV and CD`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'pages', + threshold: '100%', + suggested_duration: 1234, + suggested_duration_type: 'time', + }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('exactTime'); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'completeDuration'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.durationDropdown).toBe('exactTime'); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + expect(wrapper.vm.minutes).toBe(1234); + }); + }); + }); + describe(`exercise`, () => { + describe(`when completion dropdown is 'Practice until goal is met'`, () => { + it(`minutes input is displayed when 'Short activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 } }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Long activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 } }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'longActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Exact time' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 } }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + }); + describe(`when completion dropdown is 'Practice quiz'`, () => { + it(`minutes input is displayed when 'Short activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 }, modality: 'QUIZ' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Long activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 }, modality: 'QUIZ' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'longActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Exact time' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { model: 'mastery', threshold: { m: 3, n: 5 }, modality: 'QUIZ' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + }); + describe(`switching between 'Practice until goal is met (PUGIM)' and 'Practice quiz (PQ)'`, () => { + it(`Duration dropdown and minutes input stay the same when switching betweeen 'Short activity' in PUGIM and PQ`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { + model: 'mastery', + modality: 'QUIZ', + suggested_duration: 1200, + suggested_duration_type: 'approx_time', + threshold: { m: 3, n: 5 }, + }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('shortActivity'); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'goal'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.durationDropdown).toBe('shortActivity'); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + expect(wrapper.vm.minutes).toBe(1200); + }); + it(`Duration dropdown and minutes input stay the same when switching betweeen 'Long activity' in PUGIM and PQ`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { + model: 'mastery', + modality: 'QUIZ', + suggested_duration: 6000, + suggested_duration_type: 'approx_time', + threshold: { m: 3, n: 5 }, + }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('longActivity'); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'goal'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.durationDropdown).toBe('longActivity'); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + expect(wrapper.vm.minutes).toBe(6000); + }); + it(`Duration dropdown and minutes input stay the same when switching betweeen 'Exact time' in PUGIM and PQ`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'exercise', + value: { + model: 'mastery', + modality: 'QUIZ', + suggested_duration: 1234, + suggested_duration_type: 'time', + threshold: { m: 3, n: 5 }, + }, + }, + }); + expect(wrapper.vm.durationDropdown).toBe('exactTime'); + wrapper.find({ ref: 'completion' }).vm.$emit('input', 'completeDuration'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.durationDropdown).toBe('exactTime'); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + expect(wrapper.vm.minutes).toBe(1234); + }); + }); + }); + describe(`html5 or h5p`, () => { + describe(`when completion dropdown is 'Determined by this resource'`, () => { + it(`minutes input is displayed when 'Short activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'html5', + value: { suggested_duration: null, model: 'approx_time' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'shortActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Long activity' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'html5', + value: { suggested_duration: null, model: 'approx_time' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'longActivity'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is displayed when 'Exact time' is selected`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'html5', + value: { suggested_duration: null, model: 'time' }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(true); + expect(wrapper.vm.showActivityDurationInput).toBe(true); + }); + it(`minutes input is hidden and reference hint is displayed when 'Reference' is selected`, () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'html5', + value: { suggested_duration: null, model: 'reference' }, + }, + }); + expect(wrapper.find({ ref: 'activity_duration' }).exists()).toBe(false); + expect(wrapper.vm.showActivityDurationInput).toBe(false); + expect(wrapper.vm.showReferenceHint).toBe(true); + }); + }); + }); + }); + describe(`minutes input`, () => { + //Note: while the 'ActivityDuration' component itself is in another component, + //the logic to get the data ready for the BE is in this component + describe(`correct handling of values for switching from 'Exact time' to 'Short activity' or 'Long activity'`, () => { + it(`displays default 'Short activity' value when input > the max allowed for 'Short activity'`, () => { + const shortActivityDefaultValue = 600; + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'pages', + threshold: '100%', + suggested_duration: 3060, + suggested_duration_type: 'time', + }, + }, + }); + + expect(wrapper.vm.handleMinutesInputFromActivityDuration(3060, `shortActivity`)).toBe( + shortActivityDefaultValue + ); + }); + it(`displays default 'Long activity' value when input < the min allowed for 'Long activity'`, () => { + const longActivityDefaultValue = 3000; + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'pages', + threshold: '100%', + suggested_duration: 50, + suggested_duration_type: 'time', + }, + }, + }); + + expect(wrapper.vm.handleMinutesInputFromActivityDuration(50, `longActivity`)).toBe( + longActivityDefaultValue + ); + }); + }); + describe(`correct handling of values for switching from 'Short activity' or 'Long activity' to 'Exact Time'`, () => { + it(`displays 'Long activity' value`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'pages', + threshold: '100%', + suggested_duration: 4200, + suggested_duration_type: 'approx_time', + }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.currentDurationDropdown).toBe('exactTime'); + expect(wrapper.vm.minutes).toBe(4200); + }); + it(`displays 'Short activity' value`, async () => { + const wrapper = mount(CompletionOptions, { + propsData: { + kind: 'document', + value: { + model: 'pages', + threshold: '100%', + suggested_duration: 200, + suggested_duration_type: 'approx_time', + }, + }, + }); + wrapper.find({ ref: 'duration' }).vm.$emit('input', 'exactTime'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.currentDurationDropdown).toBe('exactTime'); + expect(wrapper.vm.minutes).toBe(200); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/masteryCriteriaGoal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/masteryCriteriaGoal.spec.js new file mode 100644 index 0000000000..49cc0159b5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/masteryCriteriaGoal.spec.js @@ -0,0 +1,97 @@ +import { mount } from '@vue/test-utils'; +import TestForm from '../../../../shared/views/__tests__/TestForm.vue'; +import MasteryCriteriaGoal from '../MasteryCriteriaGoal.vue'; +import { constantStrings } from 'shared/mixins'; +import MasteryModels from 'shared/leUtils/MasteryModels'; + +document.body.setAttribute('data-app', true); // Vuetify prints a warning without this + +describe('MasteryCriteriaGoal', () => {}); +function makeWrapper() { + return mount(TestForm, { + slots: { + testComponent: MasteryCriteriaGoal, + }, + }); +} + +describe('masteryCriteriaGoal', () => { + let formWrapper; + let wrapper; + let modelInput; + + beforeEach(() => { + formWrapper = makeWrapper(); + wrapper = formWrapper.find(MasteryCriteriaGoal); + wrapper.setProps({ value: { mastery_model: 'm_of_n' } }); + modelInput = wrapper.find({ ref: 'masteryModel' }).find('input'); + }); + + describe('on load', () => { + MasteryModels.forEach(model => { + it(`${model} mastery option should be an option to select`, () => { + expect(wrapper.find('.v-list').text()).toContain(constantStrings.$tr(model)); + }); + }); + it('should render according to masteryModel prop', () => { + function test(model) { + wrapper.setProps({ value: { mastery_model: model } }); + expect(wrapper.vm.$refs.masteryModel.value).toEqual(model); + } + MasteryModels.forEach(test); + }); + }); + describe('props', () => { + beforeEach(() => {}); + it('setting readonly should prevent any edits', () => { + wrapper.setProps({ readonly: true }); + wrapper.vm.$nextTick(() => { + expect(modelInput.attributes('readonly')).toEqual('readonly'); + }); + }); + it('setting required to false should make fields not required (required by default)', () => { + expect(modelInput.attributes('required')).toEqual('required'); + + wrapper.setProps({ required: false }); + wrapper.vm.$nextTick(() => { + expect(modelInput.attributes('required')).toBeFalsy(); + }); + }); + it('setting disabled should make fields disabled', () => { + expect(modelInput.attributes('disabled')).toBeFalsy(); + + wrapper.setProps({ disabled: true }); + wrapper.vm.$nextTick(() => { + expect(modelInput.attributes('disabled')).toEqual('disabled'); + }); + }); + }); + describe('emitted events', () => { + it('input should be emitted when masteryModel is updated', () => { + expect(wrapper.emitted('input')).toBeFalsy(); + modelInput.setValue('do_all'); + expect(wrapper.emitted('input')).toBeTruthy(); + expect(wrapper.emitted('input')[0][0].mastery_model).toEqual('do_all'); + }); + }); + describe('validation', () => { + it('should flag empty required mastery models', () => { + wrapper.setProps({ value: { mastery_model: null } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'masteryModel' }) + .find('.error--text') + .exists() + ).toBe(true); + wrapper.setProps({ required: false }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'masteryModel' }) + .find('.error--text') + .exists() + ).toBe(false); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/masteryCriteriaMofNFields.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/masteryCriteriaMofNFields.spec.js new file mode 100644 index 0000000000..573009eaf8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/masteryCriteriaMofNFields.spec.js @@ -0,0 +1,176 @@ +import { mount } from '@vue/test-utils'; +import TestForm from '../../../../shared/views/__tests__/TestForm.vue'; +import MasteryCriteriaMofNFields from '../MasteryCriteriaMofNFields.vue'; + +document.body.setAttribute('data-app', true); // Vuetify prints a warning without this + +describe('MasteryCriteriaMofNFields', () => {}); +function makeWrapper() { + return mount(TestForm, { + slots: { + testComponent: MasteryCriteriaMofNFields, + }, + }); +} + +describe('masteryCriteriaMofNFields', () => { + let formWrapper; + let wrapper; + let mInput; + let nInput; + + beforeEach(() => { + formWrapper = makeWrapper(); + wrapper = formWrapper.find(MasteryCriteriaMofNFields); + wrapper.setProps({ showMofN: true }); + wrapper.setProps({ value: { mastery_model: 'm_of_n' } }); + wrapper.vm.$nextTick(() => { + mInput = wrapper.find({ ref: 'mValue' }).find('input'); + nInput = wrapper.find({ ref: 'nValue' }).find('input'); + }); + }); + + describe('on load', () => { + it('should render according to masteryModel prop', () => { + const model = 'm_of_n'; + expect(wrapper.find({ ref: 'mValue' }).exists()).toBe(model === 'm_of_n'); + expect(wrapper.find({ ref: 'nValue' }).exists()).toBe(model === 'm_of_n'); + }); + it('should render correct mValue and nValue props', () => { + wrapper.setProps({ value: { m: 10, n: 20 } }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$refs.mValue.value).toEqual(10); + expect(wrapper.vm.$refs.nValue.value).toEqual(20); + }); + }); + }); + describe('props', () => { + beforeEach(() => {}); + it('setting readonly should prevent any edits', () => { + wrapper.setProps({ readonly: true }); + return wrapper.vm.$nextTick(() => { + expect(mInput.attributes('readonly')).toEqual('readonly'); + expect(nInput.attributes('readonly')).toEqual('readonly'); + }); + }); + it('setting required to false should make fields not required (required by default)', () => { + expect(mInput.attributes('required')).toEqual('required'); + expect(nInput.attributes('required')).toEqual('required'); + + wrapper.setProps({ mRequired: false, nRequired: false }); + return wrapper.vm.$nextTick(() => { + expect(mInput.attributes('required')).toBeFalsy(); + expect(nInput.attributes('required')).toBeFalsy(); + }); + }); + it('setting disabled should make fields disabled', () => { + expect(mInput.attributes('disabled')).toBeFalsy(); + expect(nInput.attributes('disabled')).toBeFalsy(); + + wrapper.setProps({ disabled: true }); + return wrapper.vm.$nextTick(() => { + expect(mInput.attributes('disabled')).toEqual('disabled'); + expect(nInput.attributes('disabled')).toEqual('disabled'); + }); + }); + }); + describe('emitted events', () => { + it('input should be emitted when mValue is updated', () => { + expect(wrapper.emitted('input')).toBeFalsy(); + mInput.setValue(10); + expect(wrapper.emitted('input')).toBeTruthy(); + expect(wrapper.emitted('input')[0][0].m).toEqual(10); + }); + it('input should be emitted when nValue is updated', () => { + expect(wrapper.emitted('input')).toBeFalsy(); + nInput.setValue(10); + expect(wrapper.emitted('input')).toBeTruthy(); + expect(wrapper.emitted('input')[0][0].n).toEqual(10); + }); + }); + describe('validation', () => { + it('should flag empty n and m values', () => { + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(true); + expect( + wrapper + .find({ ref: 'nValue' }) + .find('.error--text') + .exists() + ).toBe(true); + wrapper.setProps({ mRequired: false, nRequired: false }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(false); + expect( + wrapper + .find({ ref: 'nValue' }) + .find('.error--text') + .exists() + ).toBe(false); + }); + it('should flag if m is not a whole number', () => { + wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 0.1231, n: 10 } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(true); + wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 1, n: 10 } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(false); + }); + it('should flag if m < 1', () => { + wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 0, n: 10 } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(true); + wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 1, n: 10 } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(false); + }); + it('should flag if m > n', () => { + wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 2, n: 1 } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(true); + wrapper.setProps({ value: { mastery_model: 'm_of_n', m: 2, n: 2 } }); + formWrapper.vm.validate(); + expect( + wrapper + .find({ ref: 'mValue' }) + .find('.error--text') + .exists() + ).toBe(false); + }); + }); +});