rewrite heuristics for which fields of bugs should change when they are inconsistent
authorJacob Lifshay <programmerjake@gmail.com>
Sat, 5 Sep 2020 02:26:45 +0000 (19:26 -0700)
committerJacob Lifshay <programmerjake@gmail.com>
Sat, 5 Sep 2020 02:26:45 +0000 (19:26 -0700)
src/budget_sync/budget_graph.py
src/budget_sync/test/test_budget_graph.py

index cb77a228837529ff879dfa735db382f290ab885a..1ebcd509a9ec3dd07d0259535436aa6e75626dfa 100644 (file)
@@ -6,6 +6,7 @@ from budget_sync.money import Money
 from functools import cached_property
 import toml
 import sys
+from collections import deque
 
 
 class BudgetGraphBaseError(Exception):
@@ -55,6 +56,8 @@ class Node:
     immediate_children: Set["Node"]
     budget_excluding_subtasks: Money
     budget_including_subtasks: Money
+    fixed_budget_excluding_subtasks: Money
+    fixed_budget_including_subtasks: Money
     nlnet_milestone: Optional[str]
 
     def __init__(self, graph: "BudgetGraph", bug: Bug):
@@ -63,7 +66,9 @@ class Node:
         self.parent_id = getattr(bug, "cf_budget_parent", None)
         self.immediate_children = set()
         self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
+        self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks
         self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
+        self.fixed_budget_including_subtasks = self.budget_including_subtasks
         self.nlnet_milestone = bug.cf_nlnet_milestone
         if self.nlnet_milestone == "---":
             self.nlnet_milestone = None
@@ -133,6 +138,16 @@ class Node:
                 yield from visitor(i)
         return visitor(self)
 
+    def children_breadth_first(self) -> Iterable["Node"]:
+        q = deque(self.immediate_children)
+        while True:
+            try:
+                node = q.popleft()
+            except IndexError:
+                return
+            q.extend(node.immediate_children)
+            yield node
+
     def __eq__(self, other):
         return self.bug.id == other.bug.id
 
@@ -158,6 +173,8 @@ class Node:
                 f"parent={parent}, "
                 f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
                 f"budget_including_subtasks={self.budget_including_subtasks}, "
+                f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
+                f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
                 f"nlnet_milestone={self.nlnet_milestone!r}, "
                 f"immediate_children={immediate_children!r}, "
                 f"payees={self.payees!r}")
@@ -183,7 +200,7 @@ class BudgetGraphMilestoneMismatch(BudgetGraphError):
                 f" #{self.root_bug_id}")
 
 
-class BudgetGraphMoneyMismatch(BudgetGraphError):
+class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError):
     def __init__(self, bug_id: int, root_bug_id: int,
                  expected_budget_excluding_subtasks: Money):
         super().__init__(bug_id, root_bug_id)
@@ -197,6 +214,20 @@ class BudgetGraphMoneyMismatch(BudgetGraphError):
                 f" {self.expected_budget_excluding_subtasks}")
 
 
+class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int,
+                 expected_budget_including_subtasks: Money):
+        super().__init__(bug_id, root_bug_id)
+        self.expected_budget_including_subtasks = \
+            expected_budget_including_subtasks
+
+    def __str__(self):
+        return (f"Budget assigned to task including subtasks "
+                f"(cf_total_budget field) doesn't match calculated value: "
+                f"bug #{self.bug_id}, calculated value"
+                f" {self.expected_budget_including_subtasks}")
+
+
 class BudgetGraphNegativeMoney(BudgetGraphError):
     def __str__(self):
         return (f"Budget assigned to task is less than zero: "
@@ -204,16 +235,17 @@ class BudgetGraphNegativeMoney(BudgetGraphError):
 
 
 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
-    def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money):
+    def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money,
+                 expected_payees_total: Money):
         super().__init__(bug_id, root_bug_id)
         self.payees_total = payees_total
+        self.expected_payees_total = expected_payees_total
 
     def __str__(self):
-        return (f"Budget assigned to task excluding subtasks "
-                f"(cf_budget field) doesn't match total value "
-                f"assigned to payees (cf_payees_list): "
-                f"bug #{self.bug_id}, calculated total"
-                f" {self.payees_total}")
+        return (f"Total budget assigned to payees (cf_payees_list) doesn't "
+                f"match expected value: bug #{self.bug_id}, calculated total "
+                f"{self.payees_total}, expected value "
+                f"{self.expected_payees_total}")
 
 
 class BudgetGraphNegativePayeeMoney(BudgetGraphError):
