From 89af752a987dc344b4efb074f7ccda918423687b Mon Sep 17 00:00:00 2001 From: Nettika Date: Sat, 16 Dec 2023 16:02:06 -0800 Subject: [PATCH] Cleanup day 2 solution --- advent_of_code/__init__.py | 9 ++- advent_of_code/cubes.py | 92 +++++++++++++++++++++++++++ advent_of_code/trebuchet.py | 7 ++- tests/cubes_test.py | 121 ++++++++++++++++++++++++++++++++++++ tests/trebuchet_test.py | 23 +++++-- 5 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 advent_of_code/cubes.py create mode 100644 tests/cubes_test.py diff --git a/advent_of_code/__init__.py b/advent_of_code/__init__.py index e266f44..7eb8d49 100644 --- a/advent_of_code/__init__.py +++ b/advent_of_code/__init__.py @@ -4,14 +4,13 @@ __author__ = "Nettika " __version__ = "1.0.0" from typing import Callable -from advent_of_code.trebuchet import ( - recover_calibration_digits_and_words, - recover_calibration_digits_only, -) + +from advent_of_code import cubes, trebuchet Solver = Callable[[str], str] solvers: dict[int, tuple[Solver, Solver]] = { - 1: (recover_calibration_digits_only, recover_calibration_digits_and_words), + 1: (trebuchet.solve_part_1, trebuchet.solve_part_2), + 2: (cubes.solve_part_1, cubes.solve_part_2), } diff --git a/advent_of_code/cubes.py b/advent_of_code/cubes.py new file mode 100644 index 0000000..36aed64 --- /dev/null +++ b/advent_of_code/cubes.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import re +from collections import UserDict +from dataclasses import dataclass +from typing import ClassVar + + +class Configuration(UserDict[str, int]): + color_yield_pattern: ClassVar[re.Pattern] = re.compile(r"(\d+) (\w+)") + + @staticmethod + def _parse_color_yield(yield_desc: str) -> tuple[str, int]: + yield_match = Configuration.color_yield_pattern.match(yield_desc.strip()) + if not yield_match: + raise ValueError(f"Color yield is invalid: {yield_desc}") + amount = int(yield_match.group(1)) + color = yield_match.group(2) + return (color, amount) + + @classmethod + def parse(cls, desc: str) -> Configuration: + return cls( + cls._parse_color_yield(yield_desc) + for yield_desc in desc.split(",") + if len(yield_desc) + ) + + @classmethod + def parse_sequence(cls, desc: str) -> list[Configuration]: + return [cls.parse(draw_desc) for draw_desc in desc.split(";") if len(draw_desc)] + + def power(self) -> int: + return self.get("blue", 0) * self.get("green", 0) * self.get("red", 0) + + +@dataclass +class Game: + id: int + draws: list[Configuration] + + id_pattern: ClassVar[re.Pattern] = re.compile(r"Game (\d+)$") + + @classmethod + def parse_table(cls, table: str) -> list[Game]: + return [cls.parse(line) for line in table.split("\n")] + + @classmethod + def parse(cls, desc: str) -> Game: + id_segment, _, draws_segment = desc.partition(":") + return cls( + cls._parse_id(id_segment), + Configuration.parse_sequence(draws_segment), + ) + + @staticmethod + def _parse_id(desc: str) -> int: + id_match = Game.id_pattern.match(desc) + if not id_match: + raise ValueError("Game ID is invalid.") + return int(id_match.group(1)) + + def meets_configuration(self, configuration: Configuration) -> bool: + for draw in self.draws: + for name, quantity in draw.items(): + if quantity > configuration.get(name, 0): + return False + return True + + def minimum_configuration(self) -> Configuration: + config = Configuration() + for draw in self.draws: + for name, quantity in draw.items(): + config[name] = max(quantity, config.get(name, 0)) + return config + + +def solve_part_1(input: str) -> str: + actual_bag = Configuration(red=12, green=13, blue=14) + return str( + sum( + game.id + for game in Game.parse_table(input) + if game.meets_configuration(actual_bag) + ) + ) + + +def solve_part_2(input: str) -> str: + return str( + sum(game.minimum_configuration().power() for game in Game.parse_table(input)) + ) diff --git a/advent_of_code/trebuchet.py b/advent_of_code/trebuchet.py index 52f6de7..7d0f183 100644 --- a/advent_of_code/trebuchet.py +++ b/advent_of_code/trebuchet.py @@ -1,5 +1,6 @@ -import re +"Day 1: Trebuchet?!" +import re number_word_map = { "one": "1", @@ -61,7 +62,7 @@ def _recover_all_calibration_values( ) -def recover_calibration_digits_only(input: str) -> str: +def solve_part_1(input: str) -> str: return str( _recover_all_calibration_values( input, @@ -72,7 +73,7 @@ def recover_calibration_digits_only(input: str) -> str: ) -def recover_calibration_digits_and_words(input: str) -> str: +def solve_part_2(input: str) -> str: return str( _recover_all_calibration_values( input, diff --git a/tests/cubes_test.py b/tests/cubes_test.py new file mode 100644 index 0000000..0d73f4c --- /dev/null +++ b/tests/cubes_test.py @@ -0,0 +1,121 @@ +import pytest + +from advent_of_code.cubes import Configuration, Game, solve_part_1, solve_part_2 + + +def test_configuration_parse(): + assert Configuration.parse("1 red") == Configuration(red=1) + assert Configuration.parse(" 2 blue, 4 green,") == Configuration(green=4, blue=2) + assert Configuration.parse("") == Configuration() + + with pytest.raises(ValueError): + Configuration.parse("invalid") + + +def test_configuration_parse_sequence(): + assert Configuration.parse_sequence("1 red; 2 blue, 4 green;") == [ + Configuration(red=1), + Configuration(green=4, blue=2), + ] + assert Configuration.parse_sequence("") == [] + + +def test_configuration_power(): + assert Configuration(red=2, blue=3, green=5).power() == 30 + + +def test_game_parse_table(): + assert list(Game.parse_table("Game 1: 1 red\nGame 2: 1 blue")) == [ + Game(1, [{"red": 1}]), + Game(2, [{"blue": 1}]), + ] + + +def test_game_parse(): + assert Game.parse("Game 3: 1 green") == Game(3, [Configuration(green=1)]) + assert Game.parse( + "Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green", + ) == Game( + 1, + [ + Configuration(blue=3, red=4), + Configuration(red=1, green=2, blue=6), + Configuration(green=2), + ], + ) + assert Game.parse("Game 4:") == Game(4, []) + + with pytest.raises(ValueError): + assert Game.parse("invalid") + + with pytest.raises(ValueError): + assert Game.parse(":") + + +def test_game_meets_configuration(): + bag = Configuration(red=5, green=5, blue=5) + + assert Game( + 1, + [ + Configuration(red=3, blue=5), + Configuration(green=2, blue=3), + Configuration(red=4), + ], + ).meets_configuration(bag) + assert Game(3, []).meets_configuration(bag) + assert not Game( + 2, + [ + Configuration(green=3), + Configuration(red=6, blue=2), + Configuration(blue=2), + ], + ).meets_configuration(bag) + assert not Game(3, [Configuration(orange=1)]).meets_configuration(bag) + + +def test_game_minimum_configuration(): + assert Game( + 1, + [ + Configuration(red=3, blue=5), + Configuration(green=2, blue=3), + Configuration(red=4), + ], + ).minimum_configuration() == Configuration(red=4, blue=5, green=2) + + assert Game( + 2, + [ + Configuration(red=1), + Configuration(blue=1), + Configuration(green=1), + ], + ).minimum_configuration() == Configuration(red=1, blue=1, green=1) + + +mock_input = "\n".join( + [ + "Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green", + "Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue", + "Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red", + "Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red", + "Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green", + ] +) + + +def test_solve_pt_1(): + assert solve_part_1(mock_input) == "8" + + +def test_solve_pt_2(): + assert solve_part_2(mock_input) == "2286" + + +# def test_configuration_power(): +# assert [ +# _configuration_power(Game.parse(game_desc).minimum_configuration()) +# for game_desc in mock_input +# ] == [48, 12, 1560, 630, 36] diff --git a/tests/trebuchet_test.py b/tests/trebuchet_test.py index a47074f..90d8975 100644 --- a/tests/trebuchet_test.py +++ b/tests/trebuchet_test.py @@ -1,9 +1,12 @@ import re + +import pytest + from advent_of_code.trebuchet import ( - recover_calibration_digits_and_words, - recover_calibration_digits_only, _recover_all_calibration_values, _recover_calibration_value, + solve_part_1, + solve_part_2, ) @@ -18,6 +21,14 @@ def test_recover_calibration_value(): == 12 ) + with pytest.raises(ValueError): + _recover_calibration_value( + ".", + re.compile("ab"), + re.compile("ba"), + {}, + ) + def test_recover_all_calibration_values(): assert ( @@ -31,9 +42,9 @@ def test_recover_all_calibration_values(): ) -def test_recover_calibration_digits_only(): +def test_solve_part_1(): assert ( - recover_calibration_digits_only( + solve_part_1( "\n".join( [ "1abc2", @@ -47,9 +58,9 @@ def test_recover_calibration_digits_only(): ) -def test_recover_calibration_digits_and_words(): +def test_solve_part_2(): assert ( - recover_calibration_digits_and_words( + solve_part_2( "\n".join( [ "two1nine",