Skip to content

Commit

Permalink
[SOL-1944] Unit tests for do_attempt
Browse files Browse the repository at this point in the history
  • Loading branch information
E. Kolpakov committed Aug 3, 2016
1 parent f889e62 commit 811f5a1
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 51 deletions.
91 changes: 48 additions & 43 deletions drag_and_drop_v2/drag_and_drop_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from xblockutils.resources import ResourceLoader
from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin

from .utils import _, ngettext as ngettext_fallback
from .utils import _, ngettext as ngettext_fallback, FeedbackMessages
from .default_data import DEFAULT_DATA


Expand Down Expand Up @@ -306,61 +306,22 @@ def drop_item(self, item_attempt, suffix=''):

@XBlock.json_handler
def do_attempt(self, data, suffix=''):
ngettext = self._get_ngettext()
self._validate_attempt()
self._validate_do_attempt()

self.num_attempts += 1
self._mark_complete_and_publish_grade()

required, placed, correct = self._get_item_raw_stats()
placed_ids, correct_ids = set(placed), set(correct)
missing_ids = set(required) - set(placed)
misplaced_ids = placed_ids - correct_ids

correct_count, misplaced_count, missing_count = len(correct_ids), len(misplaced_ids), len(missing_ids)

feedback_msgs = [
ngettext(
'Correctly placed {correct_count} item.',
'Correctly placed {correct_count} items.',
correct_count
).format(correct_count=correct_count),
ngettext(
'Misplaced {misplaced_count} item.',
'Misplaced {misplaced_count} items.',
misplaced_count
).format(misplaced_count=misplaced_count),
ngettext(
'Not placed {missing_count} required item.',
'Not placed {missing_count} required items.',
missing_count
).format(missing_count=missing_count)
]

if misplaced_ids and self.attemps_remain:
feedback_msgs.append(_('Misplaced items were returned to item bank.'))

if not misplaced_ids and not missing_ids:
feedback_msgs.append(self.data['feedback']['finish'])
feedback_msgs, misplaced_ids = self._get_do_attempt_feedback()

for item_id in misplaced_ids:
del self.item_state[item_id]

if not self.attemps_remain:
feedback_msgs.append(_('Final attempt was used, final score is {score}').format(score=self._get_grade()))

return {
'num_attempts': self.num_attempts,
'misplaced_items': list(misplaced_ids),
'feedback': ''.join(["<p>{}</p>".format(msg) for msg in feedback_msgs])
}

def _validate_attempt(self):
if self.mode != self.ASSESSMENT_MODE:
raise JsonHandlerError(400, _("do_attempt handler should only be called for assessment mode"))
if not self.attemps_remain:
raise JsonHandlerError(409, _("Max number of attempts reached"))

@XBlock.json_handler
def publish_event(self, data, suffix=''):
try:
Expand Down Expand Up @@ -416,6 +377,50 @@ def _get_ngettext(self):
else:
return ngettext_fallback

def _validate_do_attempt(self):
if self.mode != self.ASSESSMENT_MODE:
raise JsonHandlerError(400, _("do_attempt handler should only be called for assessment mode"))
if not self.attemps_remain:
raise JsonHandlerError(409, _("Max number of attempts reached"))

def _get_do_attempt_feedback(self):
ngettext = self._get_ngettext()

required, placed, correct = self._get_item_raw_stats()
placed_ids, correct_ids = set(placed), set(correct)
missing_ids = set(required) - set(placed)
misplaced_ids = placed_ids - correct_ids

correct_count, misplaced_count, missing_count = len(correct_ids), len(misplaced_ids), len(missing_ids)

feedback_msgs = [
ngettext(
FeedbackMessages.CORRECTLY_PLACED_SINGULAR_TPL,
FeedbackMessages.CORRECTLY_PLACED_PLURAL_TPL,
correct_count
).format(correct_count=correct_count),
ngettext(
FeedbackMessages.MISPLACED_SINGULAR_TPL,
FeedbackMessages.MISPLACED_PLURAL_TPL,
misplaced_count
).format(misplaced_count=misplaced_count),
ngettext(
FeedbackMessages.NOT_PLACED_REQUIRED_SINGULAR_TPL,
FeedbackMessages.NOT_PLACED_REQUIRED_PLURAL_TPL,
missing_count
).format(missing_count=missing_count)
]

if misplaced_ids and self.attemps_remain:
feedback_msgs.append(FeedbackMessages.MISPLACED_ITEMS_RETURNED)
if not misplaced_ids and not missing_ids:
feedback_msgs.append(self.data['feedback']['finish'])

if not self.attemps_remain:
feedback_msgs.append(
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=self._get_grade()))
return feedback_msgs, misplaced_ids

def _drop_item_standard(self, item_attempt):
item = self._get_item_definition(item_attempt['val'])

Expand Down Expand Up @@ -467,7 +472,7 @@ def _make_state_from_attempt(attempt, correct):

