8
\$\begingroup\$

I'm creating a simple android app for pre-highschool students which teaches the very basics of addition or multiplication of integers or decimals.

The part of the program in this question is aimed at allowing them to practice without the need of a teacher giving them exercises and checking their results.

Note: This is the first time I m using unit tests so I'm not experienced at unit-testing at all.

The following are of great interest to me:

  1. Unit tests:
  • What should I improve? I don't aim for 100% code coverage since that feels more of a fixation on following blindly a "rule" without the actual need for it (but of course I might be wrong).
  • Since my methods contain random.random() numbers I was thinking of creating tests that test a method millions or billions of times (e.g. assertIsInstance(_int_term(), int) inside a for _ in range(10**8) loop). This would very strongly indicate (but not with absolute certainty) that the methods work correctly. Should I create such tests?
  • Methods with optional parameters, e.g. Terms._int_term(max_val): The parameters are made optional only because I wanted to be able to test the method easily (that is, call it without the need to instantiate Terms() class. If I were not using unit tests, I would simply make it a non static method, and would remove the parameter completely. Is this a bad practice?
  1. Docstrings: Are more docstrings needed? Or do good method names suffice? The reason I didn't create dosctings for everything is that they often go stale when I change code without also updating all the related docstrings.

Of course any other comments unrelated to the above 2 points are also very welcome.


Directory tree

  • project_dir
    • main.py
    • tests
      • test_main
        • test_questionandanswer.py
        • test_terms.py

Code

Main module:

"""
Used for the creation of "questions" for the user,
along with the expected answer (that is, the result of the operation)

Some examples of "questions" and their "answers":

"+2-4" ,        "-2"
"(-2)(+7)",     "-14"
"-1.60-2.04",   "-3.64"

The program should be aimed specifically at teaching
the very basics of addition or multiplication of integers or decimals,
in a scaling difficulty.

Exercises that deviate from those specific concepts should be avoided.
Examples of what should NOT be implemented:

"2-4",          # Deviates (slightly) since it also teaches that 2 == +2
"-2(+7)",       # Deviates (same as example above)
"+2-4(-5)",     # Deviates since it combines addition and multiplication



"""


import decimal
from random import randint, choice, random


class Terms(object):

    MIN_TERMS_COUNT = 2
    MAX_TERMS_COUNT = 3
    TERMS_TYPES = {'int', 'float'}
    MAX_ABS_VALUE = 10

    DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP = {
        1: dict(
            terms_count=2,
            terms_type='int'
        ),
        2: dict(
            terms_count=3,
            terms_type='int'
        ),
        3: dict(
            terms_count=2,
            terms_type='float'
        )
    }

    TOTAL_DIFFICULTY_LVLS = len(DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP)

    def __init__(self, difficulty_lvl):
        self.difficulty_lvl = difficulty_lvl
        self.terms_count = self.DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP[self.difficulty_lvl]['terms_count']
        self.terms_type = self.DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP[self.difficulty_lvl]['terms_type']

    @staticmethod
    def _int_term(max_val=MAX_ABS_VALUE):
        return randint(0, max_val)

    @staticmethod
    def _float_term(max_val=MAX_ABS_VALUE):
        return random() * max_val

    @staticmethod
    def final_term(term_without_sign):
        """
        Creates a single term.

        :param term_without_sign:
        :return: (num)
        """
        sign = choice(['-', '+'])

        if sign == '-':
            final_term = -1 * term_without_sign
        else:
            final_term = term_without_sign

        return final_term

    def all_terms(self):
        lst = []

        if self.terms_type == 'int':
            func = self._int_term
        elif self.terms_type == 'float':
            func = self._float_term
        else:
            raise NotImplemented('{}'.format(self.terms_type))

        for _ in range(self.terms_count):
            term_without_sign = func()
            final_term = self.final_term(term_without_sign=term_without_sign)
            lst.append(final_term)

        return lst


class QuestionAndAnswer(object):
    """
    Based on difficulty and operation type,
    creates terms (either float or ints) which are then rounded to allow
    specific number of decimals.
    """

    OPERATIONS_TYPES = {'addition', 'multiplication'}

    def __init__(self, difficulty_lvl, op_type):
        self.terms_as_numbers = Terms(difficulty_lvl=difficulty_lvl).all_terms()
        self.op_type = op_type

    @staticmethod
    def round_single_term_2_decimals(given_float):

        num = decimal.Decimal(str(given_float))
        return num.quantize(decimal.Decimal('.01'), decimal.ROUND_HALF_UP)

    def terms_to_rounded_numbers(self):
        """
        Ensures all terms have an appropriate number of decimals.

        :return: (list)
        """

        lst = []

        for t in self.terms_as_numbers:

            if isinstance(t, int):
                lst.append(t)

            else:
                t = self.round_single_term_2_decimals(given_float=t)
                lst.append(t)

        return lst

    def terms_as_strings(self):
        lst = []

        for t in self.terms_to_rounded_numbers():

            if t > 0:
                lst.append('+{}'.format(t))

            elif t == 0:
                random_sign = choice(['+', '-'])
                lst.append('{sign}{term}'.format(sign=random_sign, term=t))

            else:
                lst.append(str(t))

        return lst

    def operation_str(self):
        """
        Creates the operation string presented to the user.

        :return: (str)
        """

        terms_as_strings = self.terms_as_strings()

        if self.op_type == 'addition':
            op_str = ''.join(terms_as_strings)

        elif self.op_type == 'multiplication':
            op_str = ''
            for t in terms_as_strings:
                op_str += '({})'.format(t)

        else:
            raise NotImplemented('{}'.format(self.op_type))

        return op_str

    def expected_answer(self):

        if self.op_type == 'addition':

            result = 0
            for t in self.terms_to_rounded_numbers():
                result += t

        elif self.op_type == 'multiplication':

            result = 1
            for t in self.terms_to_rounded_numbers():
                result *= t

        else:
            raise NotImplemented('{}'.format(self.op_type))

        return result


if __name__ == '__main__':

    # VISUAL TESTS
    if 1:

        TOTAL_DIFFICULTY_LVLS = Terms.TOTAL_DIFFICULTY_LVLS

        # --------------------------------------------------
        # all_terms()
        print('\n'+'-'*80)
        print('TERMS')

        def print_all_terms(difficulty_lvl):
            print('\nDifficulty {}'.format(difficulty_lvl))
            # (terms' lists printed for each difficulty lvl)
            terms_lsts_count = 3
            for _ in range(terms_lsts_count):
                print(Terms(difficulty_lvl=difficulty_lvl).all_terms())

        # (tests all difficulties)
        for d in range(1, TOTAL_DIFFICULTY_LVLS + 1):
            print_all_terms(difficulty_lvl=d)

        # --------------------------------------------------
        # operation_str() and answer
        print('\n'+'-'*80)
        print('OPERATION STRING')
        for d in range(1, TOTAL_DIFFICULTY_LVLS + 1):
            print('\nDifficulty: {}'.format(d))

            for operation in QuestionAndAnswer.OPERATIONS_TYPES:

                for _ in range(3):
                    inst = QuestionAndAnswer(difficulty_lvl=d, op_type=operation)
                    question = inst.operation_str()
                    answer = inst.expected_answer()
                    msg = '{q} = {a}'.format(q=question, a=answer)
                    print(msg)

Test module test_terms.py:

from unittest import TestCase


class TestDifficultyMap(TestCase):

    def setUp(self):

        import main

        self.Term = main.Terms
        self.difficulty_dct = self.Term.DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP
        self.terms_counts_found = {v['terms_count'] for v in self.difficulty_dct.values()}
        self.terms_types_found = {v['terms_type'] for v in self.difficulty_dct.values()}

    def test_min_terms_count_DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP(self):

        min_terms_count_allowed = self.Term.MIN_TERMS_COUNT
        min_terms_count_found = min(self.terms_counts_found)

        self.assertGreaterEqual(
            min_terms_count_found, min_terms_count_allowed
        )

    def test_max_terms_count_DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP(self):

        max_terms_count_allowed = self.Term.MAX_TERMS_COUNT
        max_terms_count_found = max(self.terms_counts_found)

        self.assertGreaterEqual(
            max_terms_count_found, max_terms_count_allowed
        )

    def test_terms_types_DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP(self):

        self.assertEquals(
            self.terms_types_found, self.Term.TERMS_TYPES
        )


class TestIntAndFloatTerm(TestCase):

    def setUp(self):

        import main
        self.Term = main.Terms

    # _int_term
    def test__int_term(self):
        self.assertEqual(
            self.Term._int_term(max_val=0), 0
        )

    def test_is_int__int_term(self):
        self.assertIsInstance(
            self.Term._int_term(max_val=1), int
        )

    # _float_term
    def test__float_term(self):
        max_val = 15
        term_val = self.Term._float_term(max_val=max_val)
        self.assertTrue(0 <= term_val <= max_val)

    def test_is_float__float_term(self):
        self.assertIsInstance(
            self.Term._float_term(max_val=1), float
        )


class TestAllTermsMethod(TestCase):

    def setUp(self):

        import main
        self.Term = main.Terms
        self.difficulties_available = self.Term.DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP.keys()

    def test_len_above_min_all_terms(self):
        inst = self.Term(difficulty_lvl=1)

        min_terms_count_allowed = self.Term.MIN_TERMS_COUNT
        terms_lst = inst.all_terms()

        self.assertGreaterEqual(len(terms_lst), min_terms_count_allowed)

    def _contains_x_type_all_terms(self, x_type):
        found_type = False

        for lvl in self.difficulties_available:
            inst = self.Term(difficulty_lvl=lvl)

            terms = inst.all_terms()

            for t in terms:
                if type(t) is x_type:
                    found_type = True

        self.assertTrue(found_type, msg='Did not find {}.'.format(x_type))

    def test_contains_float_all_terms(self):
        self._contains_x_type_all_terms(x_type=float)

    def test_contains_int_all_terms(self):
        self._contains_x_type_all_terms(x_type=int)

Test module test_questionandanswer.py:

from unittest import TestCase


class TestQuestion(TestCase):

    def setUp(self):

        from decimal import Decimal
        from main import QuestionAndAnswer

        self.positive_float_to_expected_2nd_decimal_rounded = {
            0.0049: Decimal('0.00'),
            0: Decimal('0'),
            0.005: Decimal('0.01'),
            2.255: Decimal('2.26'),
            9.999: Decimal('10'),
            0.004: Decimal('0'),
        }

        self.negative_float_to_expected_2nd_decimal_rounded = {
            -k: -v for k, v in self.positive_float_to_expected_2nd_decimal_rounded.items()}

        self.QuestionAndAnswer = QuestionAndAnswer

    def test_positive_round_single_term_2_decimals(self):

        for given_float, expected in self.positive_float_to_expected_2nd_decimal_rounded.items():
            self.assertEqual(self.QuestionAndAnswer.round_single_term_2_decimals(given_float=given_float), expected)

    def test_negative_round_single_term_2_decimals(self):

        for given_float, expected in self.negative_float_to_expected_2nd_decimal_rounded.items():
            self.assertEqual(self.QuestionAndAnswer.round_single_term_2_decimals(given_float=given_float), expected)
\$\endgroup\$
5
\$\begingroup\$

Style

Python has a style guide called PEP 8 which is definitly worth reading and and worth following if you do not have good reasons not to. In you case, your usage of empty lines for instance is not compliant to PEP8. You'll find tools online to check your code compliancy to PEP8 in a automated way if you want to.

Code organisation

You've written your code in classes which is sometimes a good thing. Sometimes, you don't need a class.

In any case, most of the things in the Terms class could be extracted out of it (also removing the need for many self.). By the way, it may be interesting to point out that variables defined at the class levels can be pretty complicated and are not entirely needed and could be simple global variables.

Also, you have variables that are not used at all and you should get rid of them.

Once everything than can be moved out of the class (and renamed) is moved out, you get:

"""
Used for the creation of "questions" for the user,
along with the expected answer (that is, the result of the operation)

Some examples of "questions" and their "answers":

"+2-4" ,        "-2"
"(-2)(+7)",     "-14"
"-1.60-2.04",   "-3.64"

The program should be aimed specifically at teaching
the very basics of addition or multiplication of integers or decimals,
in a scaling difficulty.

Exercises that deviate from those specific concepts should be avoided.
Examples of what should NOT be implemented:

"2-4",          # Deviates (slightly) since it also teaches that 2 == +2
"-2(+7)",       # Deviates (same as example above)
"+2-4(-5)",     # Deviates since it combines addition and multiplication



"""


import decimal
from random import randint, choice, random, seed

MAX_ABS_VALUE = 10

def rand_int_term(max_val=MAX_ABS_VALUE):
    return randint(0, max_val)

def rand_float_term(max_val=MAX_ABS_VALUE):
    return random() * max_val

def create_term(term_without_sign):
    """
    Creates a single term.

    :param term_without_sign:
    :return: (num)
    """
    sign = choice(['-', '+'])

    if sign == '-':
        term = -1 * term_without_sign
    else:
        term = term_without_sign

    return term

DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP = {
    1: dict(
        terms_count=2,
        terms_type='int'
    ),
    2: dict(
        terms_count=3,
        terms_type='int'
    ),
    3: dict(
        terms_count=2,
        terms_type='float'
    )
}

TOTAL_DIFFICULTY_LVLS = len(DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP)


class Terms(object):

    def __init__(self, difficulty_lvl):
        terms_data = DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP[difficulty_lvl]
        self.terms_count = terms_data['terms_count']
        self.terms_type = terms_data['terms_type']

    def all_terms(self):
        lst = []

        if self.terms_type == 'int':
            func = rand_int_term
        elif self.terms_type == 'float':
            func = rand_float_term
        else:
            raise NotImplemented('{}'.format(self.terms_type))

        for _ in range(self.terms_count):
            term_without_sign = func()
            term = create_term(term_without_sign=term_without_sign)
            lst.append(term)

        return lst


class QuestionAndAnswer(object):
    """
    Based on difficulty and operation type,
    creates terms (either float or ints) which are then rounded to allow
    specific number of decimals.
    """

    OPERATIONS_TYPES = {'addition', 'multiplication'}

    def __init__(self, difficulty_lvl, op_type):
        self.terms_as_numbers = Terms(difficulty_lvl=difficulty_lvl).all_terms()
        self.op_type = op_type

    @staticmethod
    def round_single_term_2_decimals(given_float):

        num = decimal.Decimal(str(given_float))
        return num.quantize(decimal.Decimal('.01'), decimal.ROUND_HALF_UP)

    def terms_to_rounded_numbers(self):
        """
        Ensures all terms have an appropriate number of decimals.

        :return: (list)
        """

        lst = []

        for t in self.terms_as_numbers:

            if isinstance(t, int):
                lst.append(t)

            else:
                t = self.round_single_term_2_decimals(given_float=t)
                lst.append(t)

        return lst

    def terms_as_strings(self):
        lst = []

        for t in self.terms_to_rounded_numbers():

            if t > 0:
                lst.append('+{}'.format(t))

            elif t == 0:
                random_sign = choice(['+', '-'])
                lst.append('{sign}{term}'.format(sign=random_sign, term=t))

            else:
                lst.append(str(t))

        return lst

    def operation_str(self):
        """
        Creates the operation string presented to the user.

        :return: (str)
        """

        terms_as_strings = self.terms_as_strings()

        if self.op_type == 'addition':
            op_str = ''.join(terms_as_strings)

        elif self.op_type == 'multiplication':
            op_str = ''
            for t in terms_as_strings:
                op_str += '({})'.format(t)

        else:
            raise NotImplemented('{}'.format(self.op_type))

        return op_str

    def expected_answer(self):

        if self.op_type == 'addition':

            result = 0
            for t in self.terms_to_rounded_numbers():
                result += t

        elif self.op_type == 'multiplication':

            result = 1
            for t in self.terms_to_rounded_numbers():
                result *= t

        else:
            raise NotImplemented('{}'.format(self.op_type))

        return result


if __name__ == '__main__':

    # VISUAL TESTS
    if 1:

        seed(42)  # Tmporal addition from Josay to have same results on different runs

        # --------------------------------------------------
        # all_terms()
        print('\n'+'-'*80)
        print('TERMS')

        def print_all_terms(difficulty_lvl):
            print('\nDifficulty {}'.format(difficulty_lvl))
            # (terms' lists printed for each difficulty lvl)
            terms_lsts_count = 3
            for _ in range(terms_lsts_count):
                print(Terms(difficulty_lvl=difficulty_lvl).all_terms())

        # (tests all difficulties)
        for d in range(1, TOTAL_DIFFICULTY_LVLS + 1):
            print_all_terms(difficulty_lvl=d)

        # --------------------------------------------------
        # operation_str() and answer
        print('\n'+'-'*80)
        print('OPERATION STRING')
        for d in range(1, TOTAL_DIFFICULTY_LVLS + 1):
            print('\nDifficulty: {}'.format(d))

            for operation in QuestionAndAnswer.OPERATIONS_TYPES:

                for _ in range(3):
                    inst = QuestionAndAnswer(difficulty_lvl=d, op_type=operation)
                    question = inst.operation_str()
                    answer = inst.expected_answer()
                    msg = '{q} = {a}'.format(q=question, a=answer)
                    print(msg)

(I just realised that I forgot to extract stuff from QuestionAndAnswer but you get the spirit.)

Avoid useless string manipulation

You have a dictionnary where values are strings and these strings are later one used to retrieve the relevant function. It may be easier to just map keys to functions.

You simply get :

DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP = {
    1: dict(
        terms_count=2,
        terms_type=rand_int_term
    ),
    2: dict(
        terms_count=3,
        terms_type=rand_int_term
    ),
    3: dict(
        terms_count=2,
        terms_type=rand_float_term
    )
}

...

def all_terms(self):
    lst = []
    for _ in range(self.terms_count):
        term_without_sign = self.terms_type()
        term = create_term(term_without_sign=term_without_sign)
        lst.append(term)

(term_type probably needs to be renamed, I'll leave this to you)

List comprehension

You can use list comprehension to rewrite all_terms in a more concise/pythonic way :

def all_terms(self):
    return [ create_term(term_without_sign=self.terms_type())
             for _ in range(self.terms_count)]

Remove duplicated code

In terms_to_rounded_numbers, you have :

        if isinstance(t, int):
            lst.append(t)
        else:
            t = round_single_term_2_decimals(given_float=t)
            lst.append(t)

which could simply be written:

        if not isinstance(t, int):
            t = round_single_term_2_decimals(given_float=t)
        lst.append(t)

But here again, list comprehension can be used:

Using the right data type

At the moment, difficulties are stored in a dictionnary mapping consecutive integers to values. This looks a lot like a list (except that the indexing starts from 1 instead of 0).

Variable names

given_float is a weird parameter name for a function. Maybe float_arg would make more sense.

Avoid string concatenation

From PEP 8:

For example, do not rely on CPython's efficient implementation of in-place string concatenation for statements in the form a += b or a = a + b . This optimization is fragile even in CPython (it only works for some types) and isn't present at all in implementations that don't use refcounting. In performance sensitive parts of the library, the ''.join() form should be used instead. This will ensure that concatenation occurs in linear time across various implementations.

Thus, you could write:

    elif self.op_type == 'multiplication':
        op_str = ''.join('({})'.format(t) for t in terms_as_strings)

Avoid useless string manipulation (again)

In create_term, you call sign = random.choice(['-', '+']) to get the character corresponding to a sign. It may be easier to do:

return term_without_sign * random.choice([-1, 1])

This could also be applied to terms_as_strings but I do not quite understand what you were trying to do.

List comprehension (again)

It would probably be worth extracting the conversion from terms_as_strings in a function on its own and then use it in a list comprehension.

def term_as_string(t):
    if t > 0:
        return '+{}'.format(t)
    elif t == 0:
        random_sign = random.choice(['+', '-'])
        return '{sign}{term}'.format(sign=random_sign, term=t)
    else:
        return str(t)

....

def terms_as_strings(self):
    return [term_as_string(t) for t in self.terms_to_rounded_numbers()]

Avoid string manipulation (again)

At the moment, you have a list of string representing operations types and in a few places, you look if the string you are handling corresponds to any of the string you know and perform the relevant computations. This has multiple problems: you have a lot of repeated code and it is easy to forgot a place if you want to add an operation. It would make sense to use a data structure with all the relevant pieces of information. A tuple and more particularly, a namedtuple seems relevant for this task.

import operator
from collections import namedtuple
Operation = namedtuple('Operation', 'name symbol func neutral format')  # name and symbol do not seems to be useful at the moment

OPERATIONS_TYPES = {
    Operation('multiplication', '*', operator.mul, 1, '{}'),
    Operation('addition',       '+', operator.add, 0, '({})'),
}

...

    def operation_str(self):
        """
        Creates the operation string presented to the user.

        :return: (str)
        """
        terms_as_strings = self.terms_as_strings()
        return ''.join(self.op_type.format.format(t) for t in terms_as_strings)

    def expected_answer(self):
        op = self.op_type
        result = op.neutral
        for t in self.terms_to_rounded_numbers():
            result = op.func(result, t)
        return result

Using the right data type (again with actual solution)

As said before, the fact that DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP is a bit puzzling as you are accessing it using range and getting item at the given index. A list is probably the right thing to use here. Also, this is not really pythonic nor efficient. I'd recommand reading/watching Ned Batchelder's presentation : "Loop Like A Native".

In your case, instead of getting the element at difficulty_lvl in Terms, it may be easier to provide it directly to the constructor. Doing the same thing also in QuestionAndAnswser, you can then simply look over the list. Then, if you do need the index, you can use enumerate.

All this requires many changes everywhere but once done, you can get rid of the TOTAL_DIFFICULTY_LVLS variable.

Remaining code looks like :

"""
Used for the creation of "questions" for the user,
along with the expected answer (that is, the result of the operation)

Some examples of "questions" and their "answers":

"+2-4" ,        "-2"
"(-2)(+7)",     "-14"
"-1.60-2.04",   "-3.64"

The program should be aimed specifically at teaching
the very basics of addition or multiplication of integers or decimals,
in a scaling difficulty.

Exercises that deviate from those specific concepts should be avoided.
Examples of what should NOT be implemented:

"2-4",          # Deviates (slightly) since it also teaches that 2 == +2
"-2(+7)",       # Deviates (same as example above)
"+2-4(-5)",     # Deviates since it combines addition and multiplication



"""


import decimal
import random
import operator
from collections import namedtuple


MAX_ABS_VALUE = 10

def rand_int_term(max_val=MAX_ABS_VALUE):
    return random.randint(0, max_val)

def rand_float_term(max_val=MAX_ABS_VALUE):
    return random.random() * max_val

def create_term(term_without_sign):
    """
    Creates a single term.

    :param term_without_sign:
    :return: (num)
    """
    return term_without_sign * random.choice([-1, 1])

DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP = [
    dict(
        terms_count=2,
        terms_type=rand_int_term
    ),
    dict(
        terms_count=3,
        terms_type=rand_int_term
    ),
    dict(
        terms_count=2,
        terms_type=rand_float_term
    )
]

class Terms(object):

    def __init__(self, difficulty):
        self.terms_count = difficulty['terms_count']
        self.terms_type =  difficulty['terms_type']

    def all_terms(self):
        return [ create_term(term_without_sign=self.terms_type())
                 for _ in range(self.terms_count)]



Operation = namedtuple('Operation', 'name symbol func neutral format')

OPERATIONS_TYPES = {
    Operation('multiplication', '*', operator.mul, 1, '{}'),
    Operation('addition',       '+', operator.add, 0, '({})'),
}

def round_single_term_2_decimals(float_arg):
    num = decimal.Decimal(str(float_arg))
    return num.quantize(decimal.Decimal('.01'), decimal.ROUND_HALF_UP)

def term_as_string(t):
    if t > 0:
        return '+{}'.format(t)
    elif t == 0:
        random_sign = random.choice(['+', '-'])
        return '{sign}{term}'.format(sign=random_sign, term=t)
    else:
        return str(t)


class QuestionAndAnswer(object):
    """
    Based on difficulty and operation type,
    creates terms (either float or ints) which are then rounded to allow
    specific number of decimals.
    """


    def __init__(self, difficulty, op_type):
        self.terms_as_numbers = Terms(difficulty=difficulty).all_terms()
        self.op_type = op_type

    def terms_to_rounded_numbers(self):
        """
        Ensures all terms have an appropriate number of decimals.

        :return: (list)
        """
        return [t if isinstance(t, int) else round_single_term_2_decimals(float_arg=t)
                for t in self.terms_as_numbers]

    def terms_as_strings(self):
        return [term_as_string(t) for t in self.terms_to_rounded_numbers()]

    def operation_str(self):
        """
        Creates the operation string presented to the user.

        :return: (str)
        """
        terms_as_strings = self.terms_as_strings()
        return ''.join(self.op_type.format.format(t) for t in terms_as_strings)

    def expected_answer(self):
        op = self.op_type
        result = op.neutral
        for t in self.terms_to_rounded_numbers():
            result = op.func(result, t)
        return result




if __name__ == '__main__':

    random.seed(42)  # Temporal addition from Josay to have same results on different runs
    newline = '\n'+'-'*80

    # --------------------------------------------------
    # all_terms()
    print(newline)
    print('TERMS')

    # (tests all difficulties)
    for i, difficulty in enumerate(DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP, start=1):
        print('\nDifficulty {}'.format(i))
        for _ in range(3):
             print(Terms(difficulty=difficulty).all_terms())

    # --------------------------------------------------
    # operation_str() and answer
    print(newline)
    print('OPERATION STRING')
    for i, difficulty in enumerate(DIFFICULTY_TO_TERMS_COUNT_AND_OP_TYPE_MAP, start=1):
        print('\nDifficulty: {}'.format(i))
        for operation in OPERATIONS_TYPES:
            for _ in range(3):
                inst = QuestionAndAnswer(difficulty=difficulty, op_type=operation)
                question = inst.operation_str()
                answer = inst.expected_answer()
                msg = '{q} = {a}'.format(q=question, a=answer)
                print(msg)

*Many more things could be said but I have to go. Regarding your testing questions, you might be interested in the Hypothesis testing framework.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ I'm using PyCharm and thankfully it guides me in the PEP8 direction most of the time; i do agree that some of my empty lines are redundant though. The namedtuple() suggestion is amazing since it forces specific keys and allows easy "lookup", and i think can be used for the DIFFICULTY_MAP elements as well. Unused vars and duplicate code was an issue as you pointed out. I do have some objections on string join() though, comprehensions instead of for loops, and converting DIFFICULTY_MAP to a list. It feels like premature optimization in some cases and [..TO BE CONTINUED] \$\endgroup\$ – user Jul 22 '16 at 17:22
  • \$\begingroup\$ ... going to somewhat less readable code. E.g. my top priority is having readable code which string concatenation accomplishes better than join(). Although I do agree that in general the latter will be faster, but specifically for my use case I doubt any of the Term-creation functionality would be a bottleneck (additionally user will be spending a lot of time between requesting new terms). \$\endgroup\$ – user Jul 22 '16 at 17:28
  • \$\begingroup\$ I m uncomfortable with DIFFICULTY_MAP suggestion since it contains the implicit assumption that difficulties are ordered, e.g. DIFFICULTY_MAP[0] corresponds to difficulty_lvl==1. \$\endgroup\$ – user Jul 22 '16 at 17:37
  • \$\begingroup\$ Regarding your last point, the idea is that most places do not even need a concept of difficulty level. If you need your levels not not to be consecutive, you don't have that many places to change. Also, because you were looping with range, your code had the assumption that difficulty levels were consecutive and I somehow got rid of it. \$\endgroup\$ – SylvainD Jul 22 '16 at 17:50
  • \$\begingroup\$ As for the join, I guess it becomes a matter of personal preference and of habit. I quite like that with a single glance, I can think "OK we're building a string by joining multiple strings". (The fact that it may also be faster is only a nice benefit) \$\endgroup\$ – SylvainD Jul 22 '16 at 17:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.