@@ -254,29 +286,135 @@ class BudgetGraph:
                     or node.budget_excluding_subtasks != 0:
                 errors.append(BudgetGraphMoneyWithNoMilestone(
                     node.bug.id, root.bug.id))
+
         if node.nlnet_milestone != root.nlnet_milestone:
             errors.append(BudgetGraphMilestoneMismatch(
                 node.bug.id, root.bug.id))
+
         if node.budget_excluding_subtasks < 0 \
                 or node.budget_including_subtasks < 0:
             errors.append(BudgetGraphNegativeMoney(
                 node.bug.id, root.bug.id))
-        budget = node.budget_including_subtasks
+
+        subtasks_total = Money(0)
         for child in node.immediate_children:
-            budget -= child.budget_including_subtasks
-        if node.budget_excluding_subtasks != budget:
-            errors.append(BudgetGraphMoneyMismatch(
-                node.bug.id, root.bug.id, budget))
+            subtasks_total += child.fixed_budget_including_subtasks
+
         payees_total = Money(0)
         for payee_key, payee_value in node.payees.items():
             if payee_value < 0:
                 errors.append(BudgetGraphNegativePayeeMoney(
                     node.bug.id, root.bug.id, payee_key))
             payees_total += payee_value
-        if node.budget_excluding_subtasks != payees_total \
-                and len(node.payees) != 0:
+
+        def set_including_from_excluding_and_error():
+            node.fixed_budget_including_subtasks = \
+                node.budget_excluding_subtasks + subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_including_subtasks))
+
+        def set_including_from_payees_and_error():
+            node.fixed_budget_including_subtasks = \
+                payees_total + subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_including_subtasks))
+
+        def set_excluding_from_including_and_error():
+            node.fixed_budget_excluding_subtasks = \
+                node.budget_including_subtasks - subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_excluding_subtasks))
+
+        def set_excluding_from_payees_and_error():
+            node.fixed_budget_excluding_subtasks = \
+                payees_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_excluding_subtasks))
+
+        def set_payees_from_including_and_error():
+            fixed_payees_total = \
+                node.budget_including_subtasks - subtasks_total
             errors.append(BudgetGraphPayeesMoneyMismatch(
-                node.bug.id, root.bug.id, payees_total))
+                node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+        def set_payees_from_excluding_and_error():
+            fixed_payees_total = \
+                node.budget_excluding_subtasks
+            errors.append(BudgetGraphPayeesMoneyMismatch(
+                node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+        payees_matches_including = \
+            node.budget_including_subtasks - subtasks_total == payees_total
+        payees_matches_excluding = \
+            node.budget_excluding_subtasks == payees_total
+        including_matches_excluding = \
+            node.budget_including_subtasks - subtasks_total \
+            == node.budget_excluding_subtasks
+
+        if payees_matches_including \
+                and payees_matches_excluding \
+                and including_matches_excluding:
+            pass  # no error
+        elif payees_matches_including:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_excluding
+            assert not including_matches_excluding
+            if node.budget_including_subtasks == 0 and len(node.payees) == 0:
+                set_including_from_excluding_and_error()
+            else:
+                set_excluding_from_including_and_error()
+        elif payees_matches_excluding:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_including
+            assert not including_matches_excluding
+            if node.budget_excluding_subtasks == 0 and len(node.payees) == 0:
+                if node.budget_including_subtasks == 0:
+                    set_including_from_excluding_and_error()
+                else:
+                    set_excluding_from_including_and_error()
+            else:
+                set_including_from_excluding_and_error()
+        elif including_matches_excluding:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_including
+            assert not payees_matches_excluding
+            if len(node.payees) == 0:
+                pass  # no error -- payees is just not set
+            elif node.budget_excluding_subtasks == 0 \
+                    and node.budget_including_subtasks == 0:
+                set_excluding_from_payees_and_error()
+                set_including_from_payees_and_error()
+            else:
+                set_payees_from_excluding_and_error()
+        else:
+            # nothing matches
+            if len(node.payees) == 0:
+                # payees unset -- don't need to set payees
+                if node.budget_including_subtasks == 0:
+                    set_including_from_excluding_and_error()
+                else:
+                    set_excluding_from_including_and_error()
+            elif node.budget_excluding_subtasks == 0 \
+                    and node.budget_including_subtasks == 0:
+                set_excluding_from_payees_and_error()
+                set_including_from_payees_and_error()
+            elif node.budget_excluding_subtasks == 0:
+                set_excluding_from_including_and_error()
+                set_payees_from_including_and_error()
+            elif node.budget_including_subtasks == 0:
+                set_including_from_excluding_and_error()
+                set_payees_from_excluding_and_error()
+            else:
+                set_including_from_excluding_and_error()
+                set_payees_from_excluding_and_error()
 
     def get_errors(self) -> List[BudgetGraphBaseError]:
         errors = []
@@ -288,12 +426,12 @@ class BudgetGraph:
 
         for root in roots:
             try:
-                self._get_node_errors(root, root, errors)
-                for child in root.children():
+                for child in reversed(list(root.children_breadth_first())):
                     try:
                         self._get_node_errors(root, child, errors)
                     except BudgetGraphBaseError as e:
                         errors.append(e)
+                self._get_node_errors(root, root, errors)
             except BudgetGraphBaseError as e:
                 errors.append(e)
         return errors
index 932d48efd0cc5a93a138f4d6a93e3caef0bf47ef..16c107d9d976644a88de0afa9b3073352ee35d2c 100644 (file)
@@ -2,7 +2,8 @@ from budget_sync.test.mock_bug import MockBug
 from budget_sync.budget_graph import (BudgetGraphLoopError, BudgetGraph,
                                       Node, BudgetGraphMoneyWithNoMilestone,
                                       BudgetGraphBaseError,
-                                      BudgetGraphMoneyMismatch,
+                                      BudgetGraphMoneyMismatchForBudgetExcludingSubtasks,
+                                      BudgetGraphMoneyMismatchForBudgetIncludingSubtasks,
                                       BudgetGraphNegativeMoney,
                                       BudgetGraphMilestoneMismatch,
                                       BudgetGraphNegativePayeeMoney,
@@ -33,10 +34,18 @@ class TestErrorFormatting(unittest.TestCase):
                          "bug #1, root bug #5")
 
     def test_budget_graph_money_mismatch(self):
-        self.assertEqual(str(BudgetGraphMoneyMismatch(1, 5, "123.4")),
-                         "Budget assigned to task excluding subtasks "
-                         "(cf_budget field) doesn't match calculated value:"
-                         " bug #1, calculated value 123.4")
+        self.assertEqual(str(
+            BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                1, 5, "123.4")),
+            "Budget assigned to task excluding subtasks "
+            "(cf_budget field) doesn't match calculated value:"
+            " bug #1, calculated value 123.4")
+        self.assertEqual(str(
+            BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                1, 5, "123.4")),
+            "Budget assigned to task including subtasks "
+            "(cf_total_budget field) doesn't match calculated value:"
+            " bug #1, calculated value 123.4")
 
     def test_budget_graph_negative_money(self):
         self.assertEqual(str(BudgetGraphNegativeMoney(1, 5)),
@@ -55,10 +64,9 @@ class TestErrorFormatting(unittest.TestCase):
 
     def test_budget_graph_payees_money_mismatch(self):
         self.assertEqual(str(
-            BudgetGraphPayeesMoneyMismatch(1, 5, Money(123))),
-            "Budget assigned to task excluding subtasks (cf_budget field) "
-            "doesn't match total value assigned to payees (cf_payees_list):"
-            " bug #1, calculated total 123")
+            BudgetGraphPayeesMoneyMismatch(1, 5, Money(123), Money(456))),
+            "Total budget assigned to payees (cf_payees_list) doesn't match "
+            "expected value: bug #1, calculated total 123, expected value 456")
 
 
 EXAMPLE_BUG1 = MockBug(bug_id=1,
@@ -157,6 +165,7 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(node1.budget_including_subtasks, Money(cents=2000))
         self.assertEqual(node1.nlnet_milestone, "abc")
         self.assertEqual(list(node1.children()), [node2])
+        self.assertEqual(list(node1.children_breadth_first()), [node2])
         self.assertEqual(node1.payees, {})
         self.assertIsInstance(node2, Node)
         self.assertIs(node2.graph, bg)
@@ -169,117 +178,429 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(node2.nlnet_milestone, "abc")
         self.assertEqual(node2.payees, {})
 
-    def test_money_with_no_milestone(self):
+    def test_children(self):
         bg = BudgetGraph([
             MockBug(bug_id=1,
                     cf_budget_parent=None,
                     cf_budget="0",
-                    cf_total_budget="10",
+                    cf_total_budget="0",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
-        ])
-        errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphMoneyWithNoMilestone,
-                                      BudgetGraphMoneyMismatch])
-        self.assertEqual(errors[0].bug_id, 1)
-        self.assertEqual(errors[0].root_bug_id, 1)
-        bg = BudgetGraph([
-            MockBug(bug_id=1,
-                    cf_budget_parent=None,
-                    cf_budget="10",
+            MockBug(bug_id=2,
+                    cf_budget_parent=1,
+                    cf_budget="0",
                     cf_total_budget="0",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
-        ])
-        errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphMoneyWithNoMilestone,
-                                      BudgetGraphMoneyMismatch])
-        self.assertEqual(errors[0].bug_id, 1)
-        self.assertEqual(errors[0].root_bug_id, 1)
-        bg = BudgetGraph([
-            MockBug(bug_id=1,
-                    cf_budget_parent=None,
-                    cf_budget="10",
-                    cf_total_budget="10",
+            MockBug(bug_id=3,
+                    cf_budget_parent=1,
+                    cf_budget="0",
+                    cf_total_budget="0",
+                    cf_nlnet_milestone=None,
+                    cf_payees_list=""),
+            MockBug(bug_id=4,
+                    cf_budget_parent=1,
+                    cf_budget="0",
+                    cf_total_budget="0",
+                    cf_nlnet_milestone=None,
+                    cf_payees_list=""),
+            MockBug(bug_id=5,
+                    cf_budget_parent=3,
+                    cf_budget="0",
+                    cf_total_budget="0",
+                    cf_nlnet_milestone=None,
+                    cf_payees_list=""),
+            MockBug(bug_id=6,
+                    cf_budget_parent=3,
+                    cf_budget="0",
+                    cf_total_budget="0",
+                    cf_nlnet_milestone=None,
+                    cf_payees_list=""),
+            MockBug(bug_id=7,
+                    cf_budget_parent=5,
+                    cf_budget="0",
+                    cf_total_budget="0",
                     cf_nlnet_milestone=None,
                     cf_payees_list=""),
         ])
-        errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors, [BudgetGraphMoneyWithNoMilestone])
-        self.assertEqual(errors[0].bug_id, 1)
-        self.assertEqual(errors[0].root_bug_id, 1)
+        self.assertEqual(len(bg.nodes), 7)
+        node1: Node = bg.nodes[1]
+        node2: Node = bg.nodes[2]
+        node3: Node = bg.nodes[3]
+        node4: Node = bg.nodes[4]
+        node5: Node = bg.nodes[5]
+        node6: Node = bg.nodes[6]
+        node7: Node = bg.nodes[7]
+        self.assertEqual(bg.roots, {node1})
+        self.assertEqual(list(node1.children()),
+                         [node2, node3, node5, node7, node6, node4])
+        self.assertEqual(list(node1.children_breadth_first()),
+                         [node2, node3, node4, node5, node6, node7])
 
