From 1f515cc36af36a9d4e993708545e2aac6c01007d Mon Sep 17 00:00:00 2001 From: Nettika Date: Mon, 9 Dec 2024 20:47:50 -0800 Subject: [PATCH] Restructure solver modules with classes and unittest suites --- puzzles/1.py | 73 ++++++++++++++++--------- puzzles/2.py | 132 +++++++++++++++++++++++++++------------------ puzzles/3.py | 82 +++++++++++++++++----------- puzzles/_solver.py | 12 +++++ solve.py | 82 +++++++++++----------------- 5 files changed, 222 insertions(+), 159 deletions(-) create mode 100644 puzzles/_solver.py diff --git a/puzzles/1.py b/puzzles/1.py index 2887847..de04837 100644 --- a/puzzles/1.py +++ b/puzzles/1.py @@ -1,33 +1,56 @@ from collections import Counter +from textwrap import dedent +from typing import override +from unittest import TestCase -test_input = """ -3 4 -4 3 -2 5 -1 3 -3 9 -3 3 -""" - -test_solution_p1 = 11 -test_solution_p2 = 31 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - list1, list2 = _parse_lists(puzzle_input) - pairs = zip(sorted(list1), sorted(list2)) - distances = (abs(pair[0] - pair[1]) for pair in pairs) - return sum(distances) +class DayOneSolver(Solver): + list1: list[int] + list2: list[int] + + @override + def __init__(self, puzzle_input: str): + lines = (line.partition(" ") for line in puzzle_input.strip().split("\n")) + list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines)) + self.list1 = list(list1) + self.list2 = list(list2) + + @override + def solve_p1(self) -> int: + minimum_pairs = zip(sorted(self.list1), sorted(self.list2)) + distances = (abs(pair[0] - pair[1]) for pair in minimum_pairs) + return sum(distances) + + @override + def solve_p2(self) -> int: + occurrences = Counter(self.list2) + similarities = (value * occurrences.get(value, 0) for value in self.list1) + return sum(similarities) -def solve_p2(puzzle_input: str) -> int: - list1, list2 = _parse_lists(puzzle_input) - occurrences = Counter(list2) - similarities = (value * occurrences.get(value, 0) for value in list1) - return sum(similarities) +class TestDayOneSolver(TestCase): + test_input = dedent( + """ + 3 4 + 4 3 + 2 5 + 1 3 + 3 9 + 3 3 + """ + ) + def test_parse(self): + solver = DayOneSolver(self.test_input) + self.assertListEqual(solver.list1, [3, 4, 2, 1, 3, 3]) + self.assertListEqual(solver.list2, [4, 3, 5, 3, 9, 3]) -def _parse_lists(puzzle_input: str) -> tuple[list[int], list[int]]: - lines = (line.partition(" ") for line in puzzle_input.strip().split("\n")) - list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines)) - return list1, list2 + def test_solve_p1(self): + solver = DayOneSolver(self.test_input) + self.assertEqual(solver.solve_p1(), 11) + + def test_solve_p2(self): + solver = DayOneSolver(self.test_input) + self.assertEqual(solver.solve_p2(), 31) diff --git a/puzzles/2.py b/puzzles/2.py index 3e8ba4a..5bcbc96 100644 --- a/puzzles/2.py +++ b/puzzles/2.py @@ -1,63 +1,93 @@ -from itertools import islice -from typing import Iterator +from dataclasses import dataclass +from functools import cached_property +from itertools import pairwise +from textwrap import dedent +from typing import Iterator, override +from unittest import TestCase from more_itertools import ilen -test_input = """ -7 6 4 2 1 -1 2 7 8 9 -9 7 6 2 1 -1 3 2 4 5 -8 6 4 4 1 -1 3 6 7 9 -""" - -test_solution_p1 = 2 -test_solution_p2 = 4 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - reports = _parse_reports(puzzle_input) - delta_reports = (_deltas(report) for report in reports) - safe_delta_reports = filter(_is_gradual_monotonic, delta_reports) - return ilen(safe_delta_reports) +@dataclass +class Report: + levels: tuple[int, ...] + + @cached_property + def deltas(self) -> tuple[int, ...]: + return tuple(b - a for a, b in pairwise(self.levels)) + + def is_gradual_monotonic(self) -> bool: + sign = 1 if self.deltas[0] > 0 else -1 + return all( + delta != 0 and delta / abs(delta) == sign and abs(delta) < 4 + for delta in self.deltas + ) + + def permute_dampened_variations(self) -> Iterator["Report"]: + yield self + for i in range(0, len(self.levels)): + yield Report(self.levels[:i] + self.levels[i + 1 :]) -def solve_p2(puzzle_input: str) -> int: - reports = _parse_reports(puzzle_input) - dampened_report_collections = (_dampen_permutations(report) for report in reports) - delta_report_collections = ( - (_deltas(report) for report in report_collection) - for report_collection in dampened_report_collections - ) - safe_report_collections = filter( - lambda delta_report_collection: any( - _is_gradual_monotonic(delta_report) - for delta_report in delta_report_collection - ), - delta_report_collections, - ) - return ilen(safe_report_collections) +class DayTwoSolver(Solver): + reports: list[Report] + + @override + def __init__(self, puzzle_input: str): + self.reports = list( + Report(tuple(int(level) for level in line.split(" "))) + for line in puzzle_input.strip().splitlines() + ) + + @override + def solve_p1(self) -> int: + safe_reports = filter(Report.is_gradual_monotonic, self.reports) + return ilen(safe_reports) + + @override + def solve_p2(self) -> int: + safe_reports = filter( + lambda report: any( + report_variation.is_gradual_monotonic() + for report_variation in report.permute_dampened_variations() + ), + self.reports, + ) + return ilen(safe_reports) -def _parse_reports(puzzle_input: str) -> Iterator[tuple[int, ...]]: - lines = puzzle_input.strip().splitlines() - return (tuple(int(level) for level in line.split(" ")) for line in lines) - - -def _deltas(report: tuple[int, ...]) -> tuple[int, ...]: - pairs = zip(report, islice(report, 1, None)) - return tuple(b - a for a, b in pairs) - - -def _is_gradual_monotonic(delta_report: tuple[int]) -> bool: - sign = 1 if delta_report[0] > 0 else -1 - return all( - delta != 0 and delta / abs(delta) == sign and abs(delta) < 4 - for delta in delta_report +class TestDayNSolver(TestCase): + test_input = dedent( + """ + 7 6 4 2 1 + 1 2 7 8 9 + 9 7 6 2 1 + 1 3 2 4 5 + 8 6 4 4 1 + 1 3 6 7 9 + """ ) + def test_parse(self): + solver = DayTwoSolver(self.test_input) + self.assertListEqual( + solver.reports, + [ + Report((7, 6, 4, 2, 1)), + Report((1, 2, 7, 8, 9)), + Report((9, 7, 6, 2, 1)), + Report((1, 3, 2, 4, 5)), + Report((8, 6, 4, 4, 1)), + Report((1, 3, 6, 7, 9)), + ], + ) -def _dampen_permutations(report: tuple[int, ...]) -> Iterator[tuple[int, ...]]: - yield report - yield from (report[:i] + report[i + 1 :] for i in range(0, len(report))) + def test_solve_p1(self): + solver = DayTwoSolver(self.test_input) + self.assertEqual(solver.solve_p1(), 2) + + def test_solve_p2(self): + solver = DayTwoSolver(self.test_input) + self.assertEqual(solver.solve_p2(), 4) diff --git a/puzzles/3.py b/puzzles/3.py index d3befd8..f452594 100644 --- a/puzzles/3.py +++ b/puzzles/3.py @@ -1,40 +1,58 @@ import re -from functools import reduce +from typing import override +from unittest import TestCase -test_input_p1 = """ -xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5)) -""" - -test_input_p2 = """ -xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5)) -""" - -test_solution_p1 = 161 -test_solution_p2 = 48 +from puzzles._solver import Solver +simple_instruction_pattern = re.compile(r"mul\((\d+),(\d+)\)") instruction_pattern = re.compile(r"mul\((\d+),(\d+)\)|(do(?:n't)?)\(\)") -def solve_p1(puzzle_input: str) -> int: - instructions = ( - tuple(map(int, match[:2])) - for match in instruction_pattern.findall(puzzle_input) - if match[0] - ) - return sum(a * b for a, b in instructions) +class DayNSolver(Solver): + memory: str + + @override + def __init__(self, puzzle_input: str): + self.memory = puzzle_input + + @override + def solve_p1(self) -> int: + total = 0 + + for match in simple_instruction_pattern.findall(self.memory): + a, b = match + total += int(a) * int(b) + + return total + + @override + def solve_p2(self) -> int: + total = 0 + enabled = True + + for match in instruction_pattern.findall(self.memory): + a, b, enable_flag = match + match enable_flag: + case "do": + enabled = True + case "don't": + enabled = False + case "": + if enabled: + total += int(a) * int(b) + + return total -def solve_p2(puzzle_input: str) -> int: - instructions = ( - match[2] or tuple(map(int, match[:2])) - for match in instruction_pattern.findall(puzzle_input) - ) - return reduce( - lambda a, i: ( - (1 if i == "do" else 0, a[1]) - if isinstance(i, str) - else (a[0], a[1] + a[0] * i[0] * i[1]) - ), - instructions, - (1, 0), - )[1] +class TestDayNSolver(TestCase): + def test_solve_p1(self): + solver = DayNSolver( + "xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5))\n" + ) + self.assertEqual(solver.solve_p1(), 161) + + def test_solve_p2(self): + solver = DayNSolver( + "xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))\n" + ) + self.assertEqual(solver.solve_p2(), 48) diff --git a/puzzles/_solver.py b/puzzles/_solver.py new file mode 100644 index 0000000..f977b77 --- /dev/null +++ b/puzzles/_solver.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class Solver(ABC): + @abstractmethod + def __init__(self, puzzle_input: str): ... + + @abstractmethod + def solve_p1(self) -> int: ... + + @abstractmethod + def solve_p2(self) -> int: ... diff --git a/solve.py b/solve.py index b15d625..8410349 100644 --- a/solve.py +++ b/solve.py @@ -1,80 +1,60 @@ import sys from importlib import import_module +from inspect import getmembers, isclass from time import time +from unittest import TextTestRunner, defaultTestLoader -from advent.errors import InvalidSessionIDError, PuzzleNotFoundError from advent.functions import get_puzzle_input - -def usage(): - print("Usage: python solve.py ") +from puzzles._solver import Solver def main(): if len(sys.argv) != 2: - usage() - sys.exit(1) + raise RuntimeError("Usage: python solve.py ") try: day = int(sys.argv[1]) except ValueError: - print("Day must be an integer") - usage() - sys.exit(1) + raise RuntimeError("Day must be an integer") + print(f"Loading 2024, day {day} solver...") try: - print(f"Loading 2024, day {day} solver") mod = import_module(f"puzzles.{day}") except ModuleNotFoundError: print(f"Solver for day {day} not found") sys.exit(1) - try: - print("Testing part 1 solution...") - test_input_p1 = getattr(mod, "test_input_p1", getattr(mod, "test_input", None)) - start = time() - assert mod.solve_p1(test_input_p1) == mod.test_solution_p1 - print(f"Test passed in {time() - start:.3f} seconds") + solvers = getmembers(mod, lambda m: isclass(m) and issubclass(m, Solver)) + if len(solvers) == 0: + raise RuntimeError("No solver found") + solver_class: type[Solver] = solvers[0][1] - print("Testing part 2 solution...") - test_input_p2 = getattr(mod, "test_input_p2", getattr(mod, "test_input", None)) - start = time() - assert mod.solve_p2(test_input_p2) == mod.test_solution_p2 - print(f"Test passed in {time() - start:.3f} seconds") + test_suite = defaultTestLoader.loadTestsFromModule(mod) + test_runner = TextTestRunner(verbosity=0) + test_result = test_runner.run(test_suite) + if not test_result.wasSuccessful(): + raise RuntimeError("Tests failed") - except AssertionError: - print("Test failed") - sys.exit(1) + print("Fetching puzzle input...") + puzzle_input = get_puzzle_input(2024, int(day)) - except Exception as exc: - print("Error:", exc) - sys.exit(1) + solver = solver_class(puzzle_input) - try: - print("Fetching puzzle input...") - puzzle_input = get_puzzle_input(2024, int(day)) - except (PuzzleNotFoundError, InvalidSessionIDError) as exc: - print(exc) - sys.exit(1) + print("Solving part 1...") + start = time() + solution_p1 = solver.solve_p1() + print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)") - try: - print("Solving part 1...") - start = time() - solution_p1 = mod.solve_p1(puzzle_input) - print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)") - - print("Solving part 2...") - solution_p2 = mod.solve_p2(puzzle_input) - print(f"Part 2 solution: {solution_p2} ({time() - start:.3f} seconds)") - - except NotImplementedError: - print("Puzzle solver is incomplete. Exiting") - sys.exit(1) - - except Exception as exc: - print("Error:", exc) - sys.exit(1) + print("Solving part 2...") + start = time() + solution_p2 = solver.solve_p2() + print(f"Part 2 solution: {solution_p2} ({time() - start:.3f} seconds)") if __name__ == "__main__": - main() + try: + main() + except Exception as exc: + print(exc) + sys.exit(1)