diff --git a/.gitignore b/.gitignore index f50ad217f395..593dc3cf19e7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ cms/envs/private.py *.orig /nbproject .idea/ +.vscode/ .redcar/ codekit-config.json .pycharm_helpers/ diff --git a/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js b/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js index 2f4865356cd3..fdc7f9dede29 100644 --- a/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js +++ b/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js @@ -1,9 +1,15 @@ /* JavaScript for Vertical Student View. */ + +/* global markBlocksCompletedOnViewIfNeeded:false */ + window.VerticalStudentView = function(runtime, element) { 'use strict'; + + markBlocksCompletedOnViewIfNeeded(runtime, element); + if (typeof RequireJS === 'undefined') { // eslint-disable-next-line no-console - console.log('Cannot initialize VerticalStudentView. RequireJS is not defined.'); + console.warn('Cannot initialize bookmarks for VerticalStudentView. RequireJS is not defined.'); return; } RequireJS.require(['js/bookmarks/views/bookmark_button'], function(BookmarkButton) { diff --git a/common/lib/xmodule/xmodule/vertical_block.py b/common/lib/xmodule/xmodule/vertical_block.py index d253a1f67e7c..70fd2c04d5cb 100644 --- a/common/lib/xmodule/xmodule/vertical_block.py +++ b/common/lib/xmodule/xmodule/vertical_block.py @@ -2,8 +2,10 @@ VerticalBlock - an XBlock which renders its children in a column. """ import logging +import os from copy import copy +from django.conf import settings from lxml import etree from xblock.core import XBlock from xblock.fragment import Fragment @@ -23,6 +25,7 @@ @XBlock.needs('user', 'bookmarks') +@XBlock.wants('completion') class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParserMixin, MakoTemplateBlockBase, XBlock): """ Layout XBlock for rendering subblocks vertically. @@ -55,14 +58,24 @@ def student_view(self, context): if 'username' not in child_context: user_service = self.runtime.service(self, 'user') child_context['username'] = user_service.get_current_user().opt_attrs['edx-platform.username'] + child_blocks = self.get_display_items() + child_blocks_to_complete_on_view = set() + completion_service = self.runtime.service(self, 'completion') + if completion_service and completion_service.completion_tracking_enabled(): + child_blocks_to_complete_on_view = completion_service.blocks_to_mark_complete_on_view(child_blocks) child_context['child_of_vertical'] = True is_child_of_vertical = context.get('child_of_vertical', False) # pylint: disable=no-member - for child in self.get_display_items(): - rendered_child = child.render(STUDENT_VIEW, child_context) + for child in child_blocks: + child_block_context = copy(child_context) + if child in child_blocks_to_complete_on_view: + child_block_context['wrap_xblock_data'] = { + 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() + } + rendered_child = child.render(STUDENT_VIEW, child_block_context) fragment.add_frag_resources(rendered_child) contents.append({ @@ -79,6 +92,7 @@ def student_view(self, context): 'bookmark_id': u"{},{}".format(child_context['username'], unicode(self.location)) })) + fragment.add_javascript_url(self.runtime.STATIC_URL + 'bundles/CompletionOnViewService.js') fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vertical_student_view.js')) fragment.initialize_js('VerticalStudentView') diff --git a/common/test/acceptance/pages/lms/completion.py b/common/test/acceptance/pages/lms/completion.py new file mode 100644 index 000000000000..b535af7f51ff --- /dev/null +++ b/common/test/acceptance/pages/lms/completion.py @@ -0,0 +1,25 @@ +""" +Mixins for completion. +""" + + +class CompletionOnViewMixin(object): + """ + Methods for testing completion on view. + """ + + def xblock_components_mark_completed_on_view_value(self): + """ + Return the xblock components data-mark-completed-on-view-after-delay value. + """ + return self.q(css=self.xblock_component_selector).attrs(u'data-mark-completed-on-view-after-delay') + + def wait_for_xblock_component_to_be_marked_completed_on_view(self, index=0): + """ + Wait for xblock component to be marked completed on view. + + Arguments + index (int): index of block to wait on. (default is 0) + """ + self.wait_for(lambda: (self.xblock_components_mark_completed_on_view_value()[index] == u'0'), + u'Waiting for xblock to be marked completed on view.') diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index 2240808c6089..6dabe9f2e555 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -8,11 +8,13 @@ from bok_choy.promise import EmptyPromise from selenium.webdriver.common.action_chains import ActionChains +from common.test.acceptance.pages.lms import BASE_URL from common.test.acceptance.pages.lms.bookmarks import BookmarksPage +from common.test.acceptance.pages.lms.completion import CompletionOnViewMixin from common.test.acceptance.pages.lms.course_page import CoursePage -class CoursewarePage(CoursePage): +class CoursewarePage(CoursePage, CompletionOnViewMixin): """ Course info. """ @@ -587,3 +589,25 @@ def visit_course_outline_page(self): # reload the same page with the course_outline_page flag self.browser.get(self.browser.current_url + "&course_experience.course_outline_page=1") self.wait_for_page() + + +class RenderXBlockPage(PageObject, CompletionOnViewMixin): + """ + render_xblock page. + """ + + xblock_component_selector = '.xblock' + + def __init__(self, browser, block_id): + super(RenderXBlockPage, self).__init__(browser) + self.block_id = block_id + + @property + def url(self): + """ + Construct a URL to the page within the course. + """ + return BASE_URL + "/xblock/" + self.block_id + + def is_browser_on_page(self): + return self.q(css='.course-content').present diff --git a/common/test/acceptance/tests/lms/test_lms_courseware.py b/common/test/acceptance/tests/lms/test_lms_courseware.py index e86fd7e78dcf..aa500a6d9b02 100644 --- a/common/test/acceptance/tests/lms/test_lms_courseware.py +++ b/common/test/acceptance/tests/lms/test_lms_courseware.py @@ -14,7 +14,7 @@ from ...pages.common.auto_auth import AutoAuthPage from ...pages.common.logout import LogoutPage from ...pages.lms.course_home import CourseHomePage -from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage +from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage, RenderXBlockPage from ...pages.lms.create_mode import ModeCreationPage from ...pages.lms.dashboard import DashboardPage from ...pages.lms.pay_and_verify import FakePaymentPage, FakeSoftwareSecureVerificationPage, PaymentAndVerificationFlow @@ -947,3 +947,129 @@ def test_subsecton_hidden_after_due_date(self): self.progress_page.visit() self.assertEqual(self.progress_page.scores('Test Section 1', 'Test Subsection 1'), [(0, 1)]) + + +@attr(shard=9) +class CompletionTestCase(UniqueCourseTest, EventsTestMixin): + """ + Test the completion on view functionality. + """ + USERNAME = "STUDENT_TESTER" + EMAIL = "student101@example.com" + COMPLETION_BY_VIEWING_DELAY_MS = '1000' + + def setUp(self): + super(CompletionTestCase, self).setUp() + + self.studio_course_outline = StudioCourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + # Install a course with sections/problems, tabs, updates, and handouts + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + self.html_1_block = XBlockFixtureDesc('html', 'html 1', data="html 1 dummy body") + self.problem_1_block = XBlockFixtureDesc( + 'problem', 'Test Problem 1', data='problem 1 dummy body' + ) + + course_fix.add_children( + XBlockFixtureDesc('chapter', 'Test Section 1').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children( + XBlockFixtureDesc('vertical', 'Test Unit 1,1,1').add_children( + XBlockFixtureDesc('html', 'html 1', data="html 1 dummy body"), + XBlockFixtureDesc( + 'html', 'html 2', + data=("html 2 dummy body" * 100) + "End", + ), + XBlockFixtureDesc('problem', 'Test Problem 1', data='problem 1 dummy body'), + ), + XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children( + XBlockFixtureDesc('html', 'html 1', data="html 1 dummy body"), + XBlockFixtureDesc('problem', 'Test Problem 1', data='problem 1 dummy body'), + ), + XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children( + self.html_1_block, + self.problem_1_block, + ), + ), + ), + ).install() + + # Auto-auth register for the course. + AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, + course_id=self.course_id, staff=False).visit() + + def test_courseware_publish_completion_is_sent_on_view(self): + """ + Test that when viewing courseware XBlocks are correctly marked as completed on view. + """ + courseware_page = CoursewarePage(self.browser, self.course_id) + courseware_page.visit() + courseware_page.wait_for_page() + + # Initially, the first two blocks in the first vertical should be marked as needing to be completed on view. + self.assertEqual( + courseware_page.xblock_components_mark_completed_on_view_value(), + [self.COMPLETION_BY_VIEWING_DELAY_MS, self.COMPLETION_BY_VIEWING_DELAY_MS, None], + ) + # Wait and verify that the first block which is completely visible is marked as completed. + courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(0) + self.assertEqual( + courseware_page.xblock_components_mark_completed_on_view_value(), + ['0', self.COMPLETION_BY_VIEWING_DELAY_MS, None], + ) + + # Scroll to the bottom of the second block. + courseware_page.scroll_to_element('#html2-end', 'Scroll to end of html 2 block') + # Wait and verify that the second block is also now marked as completed. + courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(1) + self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), ['0', '0', None]) + + # After page refresh, no blocks in the vertical should be marked as needing to be completed on view. + self.browser.refresh() + courseware_page.wait_for_page() + self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), [None, None, None]) + + courseware_page.go_to_sequential_position(2) + + # Initially, the first block in the second vertical should be marked as needing to be completed on view. + self.assertEqual( + courseware_page.xblock_components_mark_completed_on_view_value(), + [self.COMPLETION_BY_VIEWING_DELAY_MS, None], + ) + # Wait and verify that the first block which is completely visible is marked as completed. + courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(0) + self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), ['0', None]) + + # After page refresh, no blocks in the vertical should be marked as needing to be completed on view. + self.browser.refresh() + courseware_page.wait_for_page() + self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), [None, None]) + + def test_render_xblock_publish_completion_is_sent_on_view(self): + """ + Test that when viewing a XBlock in render_xblock, it is correctly marked as completed on view. + """ + block_page = RenderXBlockPage(self.browser, self.html_1_block.locator) + block_page.visit() + block_page.wait_for_page() + + # Initially the block should be marked as needing to be completed on view. + self.assertEqual( + block_page.xblock_components_mark_completed_on_view_value(), [self.COMPLETION_BY_VIEWING_DELAY_MS] + ) + # Wait and verify that the block is marked as completed on view. + block_page.wait_for_xblock_component_to_be_marked_completed_on_view(0) + self.assertEqual(block_page.xblock_components_mark_completed_on_view_value(), ['0']) + + # After page refresh, it should not be marked as needing to be completed on view. + self.browser.refresh() + block_page.wait_for_page() + self.assertEqual(block_page.xblock_components_mark_completed_on_view_value(), [None]) diff --git a/common/test/db_fixtures/waffle_flags.json b/common/test/db_fixtures/waffle_flags.json new file mode 100644 index 000000000000..43de53b0a7ce --- /dev/null +++ b/common/test/db_fixtures/waffle_flags.json @@ -0,0 +1,9 @@ +[ + { + "pk": 2, + "fields": { + "name": "completion.enable_completion_tracking", + "active": true + } + } +] diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index e29be8df6b19..7748c4d2c636 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1458,6 +1458,15 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): student_view_context = request.GET.dict() student_view_context['show_bookmark_button'] = False + enable_completion_on_view_service = False + completion_service = block.runtime.service(block, 'completion') + if completion_service and completion_service.completion_tracking_enabled(): + if completion_service.blocks_to_mark_complete_on_view({block}): + enable_completion_on_view_service = True + student_view_context['wrap_xblock_data'] = { + 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() + } + context = { 'fragment': block.render('student_view', context=student_view_context), 'course': course, @@ -1466,6 +1475,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): 'disable_header': True, 'disable_footer': True, 'disable_window_wrap': True, + 'enable_completion_on_view_service': enable_completion_on_view_service, 'staff_access': bool(has_access(request.user, 'staff', course)), 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), } diff --git a/lms/djangoapps/lms_xblock/mixin.py b/lms/djangoapps/lms_xblock/mixin.py index 2e850088a60e..9b60ad14f45f 100644 --- a/lms/djangoapps/lms_xblock/mixin.py +++ b/lms/djangoapps/lms_xblock/mixin.py @@ -5,12 +5,14 @@ #from django.utils.translation import ugettext_noop as _ from lazy import lazy from xblock.core import XBlock +from xblock.exceptions import JsonHandlerError from xblock.fields import Boolean, Dict, Scope, String, XBlockMixin from xblock.validation import ValidationMessage from xmodule.modulestore.inheritance import UserPartitionList from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError + # Please do not remove, this is a workaround for Django 1.8. # more information can be found here: https://openedx.atlassian.net/browse/PLAT-902 _ = lambda text: text @@ -31,6 +33,7 @@ def to_json(self, access_dict): @XBlock.needs('partitions') +@XBlock.wants('completion') class LmsBlockMixin(XBlockMixin): """ Mixin that defines fields common to all blocks used in the LMS @@ -179,3 +182,18 @@ def validate(self): ) ) return validation + + @XBlock.json_handler + def publish_completion(self, data, suffix=''): # pylint: disable=unused-argument + """ + Publish completion data from the front end. + """ + completion_service = self.runtime.service(self, 'completion') + if completion_service is None: + raise JsonHandlerError(500, u"No completion service found") + elif not completion_service.completion_tracking_enabled(): + raise JsonHandlerError(404, u"Completion tracking is not enabled and API calls are unexpected") + if not completion_service.should_mark_block_completed_on_view(self): + raise JsonHandlerError(400, u"Block not configured for completion on view.") + self.runtime.publish(self, u"completion", data) + return {u'result': u'ok'} diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py index 105afd46e157..9ce5fef9e49b 100644 --- a/lms/djangoapps/lms_xblock/runtime.py +++ b/lms/djangoapps/lms_xblock/runtime.py @@ -1,7 +1,9 @@ """ Module implementing `xblock.runtime.Runtime` functionality for the LMS """ + import xblock.reference.plugins +from completion.services import CompletionService from django.conf import settings from django.core.urlresolvers import reverse @@ -178,6 +180,7 @@ class LmsModuleSystem(LmsCourse, LmsUser, ModuleSystem): # pylint: disable=abst def __init__(self, **kwargs): request_cache_dict = RequestCache.get_request_cache().data services = kwargs.setdefault('services', {}) + services['completion'] = CompletionService(user=kwargs.get('user'), course_key=kwargs.get('course_id')) services['fs'] = xblock.reference.plugins.FSService() services['i18n'] = ModuleI18nService services['library_tools'] = LibraryToolsService(modulestore()) diff --git a/lms/static/coffee/src/courseware.coffee b/lms/static/coffee/src/courseware.coffee index 49e27c484b8c..ff44e7b6a20d 100644 --- a/lms/static/coffee/src/courseware.coffee +++ b/lms/static/coffee/src/courseware.coffee @@ -10,6 +10,13 @@ class @Courseware render: -> XBlock.initializeBlocks($('.course-content')) + + courseContentElement = $('.course-content')[0] + blocks = XBlock.initializeBlocks(courseContentElement) + + if (courseContentElement.dataset.enableCompletionOnViewService == 'true') + markBlocksCompletedOnViewIfNeeded(blocks[0].runtime, courseContentElement) + $('.course-content .histogram').each -> id = $(this).attr('id').replace(/histogram_/, '') try diff --git a/lms/static/completion/js/.eslintrc.js b/lms/static/completion/js/.eslintrc.js new file mode 100644 index 000000000000..12cb26eef91e --- /dev/null +++ b/lms/static/completion/js/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: 'eslint-config-edx', + root: true, + settings: { + 'import/resolver': 'webpack', + }, +}; diff --git a/lms/static/completion/js/CompletionOnViewService.js b/lms/static/completion/js/CompletionOnViewService.js new file mode 100644 index 000000000000..833610ca4c15 --- /dev/null +++ b/lms/static/completion/js/CompletionOnViewService.js @@ -0,0 +1,44 @@ +import { ViewedEventTracker } from './ViewedEvent'; + +const completedBlocksKeys = new Set(); + +// eslint-disable-next-line import/prefer-default-export +export function markBlocksCompletedOnViewIfNeeded(runtime, containerElement) { + const blockElements = $(containerElement).find( + '.xblock-student_view[data-mark-completed-on-view-after-delay]', + ).get(); + + if (blockElements.length > 0) { + const tracker = new ViewedEventTracker(); + + blockElements.forEach((blockElement) => { + const markCompletedOnViewAfterDelay = parseInt( + blockElement.dataset.markCompletedOnViewAfterDelay, 10, + ); + if (markCompletedOnViewAfterDelay >= 0) { + tracker.addElement(blockElement, markCompletedOnViewAfterDelay); + } + }); + + tracker.addHandler((blockElement, event) => { + const blockKey = blockElement.dataset.usageId; + if (blockKey && !completedBlocksKeys.has(blockKey)) { + if (event.elementHasBeenViewed) { + $.ajax({ + type: 'POST', + url: runtime.handlerUrl(blockElement, 'publish_completion'), + data: JSON.stringify({ + completion: 1.0, + }), + }).then( + () => { + completedBlocksKeys.add(blockKey); + // eslint-disable-next-line no-param-reassign + blockElement.dataset.markCompletedOnViewAfterDelay = 0; + }, + ); + } + } + }); + } +} diff --git a/lms/static/completion/js/ViewedEvent.js b/lms/static/completion/js/ViewedEvent.js new file mode 100644 index 000000000000..fe2d8f3fead1 --- /dev/null +++ b/lms/static/completion/js/ViewedEvent.js @@ -0,0 +1,182 @@ +/** Ensure that a function is only run once every `wait` milliseconds */ +function throttle(fn, wait) { + let time = 0; + function delay() { + // Do not call the function until at least `wait` seconds after the + // last time the function was called. + const now = Date.now(); + if (time + wait < now) { + time = now; + fn(); + } + } + return delay; +} + + +export class ElementViewing { + /** + * A wrapper for an HTMLElement that tracks whether the element has been + * viewed or not. + */ + constructor(el, viewedAfterMs, callback) { + this.el = el; + this.viewedAfterMs = viewedAfterMs; + this.callback = callback; + + this.topSeen = false; + this.bottomSeen = false; + this.seenForMs = 0; + this.becameVisibleAt = undefined; + this.hasBeenViewed = false; + } + + getBoundingRect() { + return this.el.getBoundingClientRect(); + } + + /** This element has become visible on screen. + * + * (may be called even when already on screen though) + */ + handleVisible() { + if (!this.becameVisibleAt) { + this.becameVisibleAt = Date.now(); + // We're now visible; after viewedAfterMs, if the top and bottom have been + // seen, this block will count as viewed. + setTimeout( + () => { + this.checkIfViewed(); + }, + this.viewedAfterMs - this.seenForMs, + ); + } + } + + handleNotVisible() { + if (this.becameVisibleAt) { + this.seenForMs = Date.now() - this.becameVisibleAt; + } + this.becameVisibleAt = undefined; + } + + markTopSeen() { + // If this element has been seen for enough time, but the top wasn't visible, it may now be + // considered viewed. + this.topSeen = true; + this.checkIfViewed(); + } + + markBottomSeen() { + this.bottomSeen = true; + this.checkIfViewed(); + } + + getTotalTimeSeen() { + if (this.becameVisibleAt) { + return this.seenForMs + (Date.now() - this.becameVisibleAt); + } + return this.seenForMs; + } + + areViewedCriteriaMet() { + return this.topSeen && this.bottomSeen && (this.getTotalTimeSeen() >= this.viewedAfterMs); + } + + checkIfViewed() { + // User can provide a "now" value for testing purposes. + if (this.hasBeenViewed) { + return; + } + if (this.areViewedCriteriaMet()) { + this.hasBeenViewed = true; + // Report to the tracker that we have been viewed + this.callback(this.el, { elementHasBeenViewed: this.hasBeenViewed }); + } + } +} + + +export class ViewedEventTracker { + /** + * When the top or bottom of an element is first viewed, and the entire + * element is viewed for a specified amount of time, the callback is called, + * passing the element that was viewed, and an event object having the + * following field: + * + * * hasBeenViewed (bool): true if all the conditions for being + * considered "viewed" have been met. + */ + constructor() { + this.elementViewings = new Set(); + this.handlers = []; + this.registerDomHandlers(); + } + + /** Add an element to track. */ + addElement(element, viewedAfterMs) { + this.elementViewings.add( + new ElementViewing( + element, + viewedAfterMs, + (el, event) => this.callHandlers(el, event), + ), + ); + this.updateVisible(); + } + + /** Register a new handler to be called when an element has been viewed. */ + addHandler(handler) { + this.handlers.push(handler); + } + + /** Mark which elements are currently visible. + * + * Also marks when an elements top or bottom has been seen. + * */ + updateVisible() { + this.elementViewings.forEach((elv) => { + if (elv.hasBeenViewed) { + return; + } + + const now = Date.now(); // Use the same "now" for all calculations + const rect = elv.getBoundingRect(); + let visible = false; + + if (rect.top > 0 && rect.top < window.innerHeight) { + elv.markTopSeen(now); + visible = true; + } + if (rect.bottom > 0 && rect.bottom < window.innerHeight) { + elv.markBottomSeen(now); + visible = true; + } + if (rect.top < 0 && rect.bottom > window.innerHeight) { + visible = true; + } + + if (visible) { + elv.handleVisible(now); + } else { + elv.handleNotVisible(now); + } + }); + } + + registerDomHandlers() { + window.onscroll = throttle(() => this.updateVisible(), 100); + window.onresize = throttle(() => this.updateVisible(), 100); + this.updateVisible(); + } + + /** Call the handlers for all newly-viewed elements and pause tracking + * for recently disappeared elements. + */ + callHandlers(el, event) { + this.handlers.forEach((handler) => { + handler(el, event); + }); + } +} + diff --git a/lms/static/completion/js/spec/ViewedEvent_spec.js b/lms/static/completion/js/spec/ViewedEvent_spec.js new file mode 100644 index 000000000000..62662317423c --- /dev/null +++ b/lms/static/completion/js/spec/ViewedEvent_spec.js @@ -0,0 +1,98 @@ +import { ElementViewing, ViewedEventTracker } from '../ViewedEvent'; + + +describe('ViewedTracker', () => { + let existingHTML; + beforeEach(() => { + existingHTML = document.body.innerHTML; + }); + + afterEach(() => { + document.body.innerHTML = existingHTML; + }); + + it('calls the handlers when an element is viewed', () => { + document.body.innerHTML = '
'; + const tracker = new ViewedEventTracker(); + for (const element of document.getElementsByTagName('div')) { // eslint-disable-line no-restricted-syntax + tracker.addElement(element, 1000); + } + const handlerSpy = jasmine.createSpy('handlerSpy'); + tracker.addHandler(handlerSpy); + const elvIter = tracker.elementViewings.values(); + // Pick two elements, and mock them so that one has met the criteria to be viewed, + // and the other hasn't. + const viewed = elvIter.next().value; + spyOn(viewed, 'areViewedCriteriaMet').and.returnValue(true); + viewed.checkIfViewed(); + expect(handlerSpy).toHaveBeenCalledWith(viewed.el, { + elementHasBeenViewed: true, + }); + const unviewed = elvIter.next().value; + spyOn(unviewed, 'areViewedCriteriaMet').and.returnValue(false); + unviewed.checkIfViewed(); + expect(handlerSpy).not.toHaveBeenCalledWith(unviewed.el, jasmine.anything()); + }); +}); + +describe('ElementViewing', () => { + beforeEach(() => { + jasmine.clock().install(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('calls checkIfViewed when enough time has elapsed', () => { + const viewing = new ElementViewing({}, 500, () => {}); + spyOn(viewing, 'checkIfViewed').and.callThrough(); + viewing.seenForMs = 250; + viewing.handleVisible(); + jasmine.clock().tick(249); + expect(viewing.checkIfViewed).not.toHaveBeenCalled(); + jasmine.clock().tick(1); + expect(viewing.checkIfViewed).toHaveBeenCalled(); + }); + + it('has been viewed after the specified number of milliseconds', () => { + const viewing = new ElementViewing({}, 500, () => {}); + viewing.seenForMs = 250; + spyOn(Date, 'now').and.returnValue(750); + viewing.handleVisible(); + viewing.markTopSeen(); + viewing.markBottomSeen(); + Date.now.and.returnValue(999); + viewing.checkIfViewed(); + expect(viewing.hasBeenViewed).toBeFalsy(); + Date.now.and.returnValue(1000); + jasmine.clock().tick(250); + expect(viewing.hasBeenViewed).toBeTruthy(); + }); + + it('has not been viewed if the bottom has not been seen', () => { + const viewing = new ElementViewing(undefined, 500, () => {}); + viewing.markTopSeen(); + viewing.seenForMs = 500; + expect(viewing.areViewedCriteriaMet()).toBeFalsy(); + viewing.checkIfViewed(); + expect(viewing.hasBeenViewed).toBeFalsy(); + }); + + it('has not been viewed if the top has not been seen', () => { + const viewing = new ElementViewing(undefined, 500, () => {}); + viewing.markBottomSeen(); + viewing.seenForMs = 500; + expect(viewing.areViewedCriteriaMet()).toBeFalsy(); + viewing.checkIfViewed(); + expect(viewing.hasBeenViewed).toBeFalsy(); + }); + + it('does not update time seen if lastSeen is undefined', () => { + const viewing = new ElementViewing(undefined, 500, () => {}); + viewing.becameVisibleAt = undefined; + expect(viewing.becameVisibleAt).toBeUndefined(); + viewing.handleVisible(); + expect(viewing.becameVisibleAt).not.toBeUndefined(); + }); +}); diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 4395917af681..28440b25d926 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -673,6 +673,7 @@ }); testFiles = [ + 'completion/js/spec/ViewedEvent_spec.js', 'course_bookmarks/js/spec/bookmark_button_view_spec.js', 'course_bookmarks/js/spec/bookmarks_list_view_spec.js', 'course_bookmarks/js/spec/course_bookmarks_factory_spec.js', diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html index 6ebabf686009..92cb8f694432 100644 --- a/lms/templates/courseware/courseware-chromeless.html +++ b/lms/templates/courseware/courseware-chromeless.html @@ -54,11 +54,15 @@ ## codemirror + % if enable_completion_on_view_service: + + + % endif <%static:js group='courseware'/> <%include file="/mathjax_include.html" args="disable_fast_preview=True"/> % if staff_access: - <%include file="xqa_interface.html"/> + <%include file="xqa_interface.html"/> % endif