-    def test_money_mismatch(self):
+    def test_money_with_no_milestone(self):
         bg = BudgetGraph([
             MockBug(bug_id=1,
                     cf_budget_parent=None,
                     cf_budget="0",
                     cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
+                    cf_nlnet_milestone=None,
                     cf_payees_list=""),
         ])
         errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphMoneyMismatch])
+        self.assertErrorTypesMatches(errors, [
+            BudgetGraphMoneyWithNoMilestone,
+            BudgetGraphMoneyMismatchForBudgetExcludingSubtasks])
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
-        self.assertEqual(errors[0].expected_budget_excluding_subtasks, 10)
         bg = BudgetGraph([
             MockBug(bug_id=1,
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="0",
-                    cf_nlnet_milestone="abc",
+                    cf_nlnet_milestone=None,
                     cf_payees_list=""),
         ])
         errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphMoneyMismatch])
+        self.assertErrorTypesMatches(errors, [
+            BudgetGraphMoneyWithNoMilestone,
+            BudgetGraphMoneyMismatchForBudgetIncludingSubtasks])
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
-        self.assertEqual(errors[0].expected_budget_excluding_subtasks, 0)
         bg = BudgetGraph([
             MockBug(bug_id=1,
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
-                    cf_payees_list=""),
-        ])
-        errors = bg.get_errors()
-        self.assertEqual(errors, [])
-        bg = BudgetGraph([
-            MockBug(bug_id=1,
-                    cf_budget_parent=None,
-                    cf_budget="10",
-                    cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
-                    cf_payees_list=""),
-            MockBug(bug_id=2,
-                    cf_budget_parent=1,
-                    cf_budget="10",
-                    cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
-                    cf_payees_list=""),
-            MockBug(bug_id=3,
-                    cf_budget_parent=1,
-                    cf_budget="1",
-                    cf_total_budget="10",
-                    cf_nlnet_milestone="abc",
+                    cf_nlnet_milestone=None,
                     cf_payees_list=""),
         ])
         errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphMoneyMismatch,
