From fd48e49ae520c6400a18ebcd55a894534c02d13f Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Tue, 19 Jun 2012 18:15:34 -0400 Subject: [PATCH] Add Progress class and tests --- common/lib/xmodule/progress.py | 124 +++++++++++++++++++++++++++++++++ common/lib/xmodule/tests.py | 120 ++++++++++++++++++++++++++++++- common/lib/xmodule/x_module.py | 11 +++ 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/progress.py diff --git a/common/lib/xmodule/progress.py b/common/lib/xmodule/progress.py new file mode 100644 index 0000000000..1ce5d821f3 --- /dev/null +++ b/common/lib/xmodule/progress.py @@ -0,0 +1,124 @@ +''' +Progress class for modules. Represents where a student is in a module. +''' + +from collections import namedtuple +import numbers + +class Progress(object): + '''Represents a progress of a/b (a out of b done) + + a and b must be numeric, but not necessarily integer, with + 0 <= a <= b and b > 0. + + Progress can only represent Progress for modules where that makes sense. Other + modules (e.g. html) should return None from get_progress(). + ''' + + def __init__(self, a, b): + '''Construct a Progress object. a and b must be numbers, and must have + 0 <= a <= b and b > 0 + ''' + + # Want to do all checking at construction time, so explicitly check types + if not (isinstance(a, numbers.Number) and + isinstance(b, numbers.Number)): + raise TypeError('a and b must be numbers. Passed {0}/{1}'.format(a, b)) + + if not (0 <= a <= b and b > 0): + raise ValueError( + 'fraction a/b = {0}/{1} must have 0 <= a <= b and b > 0'.format(a, b)) + + self._a = a + self._b = b + + def frac(self): + ''' Return tuple (a,b) representing progress of a/b''' + return (self._a, self._b) + + def percent(self): + ''' Returns a percentage progress as a float between 0 and 100. + + subclassing note: implemented in terms of frac(), assumes sanity + checking is done at construction time. + ''' + (a, b) = self.frac() + return 100.0 * a / b + + def started(self): + ''' Returns True if fractional progress is greater than 0. + + subclassing note: implemented in terms of frac(), assumes sanity + checking is done at construction time. + ''' + return self.frac()[0] > 0 + + + def inprogress(self): + ''' Returns True if fractional progress is strictly between 0 and 1. + + subclassing note: implemented in terms of frac(), assumes sanity + checking is done at construction time. + ''' + (a, b) = self.frac() + return a > 0 and a < b + + def done(self): + ''' Return True if this represents done. + + subclassing note: implemented in terms of frac(), assumes sanity + checking is done at construction time. + ''' + (a, b) = self.frac() + return a==b + + + def ternary_str(self): + ''' Return a string version of this progress: either + "none", "in_progress", or "done". + + subclassing note: implemented in terms of frac() + ''' + (a, b) = self.frac() + if a == 0: + return "none" + if a < b: + return "in_progress" + return "done" + + def __eq__(self, other): + ''' Two Progress objects are equal if they have identical values. + Implemented in terms of frac()''' + if not isinstance(other, Progress): + return False + (a, b) = self.frac() + (a2, b2) = other.frac() + return a == a2 and b == b2 + + def __ne__(self, other): + ''' The opposite of equal''' + return not self.__eq__(other) + + + def __str__(self): + ''' Return a string representation of this string. + + subclassing note: implemented in terms of frac(). + ''' + (a, b) = self.frac() + return "{0}/{1}".format(a, b) + + @staticmethod + def add_counts(a, b): + '''Add two progress indicators, assuming that each represents items done: + (a / b) + (c / d) = (a + c) / (b + d). + If either is None, returns the other. + ''' + if a is None: + return b + if b is None: + return a + # get numerators + denominators + (n, d) = a.frac() + (n2, d2) = b.frac() + return Progress(n + n2, d + d2) diff --git a/common/lib/xmodule/tests.py b/common/lib/xmodule/tests.py index 370b3befe5..73096ce8da 100644 --- a/common/lib/xmodule/tests.py +++ b/common/lib/xmodule/tests.py @@ -13,8 +13,9 @@ import numpy import xmodule import capa.calc as calc import capa.capa_problem as lcp -from xmodule import graders +from xmodule import graders, x_module from xmodule.graders import Score, aggregate_scores +from xmodule.progress import Progress from nose.plugins.skip import SkipTest class I4xSystem(object): @@ -26,7 +27,9 @@ class I4xSystem(object): def __init__(self): self.ajax_url = '/' self.track_function = lambda x: None + self.filestore = None self.render_function = lambda x: {} # Probably incorrect + self.module_from_xml = lambda x: None # May need a real impl... self.exception404 = Exception self.DEBUG = True def __repr__(self): @@ -488,3 +491,118 @@ class GraderTest(unittest.TestCase): #TODO: How do we test failure cases? The parser only logs an error when it can't parse something. Maybe it should throw exceptions? +# -------------------------------------------------------------------------- +# Module progress tests + +class ProgressTest(unittest.TestCase): + ''' Test that basic Progress objects work. A Progress represents a + fraction between 0 and 1. + ''' + not_started = Progress(0, 17) + part_done = Progress(2, 6) + half_done = Progress(3, 6) + also_half_done = Progress(1, 2) + done = Progress(7, 7) + + def test_create_object(self): + # These should work: + p = Progress(0, 2) + p = Progress(1, 2) + p = Progress(2, 2) + + p = Progress(2.5, 5.0) + p = Progress(3.7, 12.3333) + + # These shouldn't + self.assertRaises(ValueError, Progress, 0, 0) + self.assertRaises(ValueError, Progress, 2, 0) + self.assertRaises(ValueError, Progress, 1, -2) + self.assertRaises(ValueError, Progress, 3, 2) + self.assertRaises(ValueError, Progress, -2, 5) + + self.assertRaises(TypeError, Progress, 0, "all") + # check complex numbers just for the heck of it :) + self.assertRaises(TypeError, Progress, 2j, 3) + + def test_frac(self): + p = Progress(1, 2) + (a, b) = p.frac() + self.assertEqual(a, 1) + self.assertEqual(b, 2) + + def test_percent(self): + self.assertEqual(self.not_started.percent(), 0) + self.assertAlmostEqual(self.part_done.percent(), 33.33333333333333) + self.assertEqual(self.half_done.percent(), 50) + self.assertEqual(self.done.percent(), 100) + + self.assertEqual(self.half_done.percent(), self.also_half_done.percent()) + + def test_started(self): + self.assertFalse(self.not_started.started()) + + self.assertTrue(self.part_done.started()) + self.assertTrue(self.half_done.started()) + self.assertTrue(self.done.started()) + + def test_inprogress(self): + # only true if working on it + self.assertFalse(self.done.inprogress()) + self.assertFalse(self.not_started.inprogress()) + + self.assertTrue(self.part_done.inprogress()) + self.assertTrue(self.half_done.inprogress()) + + def test_done(self): + self.assertTrue(self.done.done()) + self.assertFalse(self.half_done.done()) + self.assertFalse(self.not_started.done()) + + def test_str(self): + self.assertEqual(str(self.not_started), "0/17") + self.assertEqual(str(self.part_done), "2/6") + self.assertEqual(str(self.done), "7/7") + + def test_ternary_str(self): + self.assertEqual(self.not_started.ternary_str(), "none") + self.assertEqual(self.half_done.ternary_str(), "in_progress") + self.assertEqual(self.done.ternary_str(), "done") + + def test_add(self): + '''Test the Progress.add_counts() method''' + p = Progress(0, 2) + p2 = Progress(1, 3) + p3 = Progress(2, 5) + pNone = None + add = lambda a, b: Progress.add_counts(a, b).frac() + + self.assertEqual(add(p, p), (0, 4)) + self.assertEqual(add(p, p2), (1, 5)) + self.assertEqual(add(p2, p3), (3, 8)) + + self.assertEqual(add(p2, pNone), p2.frac()) + self.assertEqual(add(pNone, p2), p2.frac()) + + def test_equality(self): + '''Test that comparing Progress objects for equality + works correctly.''' + p = Progress(1, 2) + p2 = Progress(2, 4) + p3 = Progress(1, 2) + self.assertTrue(p == p3) + self.assertFalse(p == p2) + + # Check != while we're at it + self.assertTrue(p != p2) + self.assertFalse(p != p3) + + +class ModuleProgressTest(unittest.TestCase): + ''' Test that get_progress() does the right thing for the different modules + ''' + def test_xmodule_default(self): + '''Make sure default get_progress exists, returns None''' + xm = x_module.XModule(i4xs, "", "dummy") + p = xm.get_progress() + self.assertEqual(p, None) + diff --git a/common/lib/xmodule/x_module.py b/common/lib/xmodule/x_module.py index 9f960843d9..3a5bd05286 100644 --- a/common/lib/xmodule/x_module.py +++ b/common/lib/xmodule/x_module.py @@ -1,7 +1,9 @@ from lxml import etree import pkg_resources import logging + from keystore import Location +from progress import Progress log = logging.getLogger('mitx.' + __name__) @@ -118,6 +120,15 @@ class XModule(object): ''' return "Unimplemented" + def get_progress(self): + ''' Return a progress.Progress object that represents how far the student has gone + in this module. Must be implemented to get correct progress tracking behavior in + nesting modules like sequence and vertical. + + If this module has no notion of progress, return None. + ''' + return None + def handle_ajax(self, dispatch, get): ''' dispatch is last part of the URL. get is a dictionary-like object '''