def _mark_complete_and_publish_grade(self):
# don't publish the grade if the student has already completed the problem
if not self.completed:
if not self.completed or (self.mode == self.ASSESSMENT_MODE and not self.attemps_remain):
self.completed = self._is_finished() or not self.attemps_remain
self._publish_grade()

Expand Down
16 changes: 15 additions & 1 deletion drag_and_drop_v2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,18 @@ def ngettext(text_singular, text_plural, n):
if n == 1:
return text_singular
else:
return text_plural
return text_plural


class FeedbackMessages(object):
FINAL_ATTEMPT_TPL = _('Final attempt was used, final score is {score}')
MISPLACED_ITEMS_RETURNED = _('Misplaced items were returned to item bank.')

CORRECTLY_PLACED_SINGULAR_TPL = _('Correctly placed {correct_count} item.')
CORRECTLY_PLACED_PLURAL_TPL = _('Correctly placed {correct_count} items.')

MISPLACED_SINGULAR_TPL = _('Misplaced {misplaced_count} item.')
MISPLACED_PLURAL_TPL = _('Misplaced {misplaced_count} items.')

NOT_PLACED_REQUIRED_SINGULAR_TPL = _('Not placed {missing_count} required item.',)
NOT_PLACED_REQUIRED_PLURAL_TPL = _('Not placed {missing_count} required items.')
209 changes: 202 additions & 7 deletions tests/unit/test_advanced.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
# Imports ###########################################################

import ddt
import json
import mock
import random
import unittest

from xblockutils.resources import ResourceLoader

from ..utils import make_block, TestCaseMixin
from drag_and_drop_v2.utils import FeedbackMessages

from ..utils import make_block, TestCaseMixin, generate_max_and_num_attempts


# Globals ###########################################################
Expand Down Expand Up @@ -155,17 +160,147 @@ def test_drop_item_final(self):
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))

def test_do_attempt_not_available(self):
"""
Tests that do_attempt handler returns 400 error for standard mode DnDv2
"""
res = self.call_handler(self.DO_ATTEMPT_HANDLER, expect_json=False)

self.assertEqual(res.status_code, 400)


@ddt.ddt
class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
"""
Common tests for drag and drop in assessment mode
"""
def test_drop_item_in_assessment_mode(self):
item_id, zone_id = 0, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
# In assessment mode, the do_attempt doesn't return any data.
self.assertEqual(res, {})
CORRECT_SOLUTION = {}

@staticmethod
def _make_submission(item_id, zone_id):
x_percent, y_percent = str(random.randint(0, 100)) + '%', str(random.randint(0, 100)) + '%'
data = {"val": item_id, "zone": zone_id, "x_percent": x_percent, "y_percent": y_percent}
return data

def _submit_solution(self, solution):
for item_id, zone_id in solution.iteritems():
data = self._make_submission(item_id, zone_id)
self.call_handler(self.DROP_ITEM_HANDLER, data)

def _submit_complete_solution(self):
self._submit_solution(self.CORRECT_SOLUTION)

def _reset_problem(self):
self.call_handler(self.RESET_HANDLER, data={})
self.assertEqual(self.block.item_state, {})

def test_multiple_drop_item(self):
item_zone_map = {0: self.ZONE_1, 1: self.ZONE_2}
for item_id, zone_id in item_zone_map.iteritems():
data = self._make_submission(item_id, zone_id)
x_percent, y_percent = data['x_percent'], data['y_percent']
res = self.call_handler(self.DROP_ITEM_HANDLER, data)

self.assertEqual(res, {})

expected_item_state = {'zone': zone_id, 'correct': True, 'x_percent': x_percent, 'y_percent': y_percent}

self.assertIn(str(item_id), self.block.item_state)
self.assertEqual(self.block.item_state[str(item_id)], expected_item_state)

# make sure item_state is appended to, not reset
for item_id in item_zone_map:
self.assertIn(str(item_id), self.block.item_state)

@ddt.data(
(None, 10, False),
(0, 12, False),
*(generate_max_and_num_attempts())
)
@ddt.unpack
def test_do_attempt_validation(self, max_attempts, num_attempts, expect_validation_error):
self.block.max_attempts = max_attempts
self.block.num_attempts = num_attempts
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={}, expect_json=False)

if expect_validation_error:
self.assertEqual(res.status_code, 409)
else:
self.assertEqual(res.status_code, 200)

@ddt.data(*[random.randint(0, 100) for _ in xrange(10)])
def test_do_attempt_raises_number_of_attempts(self, num_attempts):
self.block.num_attempts = num_attempts
self.block.max_attempts = num_attempts + 1

res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertEqual(self.block.num_attempts, num_attempts + 1)
self.assertEqual(res['num_attempts'], self.block.num_attempts)

def test_do_attempt_correct_mark_complete_and_publish_grade(self):
self._submit_complete_solution()

with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})

self.assertTrue(self.block.completed)
patched_publish.assert_called_once_with(self.block, 'grade', {
'value': self.block.weight,
'max_value': self.block.weight,
})

def test_do_attempt_incorrect_publish_grade(self):
correctness = self._submit_partial_solution()

with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})

