Skip to content

Commit 44e7764

Browse files
authored
chore: move progress to xblock (#880)
1 parent 1126a52 commit 44e7764

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed

xblock/progress.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
Progress class for blocks. Represents where a student is in a block.
3+
4+
For most subclassing needs, you should only need to reimplement
5+
frac() and __str__().
6+
"""
7+
8+
import numbers
9+
10+
11+
class Progress:
12+
"""Represents a progress of a/b (a out of b done)
13+
14+
a and b must be numeric, but not necessarily integer, with
15+
0 <= a <= b and b > 0.
16+
17+
Progress can only represent Progress for blocks where that makes sense. Other
18+
blocks (e.g. html) should return None from get_progress().
19+
20+
TODO: add tag for module type? Would allow for smarter merging.
21+
"""
22+
23+
def __init__(self, a, b):
24+
"""Construct a Progress object. a and b must be numbers, and must have
25+
0 <= a <= b and b > 0
26+
"""
27+
28+
# Want to do all checking at construction time, so explicitly check types
29+
if not (isinstance(a, numbers.Number) and isinstance(b, numbers.Number)):
30+
raise TypeError(f"a and b must be numbers. Passed {a}/{b}")
31+
32+
a = min(a, b)
33+
a = max(a, 0)
34+
35+
if b <= 0:
36+
raise ValueError(f"fraction a/b = {a}/{b} must have b > 0")
37+
38+
self._a = a
39+
self._b = b
40+
41+
def frac(self):
42+
"""Return tuple (a,b) representing progress of a/b"""
43+
return (self._a, self._b)
44+
45+
def percent(self):
46+
"""Returns a percentage progress as a float between 0 and 100.
47+
48+
subclassing note: implemented in terms of frac(), assumes sanity
49+
checking is done at construction time.
50+
"""
51+
(a, b) = self.frac()
52+
return 100.0 * a / b
53+
54+
def started(self):
55+
"""Returns True if fractional progress is greater than 0.
56+
57+
subclassing note: implemented in terms of frac(), assumes sanity
58+
checking is done at construction time.
59+
"""
60+
return self.frac()[0] > 0
61+
62+
def inprogress(self):
63+
"""Returns True if fractional progress is strictly between 0 and 1.
64+
65+
subclassing note: implemented in terms of frac(), assumes sanity
66+
checking is done at construction time.
67+
"""
68+
(a, b) = self.frac()
69+
return 0 < a < b
70+
71+
def done(self):
72+
"""Return True if this represents done.
73+
74+
subclassing note: implemented in terms of frac(), assumes sanity
75+
checking is done at construction time.
76+
"""
77+
(a, b) = self.frac()
78+
return a == b
79+
80+
def ternary_str(self):
81+
"""Return a string version of this progress: either
82+
"none", "in_progress", or "done".
83+
84+
subclassing note: implemented in terms of frac()
85+
"""
86+
(a, b) = self.frac()
87+
if a == 0:
88+
return "none"
89+
if a < b:
90+
return "in_progress"
91+
return "done"
92+
93+
def __eq__(self, other):
94+
"""Two Progress objects are equal if they have identical values.
95+
Implemented in terms of frac()"""
96+
if not isinstance(other, Progress):
97+
return False
98+
(a, b) = self.frac()
99+
(a2, b2) = other.frac()
100+
return a == a2 and b == b2
101+
102+
def __ne__(self, other):
103+
"""The opposite of equal"""
104+
return not self.__eq__(other)
105+
106+
def __str__(self):
107+
"""Return a string representation of this string. Rounds results to
108+
two decimal places, stripping out any trailing zeroes.
109+
110+
subclassing note: implemented in terms of frac().
111+
112+
"""
113+
(a, b) = self.frac()
114+
115+
def display(n):
116+
return f"{n:.2f}".rstrip("0").rstrip(".")
117+
118+
return f"{display(a)}/{display(b)}"
119+
120+
@staticmethod
121+
def add_counts(a, b):
122+
"""Add two progress indicators, assuming that each represents items done:
123+
(a / b) + (c / d) = (a + c) / (b + d).
124+
If either is None, returns the other.
125+
"""
126+
if a is None:
127+
return b
128+
if b is None:
129+
return a
130+
# get numerators + denominators
131+
(n, d) = a.frac()
132+
(n2, d2) = b.frac()
133+
return Progress(n + n2, d + d2)

xblock/test/test_progress.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Module progress tests"""
2+
3+
import unittest
4+
5+
from xblock.progress import Progress
6+
7+
8+
class ProgressTest(unittest.TestCase):
9+
"""Test that basic Progress objects work. A Progress represents a
10+
fraction between 0 and 1.
11+
"""
12+
13+
not_started = Progress(0, 17)
14+
part_done = Progress(2, 6)
15+
half_done = Progress(3, 6)
16+
also_half_done = Progress(1, 2)
17+
done = Progress(7, 7)
18+
19+
def test_create_object(self):
20+
"""Test creating Progress objects with valid and invalid inputs."""
21+
# These should work:
22+
prg1 = Progress(0, 2) # pylint: disable=unused-variable
23+
prg2 = Progress(1, 2) # pylint: disable=unused-variable
24+
prg3 = Progress(2, 2) # pylint: disable=unused-variable
25+
26+
prg4 = Progress(2.5, 5.0) # pylint: disable=unused-variable
27+
prg5 = Progress(3.7, 12.3333) # pylint: disable=unused-variable
28+
29+
# These shouldn't
30+
self.assertRaises(ValueError, Progress, 0, 0)
31+
self.assertRaises(ValueError, Progress, 2, 0)
32+
self.assertRaises(ValueError, Progress, 1, -2)
33+
34+
self.assertRaises(TypeError, Progress, 0, "all")
35+
# check complex numbers just for the heck of it :)
36+
self.assertRaises(TypeError, Progress, 2j, 3)
37+
38+
def test_clamp(self):
39+
"""Test that Progress clamps values to the valid range."""
40+
assert (2, 2) == Progress(3, 2).frac()
41+
assert (0, 2) == Progress((-2), 2).frac()
42+
43+
def test_frac(self):
44+
"""Test that `frac()` returns the numerator and denominator correctly."""
45+
prg = Progress(1, 2)
46+
(a_mem, b_mem) = prg.frac()
47+
assert a_mem == 1
48+
assert b_mem == 2
49+
50+
def test_percent(self):
51+
"""Test that `percent()` returns the correct completion percentage."""
52+
assert self.not_started.percent() == 0
53+
assert round(self.part_done.percent() - 33.33333333333333, 7) >= 0
54+
assert self.half_done.percent() == 50
55+
assert self.done.percent() == 100
56+
57+
assert self.half_done.percent() == self.also_half_done.percent()
58+
59+
def test_started(self):
60+
"""Test that `started()` correctly identifies if progress has begun."""
61+
assert not self.not_started.started()
62+
63+
assert self.part_done.started()
64+
assert self.half_done.started()
65+
assert self.done.started()
66+
67+
def test_inprogress(self):
68+
"""Test that `inprogress()` correctly identifies ongoing progress."""
69+
# only true if working on it
70+
assert not self.done.inprogress()
71+
assert not self.not_started.inprogress()
72+
73+
assert self.part_done.inprogress()
74+
assert self.half_done.inprogress()
75+
76+
def test_done(self):
77+
"""Test that `done()` correctly identifies completed progress."""
78+
assert self.done.done()
79+
assert not self.half_done.done()
80+
assert not self.not_started.done()
81+
82+
def test_str(self):
83+
"""Test that `__str__()` formats progress as 'numerator/denominator' correctly."""
84+
assert str(self.not_started) == "0/17"
85+
assert str(self.part_done) == "2/6"
86+
assert str(self.done) == "7/7"
87+
assert str(Progress(2.1234, 7)) == "2.12/7"
88+
assert str(Progress(2.0034, 7)) == "2/7"
89+
assert str(Progress(0.999, 7)) == "1/7"
90+
91+
def test_add(self):
92+
"""Test the Progress.add_counts() method"""
93+
prg1 = Progress(0, 2)
94+
prg2 = Progress(1, 3)
95+
prg3 = Progress(2, 5)
96+
prg_none = None
97+
98+
def add(a, b):
99+
return Progress.add_counts(a, b).frac()
100+
101+
assert add(prg1, prg1) == (0, 4)
102+
assert add(prg1, prg2) == (1, 5)
103+
assert add(prg2, prg3) == (3, 8)
104+
105+
assert add(prg2, prg_none) == prg2.frac()
106+
assert add(prg_none, prg2) == prg2.frac()
107+
108+
def test_equality(self):
109+
"""Test that comparing Progress objects for equality
110+
works correctly."""
111+
prg1 = Progress(1, 2)
112+
prg2 = Progress(2, 4)
113+
prg3 = Progress(1, 2)
114+
assert prg1 == prg3
115+
assert prg1 != prg2
116+
117+
# Check != while we're at it
118+
assert prg1 != prg2
119+
assert prg1 == prg3

0 commit comments

Comments
 (0)