-                                      BudgetGraphMoneyMismatch])
+        self.assertErrorTypesMatches(errors, [BudgetGraphMoneyWithNoMilestone])
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
-        self.assertEqual(errors[0].expected_budget_excluding_subtasks, -10)
-        self.assertEqual(errors[1].bug_id, 3)
-        self.assertEqual(errors[1].root_bug_id, 1)
-        self.assertEqual(errors[1].expected_budget_excluding_subtasks, 10)
+
+    def test_money_mismatch(self):
+        def helper(budget, total_budget, payees_list, child_budget,
+                   expected_errors, expected_fixed_error_types=None):
+            if expected_fixed_error_types is None:
+                expected_fixed_error_types = []
+            bg = BudgetGraph([
+                MockBug(bug_id=1,
+                        cf_budget_parent=None,
+                        cf_budget=budget,
+                        cf_total_budget=total_budget,
+                        cf_nlnet_milestone="abc",
+                        cf_payees_list=payees_list),
+                MockBug(bug_id=2,
+                        cf_budget_parent=1,
+                        cf_budget=child_budget,
+                        cf_total_budget=child_budget,
+                        cf_nlnet_milestone="abc",
+                        cf_payees_list=""),
+            ])
+            node1: Node = bg.nodes[1]
+            errors = bg.get_errors()
+            self.assertErrorTypesMatches(errors,
+                                         [type(i) for i in expected_errors])
+            self.assertEqual([str(i) for i in errors],
+                             [str(i) for i in expected_errors])
+            bg = BudgetGraph([
+                MockBug(bug_id=1,
+                        cf_budget_parent=None,
+                        cf_budget=str(node1.fixed_budget_excluding_subtasks),
+                        cf_total_budget=str(
+                            node1.fixed_budget_including_subtasks),
+                        cf_nlnet_milestone="abc",
+                        cf_payees_list=payees_list),
+                MockBug(bug_id=2,
+                        cf_budget_parent=1,
+                        cf_budget=child_budget,
+                        cf_total_budget=child_budget,
+                        cf_nlnet_milestone="abc",
+                        cf_payees_list=""),
+            ])
+            errors = bg.get_errors()
+            self.assertErrorTypesMatches(errors,
+                                         expected_fixed_error_types)
+        helper(budget="0",
+               total_budget="0",
+               payees_list="",
+               child_budget="0",
+               expected_errors=[])
+        helper(budget="0",
+               total_budget="0",
+               payees_list="",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(5)),
+               ])
+        helper(budget="0",
+               total_budget="0",
+               payees_list="a=1",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(1)),
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(1)),
+               ])
+        helper(budget="0",
+               total_budget="0",
+               payees_list="a=1",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(1)),
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(6)),
+               ])
+        helper(budget="0",
+               total_budget="0",
+               payees_list="a=10",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(10)),
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(10)),
+               ])
+        helper(budget="0",
+               total_budget="0",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(10)),
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(15)),
+               ])
+        helper(budget="0",
+               total_budget="100",
+               payees_list="",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(100)),
+               ])
+        helper(budget="0",
+               total_budget="100",
+               payees_list="",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(95)),
+               ])
+        helper(budget="0",
+               total_budget="100",
+               payees_list="a=1",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(100)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(100)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="0",
+               total_budget="100",
+               payees_list="a=1",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(95)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(95)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="0",
+               total_budget="100",
+               payees_list="a=10",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(100)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(10), Money(100)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="0",
+               total_budget="100",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(95)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(10), Money(95)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="0",
+               total_budget="5",
+               payees_list="",
+               child_budget="5",
+               expected_errors=[])
+        helper(budget="0",
+               total_budget="5",
+               payees_list="a=1",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(0)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="0",
+               total_budget="5",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(10), Money(0)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="0",
+               payees_list="",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(10)),
+               ])
+        helper(budget="10",
+               total_budget="0",
+               payees_list="",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(15)),
+               ])
+        helper(budget="10",
+               total_budget="0",
+               payees_list="a=1",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(10)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="0",
+               payees_list="a=1",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(15)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="0",
+               payees_list="a=10",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(10)),
+               ])
+        helper(budget="10",
+               total_budget="0",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(15)),
+               ])
+        helper(budget="10",
+               total_budget="10",
+               payees_list="",
+               child_budget="0",
+               expected_errors=[])
+        helper(budget="10",
+               total_budget="10",
+               payees_list="a=1",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="10",
+               payees_list="a=10",
+               child_budget="0",
+               expected_errors=[])
+        helper(budget="10",
+               total_budget="100",
+               payees_list="",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(100)),
+               ])
+        helper(budget="10",
+               total_budget="100",
+               payees_list="",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money(95)),
+               ])
+        helper(budget="10",
+               total_budget="100",
+               payees_list="a=1",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(10)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="100",
+               payees_list="a=1",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(15)),
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="100",
+               payees_list="a=10",
+               child_budget="0",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(10)),
+               ])
+        helper(budget="10",
+               total_budget="100",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                       1, 1, Money(15)),
+               ])
+        helper(budget="10",
+               total_budget="15",
+               payees_list="",
+               child_budget="5",
+               expected_errors=[])
+        helper(budget="10",
+               total_budget="15",
+               payees_list="a=1",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphPayeesMoneyMismatch(1, 1, Money(1), Money(10)),
+               ],
+               expected_fixed_error_types=[BudgetGraphPayeesMoneyMismatch])
+        helper(budget="10",
+               total_budget="15",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[])
+
+        helper(budget="1",
+               total_budget="15",
+               payees_list="a=10",
+               child_budget="5",
+               expected_errors=[
+                   BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                       1, 1, Money("10"))
+               ])
 
     def test_negative_money(self):
         bg = BudgetGraph([
@@ -291,9 +612,9 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_payees_list=""),
         ])
         errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphNegativeMoney,
