Commit 13ecee01 by Matjaz Gregoric

[TNL-6018] Use SR.readTexts to read feedback.

parent c773d9fa
......@@ -868,6 +868,7 @@ function DragAndDropBlock(runtime, element, configuration) {
var applyState = function(keepDraggableInit) {
sendFeedbackPopupEvents();
updateDOM();
readScreenReaderMessages();
if (!keepDraggableInit) {
destroyDraggable();
if (!state.finished) {
......@@ -904,7 +905,7 @@ function DragAndDropBlock(runtime, element, configuration) {
return feedback_msgs_list.map(function(message) { return message.message; }).join('\n');
};
var updateDOM = function(state) {
var updateDOM = function() {
var new_vdom = render(state);
var patches = virtualDom.diff(__vdom, new_vdom);
root = virtualDom.patch(root, patches);
......@@ -912,6 +913,39 @@ function DragAndDropBlock(runtime, element, configuration) {
__vdom = new_vdom;
};
// Uses edX JS accessibility tools to read feedback messages when present.
var readScreenReaderMessages = function() {
if (window.SR && window.SR.readTexts) {
var pluckMessages = function(feedback_items) {
return feedback_items.map(function(item) {
return item.message;
});
};
var messages = [];
// In standard mode, it makes more sense to read the per-item feedback before overall feedback.
if (state.feedback && configuration.mode === DragAndDropBlock.STANDARD_MODE) {
messages = messages.concat(pluckMessages(state.feedback));
}
if (state.overall_feedback) {
messages = messages.concat(pluckMessages(state.overall_feedback));
}
// In assessment mode overall feedback comes first then multiple per-item feedbacks.
if (state.feedback && configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) {
if (state.feedback.length > 0) {
if (!state.last_action_correct) {
messages.push(gettext("Some of your answers were not correct."))
}
messages = messages.concat(
gettext("Hints:"),
pluckMessages(state.feedback)
);
}
}
SR.readTexts(messages);
}
};
var publishEvent = function(data) {
$.ajax({
type: 'POST',
......
......@@ -163,6 +163,27 @@ class BaseIntegrationTest(SeleniumBaseTest):
focused_element = self.browser.switch_to.active_element
self.assertTrue(element != focused_element, 'expected element to not have focus')
def _patch_sr_read_texts(self):
"""
Creates a mock SR.readTexts function that stores submitted texts into a global variable
for later inspection.
Returns a getter function that returns stored SR texts.
"""
self.browser.execute_script(
"""
window.SR = {
received_texts: [],
readTexts: function(texts) {
window.SR.received_texts.push(texts);
}
};
"""
)
def get_sr_texts():
return self.browser.execute_script('return window.SR.received_texts')
return get_sr_texts
@staticmethod
def get_element_html(element):
return element.get_attribute('innerHTML').strip()
......@@ -239,22 +260,32 @@ class DefaultDataTestMixin(object):
class InteractionTestBase(object):
POPUP_ERROR_CLASS = "popup-incorrect"
@classmethod
def _get_items_with_zone(cls, items_map):
def setUp(self):
super(InteractionTestBase, self).setUp()
scenario_xml = self._get_scenario_xml()
self._add_scenario(self.PAGE_ID, self.PAGE_TITLE, scenario_xml)
self._page = self.go_to_page(self.PAGE_TITLE)
# Resize window so that the entire drag container is visible.
# Selenium has issues when dragging to an area that is off screen.
self.browser.set_window_size(1024, 1024)
@staticmethod
def _get_items_with_zone(items_map):
return {
item_key: definition for item_key, definition in items_map.items()
if definition.zone_ids != []
}
@classmethod
def _get_items_without_zone(cls, items_map):
@staticmethod
def _get_items_without_zone(items_map):
return {
item_key: definition for item_key, definition in items_map.items()
if definition.zone_ids == []
}
@classmethod
def _get_items_by_zone(cls, items_map):
@staticmethod
def _get_items_by_zone(items_map):
zone_ids = set([definition.zone_ids[0] for _, definition in items_map.items() if definition.zone_ids])
return {
zone_id: {item_key: definition for item_key, definition in items_map.items()
......@@ -262,15 +293,17 @@ class InteractionTestBase(object):
for zone_id in zone_ids
}
def setUp(self):
super(InteractionTestBase, self).setUp()
scenario_xml = self._get_scenario_xml()
self._add_scenario(self.PAGE_ID, self.PAGE_TITLE, scenario_xml)
self._page = self.go_to_page(self.PAGE_TITLE)
# Resize window so that the entire drag container is visible.
# Selenium has issues when dragging to an area that is off screen.
self.browser.set_window_size(1024, 800)
@staticmethod
def _get_incorrect_zone_for_item(item, zones):
"""Returns the first zone that is not correct for this item."""
zone_id = None
zone_title = None
for z_id, z_title in zones:
if z_id not in item.zone_ids:
zone_id = z_id
zone_title = z_title
break
return [zone_id, zone_title]
def _get_item_by_value(self, item_value):
return self._page.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0]
......
......@@ -76,7 +76,7 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, BaseEventsT
@data(*enumerate(scenarios)) # pylint: disable=star-args
@unpack
def test_event(self, index, event):
self.parameterized_item_positive_feedback_on_good_move(self.items_map)
self.parameterized_item_positive_feedback_on_good_move_standard(self.items_map)
dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name'])
self.assertEqual(published_data, event['data'])
......
......@@ -50,35 +50,95 @@ class ParameterizedTestsMixin(object):
ActionChains(self.browser).send_keys(Keys.TAB).perform()
self.assertFocused(go_to_beginning_button)
def parameterized_item_positive_feedback_on_good_move(
self, items_map, scroll_down=100, action_key=None, assessment_mode=False
def parameterized_item_positive_feedback_on_good_move_standard(
self, items_map, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
get_sr_texts = self._patch_sr_read_texts()
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
for definition in self._get_items_with_zone(items_map).values():
items_with_zones = self._get_items_with_zone(items_map).values()
for i, definition in enumerate(items_with_zones):
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id))
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=assessment_mode)
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=False)
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
if assessment_mode:
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
self.assertEqual(feedback_popup_html, "<p>{}</p>".format(definition.feedback_positive))
self.assert_popup_correct(popup)
self.assertTrue(popup.is_displayed())
expected_sr_texts = [definition.feedback_positive]
if i == len(items_with_zones) - 1:
# We just dropped the last item, so the problem is done and we should see the final feedback.
overall_feedback = feedback['final']
else:
overall_feedback = feedback['intro']
expected_sr_texts.append(overall_feedback)
self.assertEqual(get_sr_texts()[-1], expected_sr_texts)
if action_key:
# Next TAB keypress should move focus to "Go to Beginning button"
self._test_next_tab_goes_to_go_to_beginning_button()
else:
self.assertEqual(feedback_popup_html, "<p>{}</p>".format(definition.feedback_positive))
self.assert_popup_correct(popup)
def parameterized_item_positive_feedback_on_good_move_assessment(
self, items_map, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
get_sr_texts = self._patch_sr_read_texts()
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
items_with_zones = self._get_items_with_zone(items_map).values()
for definition in items_with_zones:
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id))
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=True)
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
self.assertEqual(get_sr_texts()[-1], [feedback['intro']])
def parameterized_item_negative_feedback_on_bad_move_standard(
self, items_map, all_zones, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
get_sr_texts = self._patch_sr_read_texts()
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
for definition in items_map.values():
zone_id, _ = self._get_incorrect_zone_for_item(definition, all_zones)
if zone_id is not None: # Some items may be placed in any zone, ignore those.
self.place_item(definition.item_id, zone_id, action_key)
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_popup_incorrect(popup)
self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id)
expected_sr_texts = [definition.feedback_negative, feedback['intro']]
self.assertEqual(get_sr_texts()[-1], expected_sr_texts)
self._test_popup_focus_and_close(popup, action_key)
def parameterized_item_negative_feedback_on_bad_move(
self, items_map, all_zones, scroll_down=100, action_key=None, assessment_mode=False
def parameterized_item_negative_feedback_on_bad_move_assessment(
self, items_map, all_zones, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
get_sr_texts = self._patch_sr_read_texts()
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
......@@ -86,30 +146,18 @@ class ParameterizedTestsMixin(object):
self.scroll_down(pixels=scroll_down)
for definition in items_map.values():
# Get first zone that is not correct for this item.
zone_id = None
zone_title = None
for z_id, z_title in all_zones:
if z_id not in definition.zone_ids:
zone_id = z_id
zone_title = z_title
break
zone_id, zone_title = self._get_incorrect_zone_for_item(definition, all_zones)
if zone_id is not None: # Some items may be placed in any zone, ignore those.
self.place_item(definition.item_id, zone_id, action_key)
if assessment_mode:
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id))
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True)
self.assertEqual(get_sr_texts()[-1], [feedback['intro']])
self._test_popup_focus_and_close(popup, action_key)
if action_key:
self._test_next_tab_goes_to_go_to_beginning_button()
else:
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_popup_incorrect(popup)
self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id)
self._test_popup_focus_and_close(popup, action_key)
def parameterized_move_items_between_zones(self, items_map, all_zones, scroll_down=100, action_key=None):
# Scroll drop zones into view to make sure Selenium can successfully drop items
......@@ -247,11 +295,13 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet
"""
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_item_positive_feedback_on_good_move(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(self.items_map, action_key=action_key)
self.parameterized_item_positive_feedback_on_good_move_standard(self.items_map, action_key=action_key)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_item_negative_feedback_on_bad_move(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones, action_key=action_key)
self.parameterized_item_negative_feedback_on_bad_move_standard(
self.items_map, self.all_zones, action_key=action_key
)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_cannot_move_items_between_zones(self, action_key):
......@@ -477,16 +527,22 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase
def test_item_positive_feedback_on_good_move(self):
self._switch_to_block(0)
self.parameterized_item_positive_feedback_on_good_move(self.item_maps['block1'])
self.parameterized_item_positive_feedback_on_good_move_standard(
self.item_maps['block1'], feedback=self.feedback['block1']
)
self._switch_to_block(1)
self.parameterized_item_positive_feedback_on_good_move(self.item_maps['block2'], scroll_down=1000)
self.parameterized_item_positive_feedback_on_good_move_standard(
self.item_maps['block2'], feedback=self.feedback['block2'], scroll_down=1000
)
def test_item_negative_feedback_on_bad_move(self):
self._switch_to_block(0)
self.parameterized_item_negative_feedback_on_bad_move(self.item_maps['block1'], self.all_zones['block1'])
self.parameterized_item_negative_feedback_on_bad_move_standard(
self.item_maps['block1'], self.all_zones['block1'], feedback=self.feedback['block1']
)
self._switch_to_block(1)
self.parameterized_item_negative_feedback_on_bad_move(
self.item_maps['block2'], self.all_zones['block2'], scroll_down=1000
self.parameterized_item_negative_feedback_on_bad_move_standard(
self.item_maps['block2'], self.all_zones['block2'], feedback=self.feedback['block2'], scroll_down=1000
)
def test_final_feedback_and_reset(self):
......
......@@ -81,14 +81,12 @@ class AssessmentInteractionTest(
"""
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_item_no_feedback_on_good_move(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(
self.items_map, action_key=action_key, assessment_mode=True
)
self.parameterized_item_positive_feedback_on_good_move_assessment(self.items_map, action_key=action_key)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_item_no_feedback_on_bad_move(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(
self.items_map, self.all_zones, action_key=action_key, assessment_mode=True
self.parameterized_item_negative_feedback_on_bad_move_assessment(
self.items_map, self.all_zones, action_key=action_key
)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
......@@ -250,6 +248,20 @@ class AssessmentInteractionTest(
"""
Test updating overall feedback after submitting solution in assessment mode
"""
get_sr_texts = self._patch_sr_read_texts()
def check_feedback(overall_feedback_lines, per_item_feedback_lines=None):
# Check that the feedback is correctly displayed in the overall feedback area.
expected_overall_feedback = "\n".join(["FEEDBACK"] + overall_feedback_lines)
self.assertEqual(self._get_feedback().text, expected_overall_feedback)
# Check that the SR.readTexts function was passed correct feedback messages.
sr_feedback_lines = overall_feedback_lines
if per_item_feedback_lines:
sr_feedback_lines += ["Some of your answers were not correct.", "Hints:"]
sr_feedback_lines += per_item_feedback_lines
self.assertEqual(get_sr_texts()[-1], sr_feedback_lines)
# used keyboard mode to avoid bug/feature with selenium "selecting" everything instead of dragging an element
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
......@@ -261,29 +273,25 @@ class AssessmentInteractionTest(
expected_grade = 2.0 / 5.0
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3),
START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
check_feedback(feedback_lines)
# Place the item into incorrect zone. The score does not change.
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced_returned(1),
FeedbackMessages.not_placed(2),
START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
check_feedback(feedback_lines, ["No, this item does not belong here. Try again."])
# reach final attempt
for _ in xrange(self.MAX_ATTEMPTS-3):
......@@ -299,13 +307,11 @@ class AssessmentInteractionTest(
expected_grade = 1.0
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
check_feedback(feedback_lines)
def test_per_item_feedback_multiple_misplaced(self):
self.place_item(0, MIDDLE_ZONE_ID, Keys.RETURN)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment