Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions xblock/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Progress class for blocks. Represents where a student is in a block.

For most subclassing needs, you should only need to reimplement
frac() and __str__().
"""

import numbers


class Progress:
"""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 blocks where that makes sense. Other
blocks (e.g. html) should return None from get_progress().

TODO: add tag for module type? Would allow for smarter merging.
"""

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(f"a and b must be numbers. Passed {a}/{b}")

a = min(a, b)
a = max(a, 0)

if b <= 0:
raise ValueError(f"fraction a/b = {a}/{b} must have b > 0")

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 0 < 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. Rounds results to
two decimal places, stripping out any trailing zeroes.

subclassing note: implemented in terms of frac().

"""
(a, b) = self.frac()

def display(n):
return f"{n:.2f}".rstrip("0").rstrip(".")

return f"{display(a)}/{display(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)
119 changes: 119 additions & 0 deletions xblock/test/test_progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Module progress tests"""

import unittest

from xblock.progress import Progress


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):
"""Test creating Progress objects with valid and invalid inputs."""
# These should work:
prg1 = Progress(0, 2) # pylint: disable=unused-variable
prg2 = Progress(1, 2) # pylint: disable=unused-variable
prg3 = Progress(2, 2) # pylint: disable=unused-variable

prg4 = Progress(2.5, 5.0) # pylint: disable=unused-variable
prg5 = Progress(3.7, 12.3333) # pylint: disable=unused-variable

# These shouldn't
self.assertRaises(ValueError, Progress, 0, 0)
self.assertRaises(ValueError, Progress, 2, 0)
self.assertRaises(ValueError, Progress, 1, -2)

self.assertRaises(TypeError, Progress, 0, "all")
# check complex numbers just for the heck of it :)
self.assertRaises(TypeError, Progress, 2j, 3)

def test_clamp(self):
"""Test that Progress clamps values to the valid range."""
assert (2, 2) == Progress(3, 2).frac()
assert (0, 2) == Progress((-2), 2).frac()

def test_frac(self):
"""Test that `frac()` returns the numerator and denominator correctly."""
prg = Progress(1, 2)
(a_mem, b_mem) = prg.frac()
assert a_mem == 1
assert b_mem == 2

def test_percent(self):
"""Test that `percent()` returns the correct completion percentage."""
assert self.not_started.percent() == 0
assert round(self.part_done.percent() - 33.33333333333333, 7) >= 0
assert self.half_done.percent() == 50
assert self.done.percent() == 100

assert self.half_done.percent() == self.also_half_done.percent()

def test_started(self):
"""Test that `started()` correctly identifies if progress has begun."""
assert not self.not_started.started()

assert self.part_done.started()
assert self.half_done.started()
assert self.done.started()

def test_inprogress(self):
"""Test that `inprogress()` correctly identifies ongoing progress."""
# only true if working on it
assert not self.done.inprogress()
assert not self.not_started.inprogress()

assert self.part_done.inprogress()
assert self.half_done.inprogress()

def test_done(self):
"""Test that `done()` correctly identifies completed progress."""
assert self.done.done()
assert not self.half_done.done()
assert not self.not_started.done()

def test_str(self):
"""Test that `__str__()` formats progress as 'numerator/denominator' correctly."""
assert str(self.not_started) == "0/17"
assert str(self.part_done) == "2/6"
assert str(self.done) == "7/7"
assert str(Progress(2.1234, 7)) == "2.12/7"
assert str(Progress(2.0034, 7)) == "2/7"
assert str(Progress(0.999, 7)) == "1/7"

def test_add(self):
"""Test the Progress.add_counts() method"""
prg1 = Progress(0, 2)
prg2 = Progress(1, 3)
prg3 = Progress(2, 5)
prg_none = None

def add(a, b):
return Progress.add_counts(a, b).frac()

assert add(prg1, prg1) == (0, 4)
assert add(prg1, prg2) == (1, 5)
assert add(prg2, prg3) == (3, 8)

assert add(prg2, prg_none) == prg2.frac()
assert add(prg_none, prg2) == prg2.frac()

def test_equality(self):
"""Test that comparing Progress objects for equality
works correctly."""
prg1 = Progress(1, 2)
prg2 = Progress(2, 4)
prg3 = Progress(1, 2)
assert prg1 == prg3
assert prg1 != prg2

# Check != while we're at it
assert prg1 != prg2
assert prg1 == prg3