-                                      BudgetGraphMoneyMismatch])
+        self.assertErrorTypesMatches(errors, [
+            BudgetGraphNegativeMoney,
+            BudgetGraphMoneyMismatchForBudgetExcludingSubtasks])
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
         self.assertEqual(errors[1].bug_id, 1)
@@ -308,14 +629,14 @@ class TestBudgetGraph(unittest.TestCase):
                     cf_payees_list=""),
         ])
         errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphNegativeMoney,
-                                      BudgetGraphMoneyMismatch])
+        self.assertErrorTypesMatches(errors, [
+            BudgetGraphNegativeMoney,
+            BudgetGraphMoneyMismatchForBudgetIncludingSubtasks])
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
         self.assertEqual(errors[1].bug_id, 1)
         self.assertEqual(errors[1].root_bug_id, 1)
-        self.assertEqual(errors[1].expected_budget_excluding_subtasks, 0)
+        self.assertEqual(errors[1].expected_budget_including_subtasks, -10)
         bg = BudgetGraph([
             MockBug(bug_id=1,
                     cf_budget_parent=None,
@@ -394,20 +715,6 @@ class TestBudgetGraph(unittest.TestCase):
         self.assertEqual(errors[0].bug_id, 1)
         self.assertEqual(errors[0].root_bug_id, 1)
         self.assertEqual(errors[0].payees_total, 15)
-        bg = BudgetGraph([
-            MockBug(bug_id=1,
-                    cf_budget_parent=None,
-                    cf_budget="0",
-                    cf_total_budget="0",
-                    cf_nlnet_milestone=None,
-                    cf_payees_list="payee = 5\npayee2 = 10"),
-        ])
-        errors = bg.get_errors()
-        self.assertErrorTypesMatches(errors,
-                                     [BudgetGraphPayeesMoneyMismatch])
-        self.assertEqual(errors[0].bug_id, 1)
-        self.assertEqual(errors[0].root_bug_id, 1)
-        self.assertEqual(errors[0].payees_total, 15)
 
     def test_payees_parse_error(self):
         def check_parse_error(cf_payees_list, expected_msg):