self.assertFalse(self.block.completed)
patched_publish.assert_called_once_with(self.block, 'grade', {
'value': self.block.weight * correctness,
'max_value': self.block.weight,
})

def test_do_attempt_post_correct_no_publish_grade(self):
for item_id, zone_id in self.CORRECT_SOLUTION.iteritems():
data = self._make_submission(item_id, zone_id)
self.call_handler(self.DROP_ITEM_HANDLER, data)

self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) # sets self.complete

self._reset_problem()

with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})

self.assertTrue(self.block.completed)
self.assertFalse(patched_publish.called)

def test_do_attempt_incorrect_final_attempt_publish_grade(self):
self.block.max_attempts = 5
self.block.num_attempts = 4

correctness = self._submit_partial_solution()
expected_grade = self.block.weight * correctness

with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})

self.assertTrue(self.block.completed)
patched_publish.assert_called_once_with(self.block, 'grade', {
'value': expected_grade,
'max_value': self.block.weight,
})

expected_grade_feedback = FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=expected_grade)
self.assertIn(expected_grade_feedback, res['feedback'])

def test_do_attempt_misplaced_ids(self):
misplaced_ids = self._submit_incorrect_solution()

res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertTrue(res['misplaced_items'], misplaced_ids)
self.assertIn(FeedbackMessages.MISPLACED_ITEMS_RETURNED, res['feedback'])


class TestDragAndDropHtmlData(StandardModeFixture, unittest.TestCase):
Expand Down Expand Up @@ -215,10 +350,70 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
ZONE_1 = "zone-1"
ZONE_2 = "zone-2"

CORRECT_SOLUTION = {
0: ZONE_1,
1: ZONE_2
}

FEEDBACK = {
0: {"correct": "Yes 1", "incorrect": "No 1"},
1: {"correct": "Yes 2", "incorrect": "No 2"},
2: {"correct": "", "incorrect": ""}
}

FINAL_FEEDBACK = "This is the final feedback."

def _submit_partial_solution(self):
self._submit_solution({0: self.ZONE_1})
return 0.5

def _submit_incorrect_solution(self):
self._submit_solution({0: self.ZONE_2, 1: self.ZONE_1})
self.call_handler(self.DROP_ITEM_HANDLER, self._make_submission(1, self.ZONE_1))
self.call_handler(self.DROP_ITEM_HANDLER, self._make_submission(0, self.ZONE_2))
return 0, 1

def test_do_attempt_feedback_incorrect(self):
self._submit_solution({0: self.ZONE_2, 1: self.ZONE_1})

res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_misplaced = FeedbackMessages.MISPLACED_PLURAL_TPL.format(misplaced_count=2)
self.assertIn(expected_misplaced, res['feedback'])

def test_do_attempt_feedback_incorrect_not_placed(self):
self._submit_solution({0: self.ZONE_2})
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_misplaced = FeedbackMessages.MISPLACED_SINGULAR_TPL.format(misplaced_count=1)
expected_not_placed = FeedbackMessages.NOT_PLACED_REQUIRED_SINGULAR_TPL.format(missing_count=1)
self.assertIn(expected_misplaced, res['feedback'])
self.assertIn(expected_not_placed, res['feedback'])

def test_do_attempt_feedback_not_placed(self):
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_not_placed = FeedbackMessages.NOT_PLACED_REQUIRED_PLURAL_TPL.format(missing_count=2)
self.assertIn(expected_not_placed, res['feedback'])

def test_do_attempt_feedback_correct_and_decoy(self):
self._submit_solution({0: self.ZONE_1, 1:self.ZONE_2, 2: self.ZONE_2}) # incorrect solution - decoy placed
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_misplaced = FeedbackMessages.MISPLACED_SINGULAR_TPL.format(misplaced_count=1)
expected_correct = FeedbackMessages.CORRECTLY_PLACED_PLURAL_TPL.format(correct_count=2)
self.assertIn(expected_misplaced, res['feedback'])
self.assertIn(expected_correct, res['feedback'])
self.assertIn(FeedbackMessages.MISPLACED_ITEMS_RETURNED, res['feedback'])

def test_do_attempt_feedback_correct(self):
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2}) # correct solution
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_correct = FeedbackMessages.CORRECTLY_PLACED_PLURAL_TPL.format(correct_count=2)
self.assertIn(expected_correct, res['feedback'])
self.assertNotIn(FeedbackMessages.MISPLACED_ITEMS_RETURNED, res['feedback'])

def test_do_attempt_feedback_partial(self):
self._submit_solution({0: self.ZONE_1}) # partial solution
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_correct = FeedbackMessages.CORRECTLY_PLACED_SINGULAR_TPL.format(correct_count=1)
expected_missing = FeedbackMessages.NOT_PLACED_REQUIRED_SINGULAR_TPL.format(missing_count=1)
self.assertIn(expected_correct, res['feedback'])
self.assertIn(expected_missing, res['feedback'])
self.assertNotIn(FeedbackMessages.MISPLACED_ITEMS_RETURNED, res['feedback'])
Loading

0 comments on commit 811f5a1

Please sign in to comment.