├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── Steepfile ├── aoc_cli.gemspec ├── bin ├── aoc ├── check ├── console └── setup ├── db └── migrate │ ├── 1_create_events.rb │ ├── 2_create_puzzles.rb │ ├── 3_create_stats.rb │ ├── 4_create_attempts.rb │ ├── 5_create_locations.rb │ ├── 6_create_puzzle_dir_sync_logs.rb │ ├── 7_create_progresses.rb │ └── 8_remove_legacy_timestamps.rb ├── exe └── aoc ├── lib ├── aoc_cli.rb └── aoc_cli │ ├── components │ ├── attempts_table.erb │ ├── attempts_table.rb │ ├── docs_component.erb │ ├── docs_component.rb │ ├── errors_component.erb │ ├── errors_component.rb │ ├── progress_table.erb │ ├── progress_table.rb │ ├── puzzle_sync_component.erb │ └── puzzle_sync_component.rb │ ├── configurators │ └── session_configurator.rb │ ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ ├── error_concern.rb │ │ └── location_concern.rb │ ├── default_controller.rb │ ├── event_controller.rb │ ├── help │ │ ├── event_controller.rb │ │ └── puzzle_controller.rb │ └── puzzle_controller.rb │ ├── core │ ├── attempt_parser.rb │ ├── processor.rb │ ├── repository.rb │ ├── request.rb │ ├── resource.rb │ └── stats_parser.rb │ ├── helpers │ ├── table_generator.rb │ └── view_helper.rb │ ├── models │ ├── attempt.rb │ ├── event.rb │ ├── location.rb │ ├── progress.rb │ ├── puzzle.rb │ ├── puzzle_dir_sync_log.rb │ └── stats.rb │ ├── presenters │ ├── attempt_presenter.rb │ ├── puzzle_presenter.rb │ └── stats_presenter.rb │ ├── processors │ ├── event_initialiser.rb │ ├── progress_syncer.rb │ ├── puzzle_dir_synchroniser.rb │ ├── puzzle_initialiser.rb │ ├── puzzle_refresher.rb │ ├── resource_attacher.rb │ ├── solution_poster.rb │ ├── stats_initialiser.rb │ └── stats_refresher.rb │ ├── validators │ ├── collection_type_validator.rb │ ├── event_year_validator.rb │ ├── included_validator.rb │ ├── integer_validator.rb │ ├── path_validator.rb │ └── type_validator.rb │ ├── version.rb │ └── views │ ├── event │ ├── attach.erb │ └── init.erb │ ├── help │ ├── event │ │ ├── attach.erb │ │ ├── init.erb │ │ └── progress.erb │ └── puzzle │ │ ├── attempts.erb │ │ ├── init.erb │ │ ├── solve.erb │ │ └── sync.erb │ └── puzzle │ ├── init.erb │ └── solve.erb ├── rbs_collection.lock.yaml ├── rbs_collection.yaml ├── sig ├── aoc_cli.rbs ├── aoc_cli │ ├── components │ │ ├── attempts_table.rbs │ │ ├── docs_component.rbs │ │ ├── errors_component.rbs │ │ ├── progress_table.rbs │ │ └── puzzle_sync_component.rbs │ ├── configurators │ │ └── session_configurator.rbs │ ├── controllers │ │ ├── application_controller.rbs │ │ ├── concerns │ │ │ ├── error_concern.rbs │ │ │ └── location_concern.rbs │ │ ├── default_controller.rbs │ │ ├── event_controller.rbs │ │ ├── help │ │ │ ├── event_controller.rbs │ │ │ └── puzzle_controller.rbs │ │ └── puzzle_controller.rbs │ ├── core │ │ ├── attempt_parser.rbs │ │ ├── processor.rbs │ │ ├── repository.rbs │ │ ├── request.rbs │ │ ├── resource.rbs │ │ └── stats_parser.rbs │ ├── helpers │ │ ├── table_generator.rbs │ │ └── view_helper.rbs │ ├── models │ │ ├── attempt.rbs │ │ ├── event.rbs │ │ ├── location.rbs │ │ ├── progress.rbs │ │ ├── puzzle.rbs │ │ ├── puzzle_dir_sync_log.rbs │ │ └── stats.rbs │ ├── presenters │ │ ├── attempt_presenter.rbs │ │ ├── puzzle_presenter.rbs │ │ └── stats_presenter.rbs │ ├── processors │ │ ├── event_initialiser.rbs │ │ ├── progress_syncer.rbs │ │ ├── puzzle_dir_synchroniser.rbs │ │ ├── puzzle_initialiser.rbs │ │ ├── puzzle_refresher.rbs │ │ ├── resource_attacher.rbs │ │ ├── solution_poster.rbs │ │ ├── stats_initialiser.rbs │ │ └── stats_refresher.rbs │ └── validators │ │ ├── collection_type_validator.rbs │ │ ├── event_year_validator.rbs │ │ ├── included_validator.rbs │ │ ├── integer_validator.rbs │ │ ├── path_validator.rbs │ │ └── type_validator.rbs ├── http.rbs ├── kangaru.rbs ├── nokogiri.rbs └── reverse_markdown.rbs └── spec ├── aoc.yml ├── aoc_cli ├── components │ ├── attempts_table_spec.rb │ ├── docs_component_spec.rb │ ├── errors_component_spec.rb │ ├── progress_table_spec.rb │ └── puzzle_sync_component_spec.rb ├── controllers │ ├── application_controller_spec.rb │ ├── concerns │ │ ├── error_concern_spec.rb │ │ └── location_concern_spec.rb │ ├── default_controller_spec.rb │ ├── event │ │ ├── attach_spec.rb │ │ ├── init_spec.rb │ │ └── progress_spec.rb │ ├── help │ │ ├── event_controller_spec.rb │ │ └── puzzle_controller_spec.rb │ └── puzzle │ │ ├── attempts_spec.rb │ │ ├── init_spec.rb │ │ ├── solve_spec.rb │ │ └── sync_spec.rb ├── core │ ├── attempt_parser_spec.rb │ ├── processor_spec.rb │ ├── repository_spec.rb │ ├── request_spec.rb │ ├── resource_spec.rb │ └── stats_parser_spec.rb ├── helpers │ ├── table_generator_spec.rb │ └── view_helper_spec.rb ├── models │ ├── attempt_spec.rb │ ├── event_spec.rb │ ├── location_spec.rb │ ├── progress_spec.rb │ ├── puzzle_spec.rb │ └── stats_spec.rb ├── presenters │ ├── attempt_presenter_spec.rb │ ├── puzzle_presenter_spec.rb │ └── stats_presenter_spec.rb ├── processors │ ├── event_initialiser_spec.rb │ ├── progress_syncer_spec.rb │ ├── puzzle_dir_synchroniser_spec.rb │ ├── puzzle_initialiser_spec.rb │ ├── puzzle_refresher_spec.rb │ ├── resource_attacher_spec.rb │ ├── solution_poster_spec.rb │ ├── stats_initialiser_spec.rb │ └── stats_refresher_spec.rb └── validators │ ├── collection_type_validator_spec.rb │ ├── event_year_validator_spec.rb │ ├── included_validator_spec.rb │ ├── integer_validator_spec.rb │ ├── path_validator_spec.rb │ └── type_validator_spec.rb ├── factories ├── attempt_factory.rb ├── event_factory.rb ├── location_factory.rb ├── progress_factory.rb ├── puzzle_dir_sync_log_factory.rb ├── puzzle_factory.rb └── stats_factory.rb ├── fixtures ├── input-2015-03 ├── input-2015-03.yml ├── input-2016-08 ├── input-2016-08.yml ├── input-2017-15 ├── input-2017-15.yml ├── input-2018-19 ├── input-2018-19.yml ├── input-2019-01 ├── input-2019-01.yml ├── input-2020-23 ├── input-2020-23.yml ├── input-2021-20 ├── input-2021-20.yml ├── input-2022-08 ├── input-2022-08.yml ├── input-2023-01 ├── input-2023-01.yml ├── input-2023-02 ├── puzzle-2015-03.md ├── puzzle-2015-03.yml ├── puzzle-2016-08.md ├── puzzle-2016-08.yml ├── puzzle-2017-15.md ├── puzzle-2017-15.yml ├── puzzle-2018-19.md ├── puzzle-2018-19.yml ├── puzzle-2019-01.md ├── puzzle-2019-01.yml ├── puzzle-2020-23.md ├── puzzle-2020-23.yml ├── puzzle-2021-20.md ├── puzzle-2021-20.yml ├── puzzle-2022-08.md ├── puzzle-2022-08.yml ├── puzzle-2023-01.md ├── puzzle-2023-01.yml ├── puzzle-2023-02.md ├── puzzle-2023-02.yml ├── solution-2016-02-1-correct.yml ├── solution-2016-02-1-incorrect-too-high.yml ├── solution-2016-02-1-incorrect-too-low.yml ├── solution-2016-02-1-incorrect.yml ├── solution-2016-02-1-rate-limited.yml ├── solution-2016-02-1-wrong-level.yml ├── solution-2016-02-2-correct.yml ├── solution-2016-02-2-incorrect.yml ├── solution-2016-02-2-rate-limited.yml ├── solution-2016-02-2-wrong-level.yml ├── stats-2015.yml ├── stats-2023-duplicate.yml ├── stats-2023-less-complete.yml └── stats-2023.yml ├── spec_helper.rb └── support ├── cassette_helpers.rb ├── concern_helpers.rb ├── concern_specs.rb ├── contexts ├── in_event_dir.rb └── in_puzzle_dir.rb ├── examples ├── processing.rb └── validations.rb ├── fixture_helpers.rb ├── matchers ├── create_model.rb ├── match_html.rb ├── redirect_to.rb ├── render_component.rb ├── render_errors.rb └── request_url.rb ├── path_helpers.rb ├── request_helpers.rb ├── request_specs.rb └── temp_dir_helper.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "AocCli CI" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout codebase 14 | uses: actions/checkout@v3 15 | - name: Install Ruby and gems 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | bundler-cache: true 19 | - name: Run linters 20 | run: bundle exec rubocop 21 | 22 | type: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout codebase 26 | uses: actions/checkout@v3 27 | - name: Install Ruby and gems 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | bundler-cache: true 31 | - name: Install gem signatures 32 | run: bundle exec rbs collection install 33 | - name: Type check 34 | run: bundle exec steep check 35 | 36 | test: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | - name: Install Ruby and gems 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | bundler-cache: true 45 | - name: Run tests 46 | run: bundle exec rspec 47 | - name: Upload coverage reports 48 | uses: codecov/codecov-action@v3 49 | env: 50 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /db/*.sqlite3 3 | /doc/ 4 | /pkg/ 5 | /tmp/ 6 | /.gems 7 | /coverage 8 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | AllCops: 5 | NewCops: enable 6 | SuggestExtensions: false 7 | 8 | Gemspec/RequireMFA: 9 | Enabled: false 10 | 11 | Layout/ExtraSpacing: 12 | AllowForAlignment: true 13 | Layout/LineLength: 14 | Max: 80 15 | Layout/MultilineMethodCallIndentation: 16 | Enabled: false 17 | 18 | Lint/AmbiguousBlockAssociation: 19 | Enabled: false 20 | Lint/ConstantDefinitionInBlock: 21 | Exclude: 22 | - spec/spec_helper.rb 23 | Lint/MissingSuper: 24 | Enabled: false 25 | 26 | Metrics/BlockLength: 27 | Exclude: 28 | - db/migrate/**/*.rb 29 | 30 | Naming/MethodParameterName: 31 | AllowedNames: 32 | - by 33 | - to 34 | - id 35 | Naming/VariableNumber: 36 | EnforcedStyle: snake_case 37 | 38 | Style/ArgumentsForwarding: 39 | Enabled: false 40 | Style/CharacterLiteral: 41 | Enabled: false 42 | Style/Documentation: 43 | Enabled: false 44 | Style/ExplicitBlockArgument: 45 | Enabled: false 46 | Style/FormatStringToken: 47 | EnforcedStyle: template 48 | Style/FrozenStringLiteralComment: 49 | Enabled: false 50 | Style/RescueStandardError: 51 | EnforcedStyle: implicit 52 | Style/StringLiterals: 53 | EnforcedStyle: double_quotes 54 | 55 | RSpec/AnyInstance: 56 | Enabled: false 57 | RSpec/ContextWording: 58 | Prefixes: 59 | - when 60 | - with 61 | - without 62 | - if 63 | - unless 64 | - and 65 | - but 66 | RSpec/DescribeClass: 67 | Exclude: 68 | - spec/aoc_cli/controllers/**/*.rb 69 | RSpec/DescribedClass: 70 | SkipBlocks: true 71 | RSpec/ExpectChange: 72 | EnforcedStyle: block 73 | RSpec/FilePath: 74 | Exclude: 75 | - spec/aoc_cli/models/**/*.rb 76 | - spec/aoc_cli/controllers/**/*.rb 77 | RSpec/LetSetup: 78 | Enabled: false 79 | RSpec/MultipleMemoizedHelpers: 80 | Max: 15 81 | RSpec/NestedGroups: 82 | Enabled: false 83 | RSpec/SharedExamples: 84 | Enabled: false 85 | RSpec/SpecFilePathFormat: 86 | Exclude: 87 | - spec/aoc_cli/models/**/*.rb 88 | - spec/aoc_cli/controllers/**/*.rb 89 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 17-02-21 2 | ### 0.1.2 3 | - Add changelog 4 | - Add user-agent 5 | 6 | ## 20-02-21 7 | ### 0.2.0 8 | - Add calendar tables 9 | - Star progress now based off this database rather than meta json 10 | - Add cat of calendar from any directory 11 | - Auto generate config file 12 | - Better key validation 13 | - Better default alias handling 14 | - List all aliases 15 | - Rehaul of year initialisation 16 | - Expand config integration 17 | - Ability to turn on/off calendar writing 18 | - Turn on/off leaderboard stats in calendar file 19 | - Git integration 20 | - General refactor 21 | 22 | ## 22-02-21 23 | ### 0.2.1 24 | - Add day_dir_prefix option 25 | - Add functionality to prevent duplicate incorrect attempts 26 | 27 | ## 22-02-21 28 | ### 0.2.2 29 | - Year refresh now creates the calendar table if it does not exist 30 | - Update terminal-table version requirements 31 | - Add version flag 32 | 33 | ## 13-03-21 34 | ### 0.2.3 35 | - SQLite Bug fix for text-only answers 36 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "factory_bot" 6 | gem "rake", "~> 13.0" 7 | gem "rspec", "~> 3.0" 8 | gem "rubocop", "~> 1.21" 9 | gem "rubocop-rspec" 10 | gem "simplecov" 11 | gem "steep" 12 | gem "vcr" 13 | gem "webmock" 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Christian Welham 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | require "rubocop/rake_task" 7 | 8 | RuboCop::RakeTask.new 9 | 10 | task default: %i[spec rubocop] 11 | -------------------------------------------------------------------------------- /Steepfile: -------------------------------------------------------------------------------- 1 | target :lib do 2 | signature "sig" 3 | 4 | check "lib" 5 | 6 | configure_code_diagnostics do |hash| 7 | hash[Steep::Diagnostic::Ruby::FallbackAny] = nil 8 | hash[Steep::Diagnostic::Ruby::UnreachableBranch] = nil 9 | hash[Steep::Diagnostic::Ruby::UnknownConstant] = :error 10 | hash[Steep::Diagnostic::Ruby::MethodDefinitionMissing] = nil 11 | hash[Steep::Diagnostic::Ruby::UnsupportedSyntax] = :hint 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /aoc_cli.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/aoc_cli/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "aoc_cli" 5 | spec.version = AocCli::VERSION 6 | spec.authors = ["Christian Welham"] 7 | spec.email = ["welhamm@gmail.com"] 8 | 9 | spec.summary = "A command-line interface for the Advent of Code puzzles" 10 | 11 | spec.description = <<~DESCRIPTION 12 | A command-line interface for the Advent of Code puzzles. Features include \ 13 | downloading puzzles and inputs, solving puzzles and tracking year progress \ 14 | from within the terminal 15 | DESCRIPTION 16 | 17 | spec.homepage = "https://github.com/apexatoll/aoc-cli" 18 | spec.license = "MIT" 19 | spec.required_ruby_version = ">= 3.2.0" 20 | 21 | spec.metadata["homepage_uri"] = spec.homepage 22 | spec.metadata["source_code_uri"] = "https://github.com/apexatoll/aoc-cli" 23 | 24 | spec.files = Dir.chdir(__dir__) do 25 | `git ls-files -z`.split("\x0").reject do |f| 26 | f == __FILE__ || f.match?(/\A(bin|spec|\.git)/) 27 | end 28 | end 29 | 30 | spec.bindir = "exe" 31 | spec.executables = ["aoc"] 32 | spec.require_paths = %w[lib db] 33 | 34 | spec.add_dependency "http" 35 | spec.add_dependency "kangaru" 36 | spec.add_dependency "nokogiri" 37 | spec.add_dependency "reverse_markdown" 38 | spec.add_dependency "sequel_polymorphic" 39 | spec.add_dependency "terminal-table" 40 | end 41 | -------------------------------------------------------------------------------- /bin/aoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Runs the application using the latest code for development purposes 4 | # exe/aoc is the release script using the published gem code. 5 | require_relative "../lib/aoc_cli" 6 | 7 | AocCli.run!(*ARGV) 8 | -------------------------------------------------------------------------------- /bin/check: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bundle exec rubocop lib spec && 4 | bundle exec steep check --log-level fatal && 5 | bundle exec rspec 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "aoc_cli" 5 | require "irb" 6 | 7 | IRB.setup("nil") 8 | IRB.conf[:MAIN_CONTEXT] = IRB::Irb.new.context 9 | IRB.conf[:COMPLETER] = :type 10 | 11 | require "irb/ext/multi-irb" 12 | 13 | IRB.irb(nil, AocCli) 14 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /db/migrate/1_create_events.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :events do 4 | primary_key :id 5 | 6 | integer :year, null: false 7 | 8 | datetime :created_at 9 | datetime :updated_at 10 | 11 | index :year 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/2_create_puzzles.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :puzzles do 4 | primary_key :id 5 | 6 | foreign_key :event_id, :events, null: false 7 | 8 | integer :day, null: false 9 | 10 | text :content, null: false 11 | text :input, null: false 12 | 13 | datetime :created_at 14 | datetime :updated_at 15 | datetime :part_one_completed_at 16 | datetime :part_two_completed_at 17 | 18 | index :event_id 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/migrate/3_create_stats.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :stats do 4 | primary_key :id 5 | 6 | foreign_key :event_id, :events, null: false 7 | 8 | 1.upto(25) do |i| 9 | integer :"day_#{i}", null: false, default: 0 10 | end 11 | 12 | datetime :created_at 13 | datetime :updated_at 14 | datetime :completed_at 15 | 16 | index :event_id 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/4_create_attempts.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :attempts do 4 | primary_key :id 5 | 6 | foreign_key :puzzle_id, :puzzles, null: false 7 | 8 | integer :level, null: false 9 | text :answer, null: false 10 | 11 | integer :status, null: false 12 | integer :hint 13 | integer :wait_time 14 | 15 | datetime :created_at 16 | datetime :updated_at 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/5_create_locations.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :locations do 4 | primary_key :id 5 | 6 | string :path, null: false 7 | 8 | integer :resource_id, null: false 9 | string :resource_type, null: false 10 | 11 | datetime :created_at 12 | datetime :updated_at 13 | 14 | index %i[path resource_id resource_type] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/6_create_puzzle_dir_sync_logs.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :puzzle_dir_sync_logs do 4 | primary_key :id 5 | 6 | foreign_key :puzzle_id, :puzzles, null: false 7 | foreign_key :location_id, :locations, null: false 8 | 9 | integer :puzzle_status, null: false 10 | integer :input_status, null: false 11 | 12 | datetime :created_at 13 | datetime :updated_at 14 | 15 | index %i[puzzle_id location_id] 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/7_create_progresses.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :progresses do 4 | primary_key :id 5 | 6 | foreign_key :puzzle_id, :puzzles, null: false 7 | 8 | integer :level, null: false 9 | 10 | datetime :started_at, null: false 11 | datetime :completed_at 12 | 13 | datetime :created_at 14 | datetime :updated_at 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/8_remove_legacy_timestamps.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table :puzzles do 4 | drop_column :part_one_completed_at 5 | drop_column :part_two_completed_at 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /exe/aoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "aoc_cli" 4 | 5 | AocCli.run!(*ARGV) 6 | -------------------------------------------------------------------------------- /lib/aoc_cli.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "forwardable" 3 | require "http" 4 | require "kangaru" 5 | require "nokogiri" 6 | require "reverse_markdown" 7 | require "terminal-table" 8 | 9 | module AocCli 10 | extend Kangaru::Initialiser 11 | 12 | config_path File.expand_path("~/.config/aoc_cli/config.yml") 13 | 14 | config_path "spec/aoc.yml", env: :test 15 | 16 | configure do |config| 17 | migration_path = File.join( 18 | File.expand_path(__dir__ || raise), "../db/migrate" 19 | ) 20 | 21 | config.database.adaptor = :sqlite 22 | config.database.path = File.expand_path("~/.local/share/aoc/aoc.sqlite3") 23 | config.database.migration_path = migration_path 24 | end 25 | 26 | configure env: :test do |config| 27 | config.database.path = "db/test.sqlite3" 28 | end 29 | 30 | apply_config! 31 | 32 | Sequel::Model.plugin(:polymorphic) 33 | Sequel::Model.plugin(:enum) 34 | 35 | Terminal::Table::Style.defaults = { 36 | border: :unicode, 37 | alignment: :center 38 | } 39 | end 40 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/attempts_table.erb: -------------------------------------------------------------------------------- 1 | <% if puzzle.attempts.empty? -%> 2 | No attempts have been made for this puzzle. 3 | <% else -%> 4 | <%= Terminal::Table.new(title:, headings:, rows:) %> 5 | <% end -%> 6 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/attempts_table.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class AttemptsTable < Kangaru::Component 4 | attr_reader :puzzle 5 | 6 | def initialize(puzzle:) 7 | @puzzle = puzzle 8 | end 9 | 10 | def title 11 | "Advent of Code: #{puzzle.presenter.date}" 12 | end 13 | 14 | def headings 15 | %w[Answer Status Time Hint] 16 | end 17 | 18 | def rows 19 | [*level_one_rows, separator, *level_two_rows].compact 20 | end 21 | 22 | private 23 | 24 | def level_one_rows 25 | @level_one_rows ||= rows_for(level_one_attempts) 26 | end 27 | 28 | def separator 29 | return if level_one_rows.empty? || level_two_rows.empty? 30 | 31 | :separator 32 | end 33 | 34 | def level_two_rows 35 | @level_two_rows ||= rows_for(level_two_attempts) 36 | end 37 | 38 | def level_one_attempts 39 | puzzle.attempts_dataset.where(level: 1).order(:created_at).to_a 40 | end 41 | 42 | def level_two_attempts 43 | puzzle.attempts_dataset.where(level: 2).order(:created_at).to_a 44 | end 45 | 46 | def rows_for(attempts) 47 | attempts.map do |attempt| 48 | [ 49 | attempt.answer, 50 | attempt.presenter.status, 51 | attempt.created_at, 52 | attempt.presenter.hint 53 | ] 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/docs_component.erb: -------------------------------------------------------------------------------- 1 | 2 | <%= heading(title) %> <<%= AocCli::VERSION.blue %>> 3 | 4 | A command-line interface for the Advent of Code puzzles. 5 | 6 | Features include downloading puzzles and inputs, solving puzzles and 7 | tracking year progress from within the terminal. 8 | 9 | This is an unofficial project with no affiliation to Advent of Code. 10 | 11 | <% endpoints.each do |controller, data| %> 12 | <%= heading(controller) %> 13 | 14 | <%= data[:description] %> 15 | 16 | Usage: <%= "aoc #{controller}".blue %> <%= "".cyan %> 17 | 18 | <%= commands_table(data[:commands], indent: 4) %> 19 | <% end -%> 20 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/docs_component.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class DocsComponent < Kangaru::Component 4 | include Helpers::ViewHelper 5 | 6 | ENDPOINTS = { 7 | event: { 8 | description: "Handle event directories", 9 | commands: { 10 | init: "Create and initialise an event directory", 11 | progress: "Check your progress for the current event" 12 | } 13 | }, 14 | 15 | puzzle: { 16 | description: "Handle puzzle directories", 17 | commands: { 18 | init: "Fetch and initialise puzzles for the current event", 19 | solve: "Submit and evaluate a puzzle solution", 20 | sync: "Ensure puzzle files are up to date", 21 | attempts: "Review previous attempts for the current puzzle" 22 | } 23 | } 24 | }.freeze 25 | 26 | def endpoints 27 | ENDPOINTS 28 | end 29 | 30 | def title 31 | "Advent of Code CLI" 32 | end 33 | 34 | def commands_table(commands, indent: 0) 35 | rows = commands.transform_keys(&:to_s).to_a 36 | 37 | table_for(*rows, gap: 4, indent:) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/errors_component.erb: -------------------------------------------------------------------------------- 1 | <% if messages.count == 1 -%> 2 | <%= title.red %>: <%= messages.first %> 3 | <% else -%> 4 | <%= title.red %>: 5 | <% messages.each do |message| -%> 6 | <%= "\u2022 #{message}" %> 7 | <% end -%> 8 | <% end -%> 9 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/errors_component.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class ErrorsComponent < Kangaru::Component 4 | attr_reader :messages 5 | 6 | def initialize(*messages) 7 | @messages = messages 8 | end 9 | 10 | # TODO: remove once Kangaru has native conditional rendering 11 | def render 12 | return unless render? 13 | 14 | super 15 | end 16 | 17 | def self.from_model(model) 18 | errors = model.errors.map(&:full_message) 19 | 20 | new(*errors) 21 | end 22 | 23 | private 24 | 25 | def render? 26 | !messages.empty? 27 | end 28 | 29 | # TODO: move this to a const when Kangaru allows binding-agnostic 30 | # component constants (like controllers). 31 | def title 32 | "Error" 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/progress_table.erb: -------------------------------------------------------------------------------- 1 | <%= Terminal::Table.new(headings:, rows:, title:) %> 2 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/progress_table.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class ProgressTable < Kangaru::Component 4 | attr_reader :event 5 | 6 | def initialize(event:) 7 | @event = event 8 | end 9 | 10 | private 11 | 12 | TITLE = "Advent of Code: %{year}".freeze 13 | HEADINGS = %w[Day Progress].freeze 14 | PROGRESS = "Day %{day}".freeze 15 | TOTAL = "Total".freeze 16 | 17 | def title 18 | format(TITLE, year: event.year) 19 | end 20 | 21 | def headings 22 | HEADINGS 23 | end 24 | 25 | def rows 26 | [*progress_rows, :separator, total_row] 27 | end 28 | 29 | def progress_rows 30 | 1.upto(25).map do |day| 31 | [ 32 | format(PROGRESS, day:), 33 | event.stats.presenter.progress_icons(day) 34 | ] 35 | end 36 | end 37 | 38 | def total_row 39 | [TOTAL, event.stats.presenter.total_progress] 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/puzzle_sync_component.erb: -------------------------------------------------------------------------------- 1 | Puzzle <%= puzzle.presenter.date %> dir synchronised 2 | <%= table_for(*status_rows, indent: 2) -%> 3 | -------------------------------------------------------------------------------- /lib/aoc_cli/components/puzzle_sync_component.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class PuzzleSyncComponent < Kangaru::Component 4 | extend Forwardable 5 | 6 | include Helpers::ViewHelper 7 | 8 | attr_reader :log 9 | 10 | def_delegators :log, :puzzle 11 | 12 | def initialize(log:) 13 | @log = log 14 | end 15 | 16 | private 17 | 18 | def status_rows 19 | [ 20 | [puzzle.presenter.puzzle_filename, log.puzzle_status], 21 | [puzzle.presenter.input_filename, log.input_status] 22 | ] 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/aoc_cli/configurators/session_configurator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Configurators 3 | class SessionConfigurator < Kangaru::Configurator 4 | attr_accessor :token 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class ApplicationController < Kangaru::Controller 3 | include Concerns::ErrorConcern 4 | include Concerns::LocationConcern 5 | 6 | include Helpers::ViewHelper 7 | 8 | def execute 9 | return handle_help_param! if params[:help] 10 | 11 | super 12 | rescue Core::Processor::Error => e 13 | render_model_errors!(e.processor) 14 | end 15 | 16 | private 17 | 18 | def handle_help_param! 19 | params.delete(:help) 20 | 21 | path = File.join("/help", request.path) 22 | request = Kangaru::Request.new(path:, params:) 23 | 24 | Kangaru.application!.router.resolve(request) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/concerns/error_concern.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Concerns 3 | module ErrorConcern 4 | def render_error!(error) 5 | Components::ErrorsComponent.new(error).render 6 | 7 | false 8 | end 9 | 10 | def render_model_errors!(model) 11 | Components::ErrorsComponent.from_model(model).render 12 | 13 | false 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/concerns/location_concern.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Concerns 3 | module LocationConcern 4 | def current_path 5 | File.expand_path(".") 6 | end 7 | 8 | def current_location 9 | Location.first(path: current_path) 10 | end 11 | 12 | def current_event 13 | case current_resource 14 | when Event then current_resource 15 | when Puzzle then current_resource.event 16 | end 17 | end 18 | 19 | def current_puzzle 20 | case current_resource 21 | when Puzzle then current_resource 22 | end 23 | end 24 | 25 | def ensure_in_event_dir! 26 | return true if in_event_dir? 27 | 28 | render_error!(ERRORS[:not_in_event]) 29 | end 30 | 31 | def ensure_in_puzzle_dir! 32 | return true if in_puzzle_dir? 33 | 34 | render_error!(ERRORS[:not_in_puzzle]) 35 | end 36 | 37 | def ensure_in_aoc_dir! 38 | return true if in_aoc_dir? 39 | 40 | render_error!(ERRORS[:not_in_aoc]) 41 | end 42 | 43 | def ensure_not_in_aoc_dir! 44 | return true unless in_aoc_dir? 45 | 46 | render_error!(ERRORS[:in_aoc]) 47 | end 48 | 49 | private 50 | 51 | ERRORS = { 52 | not_in_event: 53 | "Action can't be performed outside event directory", 54 | not_in_puzzle: 55 | "Action can't be performed outside puzzle directory", 56 | not_in_aoc: 57 | "Action can't be performed outside Advent of Code directory", 58 | in_aoc: 59 | "Action can't be performed from Advent of Code directory" 60 | }.freeze 61 | 62 | def current_resource 63 | current_location&.resource 64 | end 65 | 66 | def in_event_dir? 67 | current_location&.event_dir? || false 68 | end 69 | 70 | def in_puzzle_dir? 71 | current_location&.puzzle_dir? || false 72 | end 73 | 74 | def in_aoc_dir? 75 | in_event_dir? || in_puzzle_dir? 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/default_controller.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class DefaultController < ApplicationController 3 | def default 4 | Components::DocsComponent.new.render 5 | end 6 | 7 | alias help default 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/event_controller.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class EventController < ApplicationController 3 | def init 4 | return unless ensure_not_in_aoc_dir! 5 | 6 | @event = Processors::EventInitialiser.run!( 7 | year: target_id, 8 | dir: params[:dir] || current_path 9 | ) 10 | end 11 | 12 | def attach 13 | return render_error!("Event does not exist") if queried_event.nil? 14 | 15 | @source = queried_event.location.path 16 | 17 | @target = Processors::ResourceAttacher.run!( 18 | resource: queried_event, 19 | path: params[:dir] || current_path 20 | ).path 21 | end 22 | 23 | def progress 24 | return unless ensure_in_aoc_dir! 25 | 26 | Components::ProgressTable.new(event: current_event || raise).render 27 | end 28 | 29 | private 30 | 31 | def queried_event 32 | @queried_event ||= Event.first(year: target_id) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/help/event_controller.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Help 3 | class EventController < ApplicationController 4 | def init = true 5 | 6 | def attach = true 7 | 8 | def progress = true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/help/puzzle_controller.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Help 3 | class PuzzleController < ApplicationController 4 | def init = true 5 | 6 | def solve = true 7 | 8 | def sync = true 9 | 10 | def attempts = true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/aoc_cli/controllers/puzzle_controller.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class PuzzleController < ApplicationController 3 | def init 4 | return unless ensure_in_event_dir! 5 | 6 | @puzzle = Processors::PuzzleInitialiser.run!( 7 | event: current_event, 8 | day: target_id 9 | ) 10 | end 11 | 12 | def solve 13 | return unless ensure_in_puzzle_dir! 14 | 15 | @attempt = Processors::SolutionPoster.run!( 16 | puzzle: current_puzzle, 17 | answer: params[:answer] 18 | ) 19 | end 20 | 21 | def sync 22 | return unless ensure_in_puzzle_dir! 23 | 24 | @sync_log = Processors::PuzzleDirSynchroniser.run!( 25 | puzzle: current_puzzle, 26 | location: current_location, 27 | skip_cache: params[:skip_cache] || false 28 | ) 29 | 30 | Components::PuzzleSyncComponent.new(log: @sync_log).render 31 | end 32 | 33 | def attempts 34 | return unless ensure_in_puzzle_dir! 35 | 36 | Components::AttemptsTable.new(puzzle: current_puzzle || raise).render 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aoc_cli/core/attempt_parser.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class AttemptParser 4 | attr_reader :response 5 | 6 | def initialize(response) 7 | @response = response 8 | end 9 | 10 | def to_h 11 | { status:, hint:, wait_time: }.compact 12 | end 13 | 14 | private 15 | 16 | module Prefixes 17 | CORRECT = /^That's the right answer/ 18 | INCORRECT = /^That's not the right answer/ 19 | RATE_LIMITED = /^You gave an answer too recently/ 20 | WRONG_LEVEL = /^You don't seem to be solving the right level/ 21 | end 22 | 23 | module Hints 24 | TOO_LOW = /your answer is too low/ 25 | TOO_HIGH = /your answer is too high/ 26 | end 27 | 28 | module WaitTimes 29 | ONE_MINUTE = /one minute/ 30 | INCORRECT_FORMAT = /(\d+) minutes/ 31 | RATE_LIMITED_FORMAT = /(?:(\d+)m)/ 32 | end 33 | 34 | def status 35 | case response 36 | when Prefixes::CORRECT then :correct 37 | when Prefixes::INCORRECT then :incorrect 38 | when Prefixes::RATE_LIMITED then :rate_limited 39 | when Prefixes::WRONG_LEVEL then :wrong_level 40 | else raise "unexpected response" 41 | end 42 | end 43 | 44 | def hint 45 | case response 46 | when Hints::TOO_LOW then :too_low 47 | when Hints::TOO_HIGH then :too_high 48 | end 49 | end 50 | 51 | def wait_time 52 | case status 53 | when :incorrect then scan_incorrect_wait_time! 54 | when :rate_limited then scan_rated_limited_wait_time! 55 | end 56 | end 57 | 58 | def scan_incorrect_wait_time! 59 | return 1 if response.match?(WaitTimes::ONE_MINUTE) 60 | 61 | response.scan(WaitTimes::INCORRECT_FORMAT).flatten.first.to_i 62 | end 63 | 64 | def scan_rated_limited_wait_time! 65 | response.scan(WaitTimes::RATE_LIMITED_FORMAT).flatten.first.to_i 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/aoc_cli/core/processor.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Processor 4 | extend Forwardable 5 | 6 | include Kangaru::Attributable 7 | include Kangaru::Validatable 8 | 9 | class Error < StandardError 10 | attr_reader :processor 11 | 12 | def initialize(processor) 13 | @processor = processor 14 | end 15 | end 16 | 17 | def run 18 | raise NotImplementedError 19 | end 20 | 21 | def run! 22 | raise(Error, self) unless valid? 23 | 24 | run 25 | end 26 | 27 | def self.run!(...) 28 | new(...).run! 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/aoc_cli/core/repository.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Repository 4 | HOST = "https://adventofcode.com".freeze 5 | 6 | RESOURCES = { 7 | stats: { 8 | url: "%{host}/%{year}", 9 | scope: "html/body/main/pre", 10 | method: :get 11 | }, 12 | 13 | puzzle: { 14 | url: "%{host}/%{year}/day/%{day}", 15 | scope: "html/body/main/article", 16 | method: :get 17 | }, 18 | 19 | input: { 20 | url: "%{host}/%{year}/day/%{day}/input", 21 | method: :get 22 | }, 23 | 24 | solution: { 25 | url: "%{host}/%{year}/day/%{day}/answer", 26 | scope: "html/body/main/article", 27 | method: :post, 28 | params: %i[level answer] 29 | } 30 | }.freeze 31 | 32 | class << self 33 | def get_stats(year:) 34 | html = build_resource(:stats, year:).fetch 35 | 36 | StatsParser.new(html).to_h 37 | end 38 | 39 | def get_puzzle(year:, day:) 40 | build_resource(:puzzle, year:, day:).fetch_markdown 41 | end 42 | 43 | def get_input(year:, day:) 44 | build_resource(:input, year:, day:).fetch 45 | end 46 | 47 | def post_solution(year:, day:, level:, answer:) 48 | response = build_resource( 49 | :solution, year:, day:, level:, answer: 50 | ).fetch_markdown 51 | 52 | AttemptParser.new(response).to_h 53 | end 54 | 55 | private 56 | 57 | def build_resource(type, **params) 58 | attributes = RESOURCES[type] 59 | 60 | Resource.new( 61 | url: format_url(attributes[:url], **params), 62 | scope: attributes[:scope], 63 | method: attributes[:method], 64 | params: params.slice(*attributes[:params]) 65 | ) 66 | end 67 | 68 | def format_url(template, **params) 69 | format(template, host: HOST, **params) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/aoc_cli/core/request.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Request 4 | extend Forwardable 5 | 6 | attr_reader :client 7 | 8 | def_delegators :client, :get, :post 9 | 10 | def initialize(token:) 11 | @client = setup_client!(token) 12 | end 13 | 14 | def self.build 15 | token = AocCli.config.session.token 16 | 17 | raise "session token not set" if token.nil? 18 | 19 | new(token:) 20 | end 21 | 22 | class << self 23 | extend Forwardable 24 | 25 | def_delegators :build, :get, :post 26 | end 27 | 28 | private 29 | 30 | def setup_client!(token) 31 | cookie = "session=#{token}" 32 | 33 | HTTP.headers(Cookie: cookie) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/aoc_cli/core/resource.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | # Scope filters the response with the given xpath which ensures that only 4 | # desired content is extracted from the (usually) HTML response. The whole 5 | # response is considered the resource payload if no scope is specified. 6 | class Resource 7 | attr_reader :url, :scope, :method, :params 8 | 9 | def initialize(url:, scope: nil, method: :get, params: {}) 10 | @url = url 11 | @scope = scope 12 | @method = method 13 | @params = params 14 | end 15 | 16 | def fetch 17 | return response if scope.nil? 18 | 19 | Nokogiri.HTML5(response).xpath(scope).to_html 20 | end 21 | 22 | # Bypess ignores unknown tags and tries to convert their nested content. 23 | # The parser adds an extra newline to the end which is removed with chomp. 24 | def fetch_markdown 25 | ReverseMarkdown.convert(fetch, unknown_tags: :bypass).chomp 26 | end 27 | 28 | private 29 | 30 | def response 31 | case method 32 | when :get then Request.get(url) 33 | when :post then Request.post(url, form: params) 34 | else raise "invalid HTTP method" 35 | end.to_s 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aoc_cli/core/stats_parser.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | # Calculates a User's year progress by parsing the CSS classes that are 4 | # applied to each calendar link in the calendar view. 5 | class StatsParser 6 | DAY_CLASS_PREFIX = "calendar-day".freeze 7 | ONE_STAR_CLASS = "calendar-complete".freeze 8 | TWO_STARS_CLASS = "calendar-verycomplete".freeze 9 | 10 | attr_reader :calendar_html 11 | 12 | def initialize(calendar_html) 13 | @calendar_html = calendar_html 14 | end 15 | 16 | def to_h 17 | calendar_link_classes.to_h do |classes| 18 | day = classes[0].delete_prefix(DAY_CLASS_PREFIX) 19 | n_stars = count_stars(classes) 20 | 21 | [:"day_#{day}", n_stars] 22 | end 23 | end 24 | 25 | private 26 | 27 | def calendar_link_classes 28 | calendar_links.map(&:classes) 29 | end 30 | 31 | def calendar_links 32 | Nokogiri.HTML5(calendar_html).xpath("//a") 33 | end 34 | 35 | def count_stars(classes) 36 | return 2 if classes.include?(TWO_STARS_CLASS) 37 | return 1 if classes.include?(ONE_STAR_CLASS) 38 | 39 | 0 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/aoc_cli/helpers/table_generator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Helpers 3 | class TableGenerator 4 | include Kangaru::Validatable 5 | 6 | attr_reader :rows, :gap, :indent 7 | 8 | validates :rows, collection_type: { all: Array } 9 | 10 | def initialize(rows:, gap: 2, indent: 0) 11 | @rows = rows 12 | @gap = gap 13 | @indent = indent 14 | end 15 | 16 | # TODO: Use validates via method for checking row length once supported. 17 | def validate 18 | super 19 | validate_rows_are_same_length! if errors.empty? 20 | end 21 | 22 | # TODO: Remove once validate! merged upstream. 23 | def validate! 24 | validate 25 | 26 | raise errors.map(&:full_message).join("\n") unless errors.empty? 27 | end 28 | 29 | def generate! 30 | validate! 31 | 32 | rows.map do |row| 33 | [space(indent), format_row!(row).strip, "\n"].join 34 | end.join 35 | end 36 | 37 | private 38 | 39 | def column_widths 40 | @column_widths ||= rows.transpose.map do |column| 41 | column.map(&:length).max + gap 42 | end 43 | end 44 | 45 | def space(count) 46 | " " * count 47 | end 48 | 49 | def format_row!(row) 50 | row.zip(column_widths).map do |string, column_width| 51 | string + space((column_width || raise) - string.length) 52 | end.join 53 | end 54 | 55 | def validate_rows_are_same_length! 56 | return if rows.all? { |row| row.length == rows[0].length } 57 | 58 | errors << Kangaru::Validation::Error.new( 59 | attribute: :rows, message: "must all be the same length" 60 | ) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/aoc_cli/helpers/view_helper.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Helpers 3 | module ViewHelper 4 | def main_header 5 | heading("aoc-cli::<#{VERSION}>") 6 | end 7 | 8 | def heading(text) 9 | text.to_s.bold.cyan 10 | end 11 | 12 | def success_tag 13 | "Success".green.bold 14 | end 15 | 16 | def table_for(*rows, gap: 2, indent: 0) 17 | rows.map! { |row| row.map(&:to_s) } 18 | 19 | TableGenerator.new(rows:, gap:, indent:).generate! 20 | end 21 | 22 | def wrap_text(text, width: 80, indent: 0) 23 | raise "indent must be less than width" unless indent < width 24 | 25 | text.gsub( 26 | # Match the longest string (up to the indented width) thats followed 27 | # by a non-word char or any combination of newlines. 28 | /(.{1,#{width - indent}})(?:[^\S\n]+\n?|\n*\Z|\n)|\n/, 29 | # Surround string fragment with indent and newline. 30 | "#{' ' * indent}\\1\n" 31 | ).chomp 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/attempt.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Attempt < Kangaru::Model 3 | many_to_one :puzzle 4 | 5 | enum :status, incorrect: 0, correct: 1, rate_limited: 2, wrong_level: 3 6 | 7 | enum :hint, too_low: 0, too_high: 1 8 | 9 | validates :puzzle, required: true 10 | 11 | validates :level, integer: { between: 1..2 } 12 | 13 | validates :answer, required: true 14 | 15 | validates :status, included: { 16 | in: %i[incorrect correct rate_limited wrong_level] 17 | } 18 | 19 | def presenter 20 | @presenter ||= Presenters::AttemptPresenter.new(self) 21 | end 22 | 23 | # TODO: move to a conditional validation when supported by Kangaru 24 | def validate 25 | super 26 | validate_hint_not_set! unless incorrect? 27 | validate_hint! if incorrect? 28 | validate_wait_time_not_set! if correct? || wrong_level? 29 | validate_wait_time_integer! if incorrect? || rate_limited? 30 | end 31 | 32 | private 33 | 34 | def validate_hint_not_set! 35 | return if hint.nil? 36 | 37 | errors << Kangaru::Validation::Error.new( 38 | attribute: :hint, message: "is not expected" 39 | ) 40 | end 41 | 42 | def validate_hint! 43 | return if hint.nil? 44 | return if %i[too_low too_high].include?(hint) 45 | 46 | errors << Kangaru::Validation::Error.new( 47 | attribute: :hint, message: "is not a valid option" 48 | ) 49 | end 50 | 51 | def validate_wait_time_not_set! 52 | return if wait_time.nil? 53 | 54 | errors << Kangaru::Validation::Error.new( 55 | attribute: :wait_time, message: "is not expected" 56 | ) 57 | end 58 | 59 | def validate_wait_time_integer! 60 | return if wait_time.is_a?(Integer) 61 | 62 | errors << Kangaru::Validation::Error.new( 63 | attribute: :wait_time, message: "is not an integer" 64 | ) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/event.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Event < Kangaru::Model 3 | one_to_many :puzzles 4 | one_to_one :stats 5 | one_to_one :location, as: :resource 6 | 7 | validates :year, event_year: true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/location.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Location < Kangaru::Model 3 | many_to_one :resource, polymorphic: true 4 | 5 | validates :resource, required: true 6 | validates :path, required: true 7 | 8 | def exists? 9 | File.exist?(path) 10 | end 11 | 12 | def event_dir? 13 | resource.is_a?(Event) 14 | end 15 | 16 | def puzzle_dir? 17 | resource.is_a?(Puzzle) 18 | end 19 | 20 | def to_pathname 21 | Pathname.new(path) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/progress.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Progress < Kangaru::Model 3 | many_to_one :puzzle 4 | 5 | validates :puzzle, required: true 6 | validates :level, integer: { between: 1..2 } 7 | validates :started_at, required: true 8 | 9 | def complete? 10 | !incomplete? 11 | end 12 | 13 | def incomplete? 14 | completed_at.nil? 15 | end 16 | 17 | def complete! 18 | update(completed_at: Time.now) 19 | end 20 | 21 | def reset! 22 | update(started_at: Time.now) 23 | end 24 | 25 | def time_taken 26 | if complete? 27 | completed_at - started_at 28 | else 29 | Time.now - started_at 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/puzzle.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Puzzle < Kangaru::Model 3 | extend Forwardable 4 | 5 | many_to_one :event 6 | one_to_one :location, as: :resource 7 | one_to_many :attempts 8 | 9 | one_to_one :part_one_progress, 10 | class: "AocCli::Progress", 11 | conditions: { level: 1 } 12 | 13 | one_to_one :part_two_progress, 14 | class: "AocCli::Progress", 15 | conditions: { level: 2 } 16 | 17 | validates :event, required: true 18 | validates :day, integer: { between: 1..25 } 19 | validates :content, required: true 20 | validates :input, required: true 21 | 22 | def_delegators :event, :year 23 | 24 | def presenter 25 | @presenter ||= Presenters::PuzzlePresenter.new(self) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/puzzle_dir_sync_log.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class PuzzleDirSyncLog < Kangaru::Model 3 | many_to_one :location 4 | many_to_one :puzzle 5 | 6 | validates :location, required: true 7 | validates :puzzle, required: true 8 | 9 | STATUS_ENUM = { new: 0, unmodified: 1, modified: 2 }.freeze 10 | 11 | enum :puzzle_status, STATUS_ENUM, prefix: true 12 | enum :input_status, STATUS_ENUM, prefix: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/aoc_cli/models/stats.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Stats < Kangaru::Model 3 | extend Forwardable 4 | 5 | many_to_one :event 6 | 7 | validates :event, required: true 8 | 9 | def_delegators :event, :year 10 | 11 | 1.upto(25) do |i| 12 | validates :"day_#{i}", integer: { between: 0..2 } 13 | end 14 | 15 | def presenter 16 | @presenter ||= Presenters::StatsPresenter.new(self) 17 | end 18 | 19 | def total 20 | 1.upto(25).map { |day| progress(day) }.sum 21 | end 22 | 23 | def progress(day) 24 | self[:"day_#{day}"] || raise("invalid day") 25 | end 26 | 27 | def current_level(day) 28 | return if complete?(day) 29 | 30 | progress(day) + 1 31 | end 32 | 33 | def complete?(day) 34 | progress(day) == 2 35 | end 36 | 37 | def advance_progress!(day) 38 | raise "already complete" if complete?(day) 39 | 40 | update("day_#{day}": current_level(day)) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/aoc_cli/presenters/attempt_presenter.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Presenters 3 | class AttemptPresenter 4 | attr_reader :attempt 5 | 6 | def initialize(attempt) 7 | @attempt = attempt 8 | end 9 | 10 | def status 11 | case attempt.status 12 | when :wrong_level then "Wrong level" 13 | when :rate_limited then "Rate limited" 14 | when :incorrect then "Incorrect" 15 | when :correct then "Correct" 16 | else raise 17 | end 18 | end 19 | 20 | def hint 21 | case attempt.hint 22 | when :too_low then "Too low" 23 | when :too_high then "Too high" 24 | else "-" 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/aoc_cli/presenters/puzzle_presenter.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Presenters 3 | class PuzzlePresenter 4 | attr_reader :puzzle 5 | 6 | def initialize(puzzle) 7 | @puzzle = puzzle 8 | end 9 | 10 | def date 11 | "#{puzzle.event.year}-12-#{formatted_day}" 12 | end 13 | 14 | def puzzle_filename 15 | "day_#{formatted_day}.md" 16 | end 17 | 18 | def input_filename 19 | "input" 20 | end 21 | 22 | private 23 | 24 | def formatted_day 25 | puzzle.day.to_s.rjust(2, "0") 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/aoc_cli/presenters/stats_presenter.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Presenters 3 | class StatsPresenter 4 | attr_reader :stats 5 | 6 | def initialize(stats) 7 | @stats = stats 8 | end 9 | 10 | def total_progress 11 | total = 1.upto(25).map { |day| stats.progress(day) }.sum 12 | 13 | "#{total}/50" 14 | end 15 | 16 | def progress_icons(day) 17 | case stats.progress(day) 18 | when 0 then Icons::INCOMPLETE 19 | when 1 then Icons::HALF_COMPLETE 20 | when 2 then Icons::COMPLETE 21 | else raise 22 | end 23 | end 24 | 25 | module Icons 26 | INCOMPLETE = "○ ○".freeze 27 | HALF_COMPLETE = "● ○".freeze 28 | COMPLETE = "● ●".freeze 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/event_initialiser.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class EventInitialiser < Core::Processor 4 | validates :year, event_year: true 5 | 6 | validates :dir, path: { exists: true } 7 | 8 | attr_accessor :year, :dir 9 | 10 | # TODO: use conditional validation once supported by Kangaru 11 | def validate 12 | super 13 | validate_event_not_already_initialised! if errors.empty? 14 | validate_event_dir_does_not_exist! if errors.empty? 15 | end 16 | 17 | def run 18 | create_event!.tap do |event| 19 | initialise_stats!(event) 20 | make_event_directory! 21 | attach_event!(event) 22 | end 23 | end 24 | 25 | private 26 | 27 | def event_dir 28 | @event_dir ||= Pathname(dir).join(year.to_s) 29 | end 30 | 31 | def create_event! 32 | Event.create(year:) 33 | end 34 | 35 | def initialise_stats!(event) 36 | StatsInitialiser.run!(event:) 37 | end 38 | 39 | def make_event_directory! 40 | event_dir.mkdir 41 | end 42 | 43 | def attach_event!(event) 44 | ResourceAttacher.run!(resource: event, path: event_dir.to_s) 45 | end 46 | 47 | def validate_event_dir_does_not_exist! 48 | return unless event_dir.exist? 49 | 50 | errors << Kangaru::Validation::Error.new( 51 | attribute: :event_dir, message: "already exists" 52 | ) 53 | end 54 | 55 | def validate_event_not_already_initialised! 56 | return if Event.where(year:).empty? 57 | 58 | errors << Kangaru::Validation::Error.new( 59 | attribute: :event, message: "already initialised" 60 | ) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/progress_syncer.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class ProgressSyncer < Core::Processor 4 | attr_accessor :puzzle, :stats 5 | 6 | validates :puzzle, required: true 7 | validates :stats, required: true 8 | 9 | def run 10 | case current_progress 11 | when 0 then handle_not_complete! 12 | when 1 then handle_partially_complete! 13 | when 2 then handle_fully_complete! 14 | else raise 15 | end 16 | end 17 | 18 | private 19 | 20 | def_delegators :puzzle, :part_one_progress, :part_two_progress 21 | 22 | def current_progress 23 | stats.progress(puzzle.day) 24 | end 25 | 26 | def create_part_one_progress! 27 | Progress.create(puzzle:, level: 1, started_at: Time.now) 28 | end 29 | 30 | def create_part_two_progress! 31 | Progress.create(puzzle:, level: 2, started_at: Time.now) 32 | end 33 | 34 | def handle_not_complete! 35 | create_part_one_progress! if part_one_progress.nil? 36 | end 37 | 38 | def handle_partially_complete! 39 | part_one_progress&.complete! if part_one_progress&.incomplete? 40 | create_part_two_progress! if part_two_progress.nil? 41 | end 42 | 43 | def handle_fully_complete! 44 | part_two_progress&.complete! if part_two_progress&.incomplete? 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/puzzle_dir_synchroniser.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class PuzzleDirSynchroniser < Core::Processor 4 | extend Forwardable 5 | 6 | attr_accessor :puzzle, :location, :skip_cache 7 | 8 | set_default skip_cache: false 9 | 10 | validates :puzzle, required: true 11 | 12 | validates :location, required: true 13 | 14 | # TODO: replace with conditional validation 15 | def validate 16 | super 17 | validate_puzzle_dir_exists! if errors.empty? 18 | end 19 | 20 | def run 21 | refresh_puzzle! if skip_cache 22 | 23 | create_puzzle_dir_sync_log!.tap do 24 | puzzle_file.write(puzzle.content) 25 | input_file.write(puzzle.input) 26 | end 27 | end 28 | 29 | private 30 | 31 | def_delegators :puzzle, :year, :day, :presenter 32 | 33 | def_delegators :presenter, :puzzle_filename, :input_filename 34 | 35 | def puzzle_dir 36 | @puzzle_dir ||= location.to_pathname 37 | end 38 | 39 | def puzzle_file 40 | @puzzle_file ||= puzzle_dir.join(puzzle_filename) 41 | end 42 | 43 | def input_file 44 | @input_file ||= puzzle_dir.join(input_filename) 45 | end 46 | 47 | def refresh_puzzle! 48 | PuzzleRefresher.run!(puzzle:) 49 | 50 | puzzle.reload 51 | end 52 | 53 | def create_puzzle_dir_sync_log! 54 | PuzzleDirSyncLog.create( 55 | puzzle:, location:, puzzle_status:, input_status: 56 | ) 57 | end 58 | 59 | def puzzle_status 60 | return :new unless puzzle_file.exist? 61 | 62 | puzzle_file.read == puzzle.content ? :unmodified : :modified 63 | end 64 | 65 | def input_status 66 | return :new unless input_file.exist? 67 | 68 | input_file.read == puzzle.input ? :unmodified : :modified 69 | end 70 | 71 | def validate_puzzle_dir_exists! 72 | return if puzzle_dir.exist? 73 | 74 | errors << Kangaru::Validation::Error.new( 75 | attribute: :puzzle_dir, message: "does not exist" 76 | ) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/puzzle_initialiser.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class PuzzleInitialiser < Core::Processor 4 | attr_accessor :event, :day 5 | 6 | validates :event, required: true 7 | validates :day, integer: { between: 1..25 } 8 | 9 | def validate 10 | super 11 | validate_event_stats_set! if errors.empty? 12 | validate_event_location_set! if errors.empty? 13 | validate_event_dir_exists! if errors.empty? 14 | validate_puzzle_dir_does_not_exist! if errors.empty? 15 | end 16 | 17 | def run 18 | create_or_update_puzzle!(fetch_content!, fetch_input!).tap do |puzzle| 19 | sync_puzzle_progress!(puzzle) 20 | 21 | attach_puzzle_dir!(puzzle).tap do |location| 22 | write_puzzle_files!(puzzle, location) 23 | end 24 | end 25 | end 26 | 27 | private 28 | 29 | def_delegators :event, :year 30 | 31 | def event_dir 32 | @event_dir ||= event.location.to_pathname 33 | end 34 | 35 | def puzzle_dir 36 | @puzzle_dir ||= event_dir.join(day.to_s) 37 | end 38 | 39 | def existing_puzzle 40 | @existing_puzzle ||= Puzzle.first(event:, day:) 41 | end 42 | 43 | def fetch_content! 44 | Core::Repository.get_puzzle(year:, day:) 45 | end 46 | 47 | def fetch_input! 48 | Core::Repository.get_input(year:, day:) 49 | end 50 | 51 | def create_or_update_puzzle!(content, input) 52 | if existing_puzzle.nil? 53 | Puzzle.create(event:, day:, content:, input:) 54 | else 55 | existing_puzzle.update(content:, input:) || existing_puzzle 56 | end 57 | end 58 | 59 | def sync_puzzle_progress!(puzzle) 60 | ProgressSyncer.run!(puzzle:, stats: event.stats) 61 | end 62 | 63 | def attach_puzzle_dir!(puzzle) 64 | puzzle_dir.mkdir 65 | 66 | ResourceAttacher.run!(resource: puzzle, path: puzzle_dir.to_s) 67 | end 68 | 69 | def write_puzzle_files!(puzzle, location) 70 | PuzzleDirSynchroniser.run!(puzzle:, location:) 71 | end 72 | 73 | def validate_event_stats_set! 74 | return unless event.stats.nil? 75 | 76 | errors << Kangaru::Validation::Error.new( 77 | attribute: :event_stats, message: "can't be blank" 78 | ) 79 | end 80 | 81 | def validate_event_location_set! 82 | return unless event.location.nil? 83 | 84 | errors << Kangaru::Validation::Error.new( 85 | attribute: :event_location, message: "can't be blank" 86 | ) 87 | end 88 | 89 | def validate_event_dir_exists! 90 | return if event_dir.exist? 91 | 92 | errors << Kangaru::Validation::Error.new( 93 | attribute: :event_dir, message: "does not exist" 94 | ) 95 | end 96 | 97 | def validate_puzzle_dir_does_not_exist! 98 | return unless puzzle_dir.exist? 99 | 100 | errors << Kangaru::Validation::Error.new( 101 | attribute: :puzzle_dir, message: "already exists" 102 | ) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/puzzle_refresher.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class PuzzleRefresher < Core::Processor 4 | attr_accessor :puzzle 5 | 6 | validates :puzzle, type: { equals: Puzzle } 7 | 8 | def run 9 | puzzle.update(content:, input:) || puzzle 10 | end 11 | 12 | private 13 | 14 | extend Forwardable 15 | 16 | def_delegators :puzzle, :year, :day 17 | 18 | def content 19 | Core::Repository.get_puzzle(year:, day:) 20 | end 21 | 22 | def input 23 | Core::Repository.get_input(year:, day:) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/resource_attacher.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class ResourceAttacher < Core::Processor 4 | extend Forwardable 5 | 6 | attr_accessor :resource, :path 7 | 8 | validates :resource, type: { one_of: [Event, Puzzle] } 9 | validates :path, path: { exists: true } 10 | 11 | def run 12 | if location.nil? 13 | Location.create(resource:, path:) 14 | else 15 | location&.update(path:) || location 16 | end.tap { resource.reload } 17 | end 18 | 19 | def_delegators :resource, :location 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/solution_poster.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class SolutionPoster < Core::Processor 4 | attr_accessor :puzzle, :answer 5 | 6 | validates :puzzle, type: { equals: Puzzle } 7 | 8 | validates :answer, required: true 9 | 10 | # TODO: replace with conditional validation 11 | def validate 12 | super 13 | validate_puzzle_location_set! if errors.empty? 14 | validate_stats_associated! if errors.empty? 15 | validate_puzzle_not_complete! if errors.empty? 16 | end 17 | 18 | def run 19 | create_attempt!(post_solution!).tap do |attempt| 20 | advance_puzzle! if attempt.correct? 21 | end 22 | end 23 | 24 | private 25 | 26 | def_delegators :puzzle, :event, :location, :year, :day 27 | 28 | def level 29 | @level ||= event.stats.current_level(day) || raise 30 | end 31 | 32 | def post_solution! 33 | Core::Repository.post_solution(year:, day:, level:, answer:) 34 | end 35 | 36 | def create_attempt!(response) 37 | Attempt.create(puzzle:, level:, answer:, **response) 38 | end 39 | 40 | def advance_puzzle! 41 | event.stats.advance_progress!(day) 42 | 43 | ProgressSyncer.run!(puzzle:, stats: event.stats.reload) 44 | PuzzleDirSynchroniser.run!(puzzle:, location:, skip_cache: true) 45 | end 46 | 47 | def validate_puzzle_location_set! 48 | return unless location.nil? 49 | 50 | errors << Kangaru::Validation::Error.new( 51 | attribute: :location, message: "can't be blank" 52 | ) 53 | end 54 | 55 | def validate_stats_associated! 56 | return unless puzzle.event.stats.nil? 57 | 58 | errors << Kangaru::Validation::Error.new( 59 | attribute: :stats, message: "can't be blank" 60 | ) 61 | end 62 | 63 | def validate_puzzle_not_complete! 64 | return unless event.stats.complete?(day) 65 | 66 | errors << Kangaru::Validation::Error.new( 67 | attribute: :puzzle, message: "is already complete" 68 | ) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/stats_initialiser.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class StatsInitialiser < Core::Processor 4 | attr_accessor :event 5 | 6 | validates :event, type: { equals: Event } 7 | 8 | def validate 9 | super 10 | validate_stats_do_not_exist! if errors.empty? 11 | end 12 | 13 | def run 14 | stats = BLANK_STATS.merge(fetch_stats!) 15 | 16 | Stats.create(event:, **stats) 17 | end 18 | 19 | private 20 | 21 | BLANK_STATS = 1.upto(25).to_h { |day| [:"day_#{day}", 0] } 22 | 23 | def fetch_stats! 24 | Core::Repository.get_stats(year: event.year) 25 | end 26 | 27 | def validate_stats_do_not_exist! 28 | return if event.stats.nil? 29 | 30 | errors << Kangaru::Validation::Error.new( 31 | attribute: :stats, message: "already exist" 32 | ) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/aoc_cli/processors/stats_refresher.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class StatsRefresher < Core::Processor 4 | attr_accessor :stats 5 | 6 | validates :stats, type: { equals: Stats } 7 | 8 | def run 9 | stats.update(**updated_stats) || stats 10 | end 11 | 12 | private 13 | 14 | extend Forwardable 15 | 16 | def_delegators :stats, :year 17 | 18 | def updated_stats 19 | Core::Repository.get_stats(year:) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/aoc_cli/validators/collection_type_validator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class CollectionTypeValidator < Kangaru::Validator 4 | def validate 5 | validate_preconditions! 6 | 7 | return add_error!("can't be blank") if value.nil? 8 | return add_error!("is not an array") unless value.is_a?(Array) 9 | 10 | case validation_rule 11 | when :all then validate_all_elements! 12 | when :any then validate_any_elements! 13 | when :none then validate_none_elements! 14 | else raise 15 | end 16 | end 17 | 18 | private 19 | 20 | VALIDATION_RULES = %i[all any none].freeze 21 | 22 | ERROR = "elements have incompatible types".freeze 23 | 24 | def validation_rule 25 | @validation_rule ||= params.slice(*VALIDATION_RULES).keys.first || raise 26 | end 27 | 28 | def target_type 29 | @target_type ||= params[validation_rule] 30 | end 31 | 32 | def validate_preconditions! 33 | return if params.slice(*VALIDATION_RULES).count == 1 34 | 35 | raise "Collection type rule must be one of #{VALIDATION_RULES.inspect}" 36 | end 37 | 38 | def validate_all_elements! 39 | return if value.all? { |element| element.is_a?(target_type) } 40 | 41 | add_error!(ERROR) 42 | end 43 | 44 | def validate_any_elements! 45 | return if value.any? { |element| element.is_a?(target_type) } 46 | 47 | add_error!(ERROR) 48 | end 49 | 50 | def validate_none_elements! 51 | return if value.none? { |element| element.is_a?(target_type) } 52 | 53 | add_error!(ERROR) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/aoc_cli/validators/event_year_validator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class EventYearValidator < Kangaru::Validator 4 | def validate 5 | case value 6 | when Integer then validate_year! 7 | when NilClass then add_error!(ERRORS[:blank]) 8 | else add_error!(ERRORS[:invalid]) 9 | end 10 | end 11 | 12 | private 13 | 14 | ERRORS = { 15 | blank: "can't be blank", 16 | invalid: "is not an integer", 17 | too_low: "is before first Advent of Code event (2015)", 18 | too_high: "is in the future" 19 | }.freeze 20 | 21 | MIN_YEAR = 2015 22 | 23 | def max_year 24 | return Date.today.year if in_december? 25 | 26 | Date.today.year - 1 27 | end 28 | 29 | def in_december? 30 | Date.today.month == 12 31 | end 32 | 33 | def validate_year! 34 | if value < MIN_YEAR 35 | add_error!(ERRORS[:too_low]) 36 | elsif value > max_year 37 | add_error!(ERRORS[:too_high]) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/aoc_cli/validators/included_validator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class IncludedValidator < Kangaru::Validator 4 | def validate 5 | return if allowed_values.include?(value) 6 | 7 | add_error!(ERRORS[:not_included]) 8 | end 9 | 10 | private 11 | 12 | ERRORS = { 13 | not_included: "is not a valid option" 14 | }.freeze 15 | 16 | def allowed_values 17 | params[:in] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/aoc_cli/validators/integer_validator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class IntegerValidator < Kangaru::Validator 4 | def validate 5 | return add_error!(ERRORS[:blank]) if value.nil? 6 | return add_error!(ERRORS[:type]) unless value.is_a?(Integer) 7 | 8 | validate_range! if params.keys.include?(:between) 9 | end 10 | 11 | private 12 | 13 | ERRORS = { 14 | blank: "can't be blank", 15 | type: "is not an integer", 16 | too_small: "is too small", 17 | too_large: "is too large" 18 | }.freeze 19 | 20 | def range 21 | params[:between] 22 | end 23 | 24 | def validate_range! 25 | return if range.include?(value) 26 | 27 | add_error!(ERRORS[:too_small]) if value < range.first 28 | add_error!(ERRORS[:too_large]) if value > params[:between].last 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/aoc_cli/validators/path_validator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class PathValidator < Kangaru::Validator 4 | def validate 5 | return add_error!(ERRORS[:blank]) if value.nil? 6 | 7 | if params[:exists] == false 8 | validate_path_does_not_exist! 9 | else 10 | validate_path_exists! 11 | end 12 | end 13 | 14 | private 15 | 16 | ERRORS = { 17 | blank: "can't be blank", 18 | exists: "already exists", 19 | does_not_exist: "does not exist" 20 | }.freeze 21 | 22 | def path_exists? 23 | File.exist?(value) 24 | end 25 | 26 | def validate_path_does_not_exist! 27 | return unless path_exists? 28 | 29 | add_error!(ERRORS[:exists]) 30 | end 31 | 32 | def validate_path_exists! 33 | return if path_exists? 34 | 35 | add_error!(ERRORS[:does_not_exist]) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aoc_cli/validators/type_validator.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class TypeValidator < Kangaru::Validator 4 | def validate 5 | validate_target_type_set! 6 | 7 | if params[:equals] 8 | validate_type! 9 | elsif params[:one_of] 10 | validate_type_from_list! 11 | end 12 | end 13 | 14 | private 15 | 16 | ERRORS = { 17 | blank: "can't be blank", 18 | type: "has incompatible type" 19 | }.freeze 20 | 21 | def valid_type 22 | params[:equals] 23 | end 24 | 25 | def valid_types 26 | params[:one_of] 27 | end 28 | 29 | def error 30 | return ERRORS[:blank] if value.nil? 31 | 32 | ERRORS[:type] 33 | end 34 | 35 | def validate_target_type_set! 36 | return if valid_type || valid_types 37 | 38 | raise "type must be specified via :equals or :one_of options" 39 | end 40 | 41 | def validate_type! 42 | return if value.is_a?(valid_type) 43 | 44 | add_error!(error) 45 | end 46 | 47 | def validate_type_from_list! 48 | return if valid_types&.include?(value.class) 49 | 50 | add_error!(error) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/aoc_cli/version.rb: -------------------------------------------------------------------------------- 1 | module AocCli 2 | VERSION = "1.0.1".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/event/attach.erb: -------------------------------------------------------------------------------- 1 | <%= success_tag %>: <%= queried_event.year %> location updated 2 | from <%= @source %> 3 | to <%= @target %> 4 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/event/init.erb: -------------------------------------------------------------------------------- 1 | <%= success_tag %>: event initialised 2 | year <%= @event.year %> 3 | path <%= @event.location.path %> 4 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/event/attach.erb: -------------------------------------------------------------------------------- 1 | aoc event attach 2 | 3 | Description: 4 | 5 | Attaches an existing event to the specified directory (defaults to current 6 | directory). This may be useful if the original directories have been moved 7 | or deleted. 8 | 9 | This command cannot be executed from, or target, a directory that is already 10 | managed by aoc (i.e., existing event and puzzle directories). 11 | 12 | The command will fail if the event has not already been initialized. To 13 | create a new event, see the event init command. 14 | 15 | Usage: 16 | 17 | aoc event attach [--dir=] 18 | 19 | * : Year of the event to relocate. 20 | * : The new event directory (default "."). 21 | 22 | Examples: 23 | 24 | aoc event attach 2023 (in a non-AoC directory) 25 | 26 | Relocates the 2023 event directory to the current directory 27 | 28 | 29 | aoc event attach 2023 --dir /foo/bar/aoc 30 | 31 | Relocates the 2023 event directory to the specified directory. 32 | 33 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/event/init.erb: -------------------------------------------------------------------------------- 1 | aoc event init 2 | 3 | Description: 4 | 5 | Initializes the event for the specified year by fetching event stats and 6 | creating an event directory in the target directory. The target directory 7 | defaults to the current directory but can be explicitly specified with the 8 | optional --dir String argument. 9 | 10 | The event directory serves as the location of event puzzle directories. For 11 | more information, refer to puzzle init. The name of the created event 12 | directory defaults to the event year. 13 | 14 | This command cannot be executed from, or target, a directory that is already 15 | managed by aoc (i.e., existing event and puzzle directories). 16 | 17 | The command will fail if the event has already been initialized elsewhere. To 18 | move an existing event's location, see the event attach command. 19 | 20 | Usage: 21 | 22 | aoc event init [--dir=] 23 | 24 | * : Year of the event to initialize. 25 | * : Base directory to initialize the event within (default "."). 26 | 27 | Examples: 28 | 29 | aoc event init 2023 (in a non-AoC directory) 30 | 31 | Initializes the 2023 event and creates the 2023 event directory inside the 32 | current directory. 33 | 34 | aoc event init 2023 --dir /foo/bar/aoc 35 | 36 | Initializes the 2023 event and creates the 2023 event directory in the 37 | specified directory. 38 | 39 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/event/progress.erb: -------------------------------------------------------------------------------- 1 | aoc event progress 2 | 3 | Description: 4 | 5 | Prints a table displaying progress (stars) for the current event. 6 | 7 | This command can be run from both event and puzzle directories. 8 | 9 | Usage: 10 | 11 | aoc event progress 12 | 13 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/puzzle/attempts.erb: -------------------------------------------------------------------------------- 1 | aoc puzzle attempts 2 | 3 | Description: 4 | 5 | This command can only be run from a puzzle directory (see puzzle init). 6 | 7 | Shows posted solution attempts for the current puzzle. 8 | 9 | Usage: 10 | 11 | aoc puzzle attempts 12 | 13 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/puzzle/init.erb: -------------------------------------------------------------------------------- 1 | aoc puzzle init 2 | 3 | Description: 4 | 5 | This command can only be run from an event directory (see event init). 6 | 7 | Initialises the given day's puzzle for the current event. This fetches 8 | the puzzle prompt and input from Advent of Code, creates a puzzle directory, 9 | and writes the puzzle files to it. 10 | 11 | Once initialised, the new puzzle directory contains two files: 12 | * Markdown puzzle prompt (format "day_.md") 13 | * Plain text puzzle input (format "input") 14 | 15 | These files can be restored at any time using puzzle sync. 16 | 17 | The puzzle directory is a good place to write puzzle solution code and review 18 | previous solution attempts. See puzzle solve and puzzle attempts respectively 19 | 20 | Usage: 21 | 22 | aoc puzzle init 23 | 24 | * : Day of puzzle to initialise (1-25). 25 | 26 | Examples: 27 | 28 | aoc puzzle init 20 (in an event directory) 29 | 30 | Initializes the day 20 puzzle and creates puzzle directory 31 | 32 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/puzzle/solve.erb: -------------------------------------------------------------------------------- 1 | aoc puzzle solve 2 | 3 | Description: 4 | 5 | This command can only be run from a puzzle directory (see puzzle init). 6 | 7 | Posts the given answer to Advent of Code for the current puzzle. The response 8 | will have one of three possible states: 9 | 10 | 1. Correct 11 | 2. Incorrect 12 | 3. Rate-limited 13 | 14 | If the attempt was correct, relevant stats are updated and the puzzle files 15 | are refreshed to include any new puzzle information. 16 | 17 | If the attempt was incorrect or rate-limited, the required wait time will be 18 | indicated to the nearest minute. 19 | 20 | Previous attempt data can be reviewed with the puzzle attempts command. 21 | 22 | Usage: 23 | 24 | aoc puzzle solve --answer= 25 | 26 | * : The puzzle solution to attempt 27 | 28 | Examples: 29 | 30 | aoc puzzle solve --answer abcdef (in a puzzle directory) 31 | 32 | Initializes the day 20 puzzle and creates puzzle directory 33 | 34 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/help/puzzle/sync.erb: -------------------------------------------------------------------------------- 1 | aoc puzzle sync 2 | 3 | Description: 4 | 5 | This command can only be run from a puzzle directory (see puzzle init). 6 | 7 | Restores puzzle dir files (puzzle and input) to their cached state. 8 | 9 | Puzzle data is cached when puzzles are initialised and correctly solved in 10 | order to decrease load on Advent of Code and increase performance. However 11 | there may be situations where the cache is outdated: for example if puzzles 12 | have been solved from the browser. 13 | 14 | To fetch and write the latest puzzle data, pass the --skip-cache flag. 15 | 16 | Usage: 17 | 18 | aoc puzzle sync [--skip-cache] 19 | 20 | * : Whether to ignore puzzle file cache (default false) 21 | 22 | Examples: 23 | 24 | aoc puzzle sync (in a puzzle directory) 25 | 26 | Restores (writes or updates) puzzle files to cached state. 27 | 28 | 29 | aoc puzzle sync --skip-cache (in a puzzle directory) 30 | 31 | Puzzle data is refreshed before writing the puzzle files. 32 | 33 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/puzzle/init.erb: -------------------------------------------------------------------------------- 1 | <%= success_tag %>: puzzle initialised 2 | event <%= @puzzle.year %> 3 | day <%= @puzzle.day %> 4 | -------------------------------------------------------------------------------- /lib/aoc_cli/views/puzzle/solve.erb: -------------------------------------------------------------------------------- 1 | <% if @attempt.rate_limited? -%> 2 | <%= "Rate limited".yellow.bold %>: please wait <%= @attempt.wait_time %> minutes. 3 | <% elsif @attempt.incorrect? -%> 4 | <%= "Incorrect".red.bold %>: please wait <%= @attempt.wait_time %> minutes. 5 | <% unless @attempt.hint.nil? -%> 6 | Hint: your answer is <%= @attempt.presenter.hint.downcase %> 7 | <% end -%> 8 | <% elsif @attempt.correct? -%> 9 | <%= "Correct".green.bold %>: part <%= @attempt.level %> complete. 10 | <% end -%> 11 | -------------------------------------------------------------------------------- /rbs_collection.yaml: -------------------------------------------------------------------------------- 1 | sources: 2 | - type: git 3 | name: ruby/gem_rbs_collection 4 | remote: https://github.com/ruby/gem_rbs_collection.git 5 | revision: main 6 | repo_dir: gems 7 | 8 | - type: git 9 | name: apexatoll/gem_signatures 10 | remote: https://github.com/apexatoll/gem_signatures 11 | revision: main 12 | repo_dir: sig 13 | 14 | path: .gems 15 | 16 | gems: 17 | - name: date 18 | - name: fileutils 19 | - name: forwardable 20 | - name: pathname 21 | - name: rake 22 | ignore: true 23 | - name: rspec 24 | ignore: true 25 | - name: rubocop 26 | ignore: true 27 | - name: steep 28 | ignore: true 29 | -------------------------------------------------------------------------------- /sig/aoc_cli.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | VERSION: String 3 | 4 | extend Kangaru::Initialiser 5 | extend Kangaru::Interface 6 | end 7 | -------------------------------------------------------------------------------- /sig/aoc_cli/components/attempts_table.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class AttemptsTable < Kangaru::Component 4 | attr_reader puzzle: Puzzle 5 | 6 | def initialize: (puzzle: Puzzle) -> void 7 | 8 | def title: -> String 9 | 10 | def headings: -> Array[String] 11 | 12 | def rows: -> Array[Array[untyped] | Symbol] 13 | 14 | private 15 | 16 | attr_reader level_one_rows: Array[Array[untyped]] 17 | 18 | attr_reader level_two_rows: Array[Array[untyped]] 19 | 20 | def separator: -> Symbol? 21 | 22 | def level_one_attempts: -> Array[Attempt] 23 | 24 | def level_two_attempts: -> Array[Attempt] 25 | 26 | def rows_for: (Array[Attempt]) -> Array[Array[untyped]] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /sig/aoc_cli/components/docs_component.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class DocsComponent < Kangaru::Component 4 | include Helpers::ViewHelper 5 | 6 | ENDPOINTS: Hash[Symbol, untyped] 7 | 8 | def endpoints: -> Hash[Symbol, untyped] 9 | 10 | def title: -> String 11 | 12 | def commands_table: (Hash[Symbol, String], ?indent: Integer) -> String 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/aoc_cli/components/errors_component.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class ErrorsComponent < Kangaru::Component 4 | attr_reader messages: Array[String] 5 | 6 | def initialize: (*String) -> void 7 | 8 | def render: -> void 9 | 10 | def self.from_model: (untyped) -> ErrorsComponent 11 | 12 | private 13 | 14 | def render?: -> bool 15 | 16 | def title: -> String 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/components/progress_table.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class ProgressTable < Kangaru::Component 4 | attr_reader event: Event 5 | 6 | def initialize: (event: Event) -> void 7 | 8 | private 9 | 10 | type row = [String, String] 11 | 12 | TITLE: String 13 | HEADINGS: Array[String] 14 | PROGRESS: String 15 | TOTAL: String 16 | 17 | def title: -> String 18 | 19 | def headings: -> Array[String] 20 | 21 | def rows: -> Array[row | Symbol] 22 | 23 | def progress_rows: -> Array[row] 24 | 25 | def total_row: -> row 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /sig/aoc_cli/components/puzzle_sync_component.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Components 3 | class PuzzleSyncComponent < Kangaru::Component 4 | extend Forwardable 5 | 6 | include Helpers::ViewHelper 7 | 8 | attr_reader log: PuzzleDirSyncLog 9 | 10 | def puzzle: -> Puzzle 11 | 12 | def initialize: (log: PuzzleDirSyncLog) -> void 13 | 14 | private 15 | 16 | def status_rows: -> Array[[untyped, untyped]] 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/configurators/session_configurator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Configurators 3 | class SessionConfigurator < Kangaru::Configurator 4 | attr_accessor token: String 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/application_controller.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class ApplicationController < Kangaru::Controller 3 | include Concerns::ErrorConcern 4 | include Concerns::LocationConcern 5 | 6 | include Helpers::ViewHelper 7 | 8 | private 9 | 10 | def handle_help_param!: -> void 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/concerns/error_concern.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Concerns 3 | module ErrorConcern 4 | def render_error!: (String) -> false 5 | 6 | def render_model_errors!: (untyped) -> false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/concerns/location_concern.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Concerns 3 | module LocationConcern 4 | include ErrorConcern 5 | 6 | def current_path: -> String 7 | 8 | def current_location: -> Location? 9 | 10 | def current_event: -> Event? 11 | 12 | def current_puzzle: -> Puzzle? 13 | 14 | def ensure_in_event_dir!: -> bool 15 | 16 | def ensure_in_puzzle_dir!: -> bool 17 | 18 | def ensure_in_aoc_dir!: -> bool 19 | 20 | def ensure_not_in_aoc_dir!: -> bool 21 | 22 | private 23 | 24 | ERRORS: Hash[Symbol, String] 25 | 26 | def current_resource: -> untyped 27 | 28 | def in_event_dir?: -> bool 29 | 30 | def in_puzzle_dir?: -> bool 31 | 32 | def in_aoc_dir?: -> bool 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/default_controller.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class DefaultController < ApplicationController 3 | def default: -> void 4 | 5 | alias help default 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/event_controller.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class EventController < ApplicationController 3 | @event: Event 4 | 5 | def init: -> void 6 | 7 | @source: String 8 | @target: String 9 | 10 | def attach: -> void 11 | 12 | def progress: -> void 13 | 14 | private 15 | 16 | attr_reader queried_event: Event? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/help/event_controller.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Help 3 | class EventController < ApplicationController 4 | def init: -> void 5 | 6 | def attach: -> void 7 | 8 | def progress: -> void 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/help/puzzle_controller.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Help 3 | class PuzzleController < ApplicationController 4 | def init: -> void 5 | 6 | def solve: -> void 7 | 8 | def sync: -> void 9 | 10 | def attempts: -> void 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/aoc_cli/controllers/puzzle_controller.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class PuzzleController < ApplicationController 3 | @puzzle: Puzzle 4 | @attempt: Attempt 5 | @sync_log: PuzzleDirSyncLog 6 | 7 | def init: -> void 8 | 9 | def solve: -> void 10 | 11 | def sync: -> void 12 | 13 | def attempts: -> void 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/aoc_cli/core/attempt_parser.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class AttemptParser 4 | attr_reader response: String 5 | 6 | def initialize: (String) -> void 7 | 8 | def to_h: -> Hash[Symbol, untyped] 9 | 10 | private 11 | 12 | module Prefixes 13 | CORRECT: Regexp 14 | INCORRECT: Regexp 15 | RATE_LIMITED: Regexp 16 | WRONG_LEVEL: Regexp 17 | end 18 | 19 | module Hints 20 | TOO_LOW: Regexp 21 | TOO_HIGH: Regexp 22 | end 23 | 24 | module WaitTimes 25 | ONE_MINUTE: Regexp 26 | INCORRECT_FORMAT: Regexp 27 | RATE_LIMITED_FORMAT: Regexp 28 | end 29 | 30 | def status: -> Symbol 31 | 32 | def hint: -> Symbol? 33 | 34 | def wait_time: -> Integer? 35 | 36 | def scan_incorrect_wait_time!: -> Integer? 37 | 38 | def scan_rated_limited_wait_time!: -> Integer? 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /sig/aoc_cli/core/processor.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Processor 4 | extend Forwardable 5 | 6 | include Kangaru::Attributable 7 | extend Kangaru::Attributable::ClassMethods 8 | 9 | include Kangaru::Validatable 10 | extend Kangaru::Validatable::ClassMethods 11 | 12 | class Error < StandardError 13 | attr_reader processor: Processor 14 | 15 | def initialize: (Processor) -> void 16 | end 17 | 18 | def run: -> untyped 19 | 20 | def run!: -> untyped 21 | 22 | def self.run!: (**untyped) -> untyped 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /sig/aoc_cli/core/repository.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Repository 4 | HOST: String 5 | 6 | RESOURCES: Hash[Symbol, Hash[Symbol, untyped]] 7 | 8 | def self.get_stats: (year: Integer) -> Hash[Symbol, Integer] 9 | 10 | def self.get_puzzle: (year: Integer, day: Integer) -> String 11 | 12 | def self.get_input: (year: Integer, day: Integer) -> String 13 | 14 | def self.post_solution: ( 15 | year: Integer, day: Integer, level: Integer, answer: untyped 16 | ) -> Hash[Symbol, untyped] 17 | 18 | private 19 | 20 | def self.build_resource: (Symbol, **untyped) -> Resource 21 | 22 | def self.format_url: (String, **untyped) -> String 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /sig/aoc_cli/core/request.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Request 4 | extend Forwardable 5 | 6 | attr_reader client: HTTP::Client 7 | 8 | def initialize: (token: String) -> void 9 | 10 | def get: (String) -> HTTP::Response 11 | 12 | def post: (String, **untyped) -> HTTP::Response 13 | 14 | def self.build: -> Request 15 | 16 | def self.get: (String) -> HTTP::Response 17 | 18 | def self.post: (String, **untyped) -> HTTP::Response 19 | 20 | class ::Class 21 | def def_delegators: (Symbol, *Symbol) -> void 22 | end 23 | 24 | private 25 | 26 | def setup_client!: (String) -> HTTP::Client 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /sig/aoc_cli/core/resource.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class Resource 4 | attr_reader url: String 5 | attr_reader scope: String? 6 | attr_reader method: Symbol 7 | attr_reader params: Hash[Symbol, untyped] 8 | 9 | def initialize: ( 10 | url: String, 11 | ?scope: String?, 12 | ?method: Symbol, 13 | ?params: Hash[Symbol, untyped] 14 | ) -> void 15 | 16 | def fetch: -> String 17 | 18 | def fetch_markdown: -> String 19 | 20 | private 21 | 22 | def response: -> String 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /sig/aoc_cli/core/stats_parser.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Core 3 | class StatsParser 4 | DAY_CLASS_PREFIX: String 5 | ONE_STAR_CLASS: String 6 | TWO_STARS_CLASS: String 7 | 8 | attr_reader calendar_html: String 9 | 10 | def initialize: (String) -> void 11 | 12 | def to_h: -> Hash[Symbol, Integer] 13 | 14 | private 15 | 16 | def calendar_link_classes: -> Array[Array[String]] 17 | 18 | def calendar_links: -> Nokogiri::XML::NodeSet 19 | 20 | def count_stars: (Array[String]) -> Integer 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /sig/aoc_cli/helpers/table_generator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Helpers 3 | class TableGenerator 4 | type matrix[T] = Array[Array[T]] 5 | 6 | include Kangaru::Validatable 7 | extend Kangaru::Validatable::ClassMethods 8 | 9 | attr_reader rows: matrix[String] 10 | attr_reader gap: Integer 11 | attr_reader indent: Integer 12 | 13 | def initialize: ( 14 | rows: matrix[String], 15 | ?gap: Integer, 16 | ?indent: Integer 17 | ) -> void 18 | 19 | def validate!: -> void 20 | 21 | def generate!: -> String 22 | 23 | private 24 | 25 | CELL_GAP: Integer 26 | 27 | attr_reader column_widths: Array[Integer] 28 | 29 | def space: (Integer) -> String 30 | 31 | def format_row!: (Array[String]) -> String 32 | 33 | def validate_rows_are_same_length!: -> void 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /sig/aoc_cli/helpers/view_helper.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Helpers 3 | module ViewHelper 4 | def main_header: -> String 5 | 6 | def heading: (String) -> String 7 | 8 | def success_tag: -> String 9 | 10 | def table_for: (*Array[String], ?gap: Integer, ?indent: Integer) -> String 11 | 12 | def wrap_text: (String, ?width: Integer, ?indent: Integer) -> String 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/attempt.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Attempt < Kangaru::Model 3 | attr_accessor id: Integer 4 | attr_accessor level: Integer 5 | attr_accessor answer: String 6 | attr_accessor status: Symbol 7 | attr_accessor hint: Symbol? 8 | attr_accessor wait_time: Integer? 9 | attr_accessor created_at: Time 10 | attr_accessor updated_at: Time 11 | 12 | attr_accessor puzzle: Puzzle 13 | attr_accessor puzzle_dataset: Sequel::Dataset 14 | 15 | attr_reader presenter: Presenters::AttemptPresenter 16 | 17 | def incorrect?: -> bool 18 | def correct?: -> bool 19 | def rate_limited?: -> bool 20 | def wrong_level?: -> bool 21 | 22 | def too_low?: -> bool 23 | def too_high?: -> bool 24 | 25 | private 26 | 27 | def validate_hint_not_set!: -> void 28 | 29 | def validate_hint!: -> void 30 | 31 | def validate_wait_time_not_set!: -> void 32 | 33 | def validate_wait_time_integer!: -> void 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/event.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Event < Kangaru::Model 3 | attr_accessor id: Integer 4 | attr_accessor year: Integer 5 | attr_accessor created_at: Time 6 | attr_accessor updated_at: Time 7 | 8 | attr_accessor puzzles: Array[Puzzle] 9 | attr_accessor puzzles_dataset: Sequel::Dataset 10 | 11 | attr_accessor stats: Stats 12 | attr_accessor stats_dataset: Sequel::Dataset 13 | 14 | attr_accessor location: Location 15 | attr_accessor location_dataset: Sequel::Dataset 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/location.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Location < Kangaru::Model 3 | type resource = (Event | Puzzle) 4 | 5 | attr_accessor id: Integer 6 | attr_accessor path: String 7 | attr_accessor resource: resource 8 | attr_accessor created_at: Time 9 | attr_accessor updated_at: Time 10 | 11 | def exists?: -> bool 12 | 13 | def event_dir?: -> bool 14 | 15 | def puzzle_dir?: -> bool 16 | 17 | def to_pathname: -> Pathname 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/progress.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Progress < Kangaru::Model 3 | attr_accessor id: Integer 4 | attr_accessor puzzle: Puzzle 5 | attr_accessor level: Integer 6 | attr_accessor started_at: Time 7 | attr_accessor completed_at: Time 8 | attr_accessor created_at: Time 9 | attr_accessor updated_at: Time 10 | 11 | def complete?: -> bool 12 | 13 | def incomplete?: -> bool 14 | 15 | def complete!: -> void 16 | 17 | def reset!: -> void 18 | 19 | def time_taken: -> Float 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/puzzle.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Puzzle < Kangaru::Model 3 | extend Forwardable 4 | 5 | attr_accessor id: Integer 6 | attr_accessor event: Event 7 | attr_accessor day: Integer 8 | attr_accessor content: String 9 | attr_accessor input: String 10 | attr_accessor created_at: Time 11 | attr_accessor updated_at: Time 12 | 13 | attr_accessor location: Location 14 | attr_accessor location_dataset: Sequel::Dataset 15 | 16 | attr_accessor attempts: Array[Attempt] 17 | attr_accessor attempts_dataset: Sequel::Dataset 18 | 19 | attr_accessor part_one_progress: Progress? 20 | attr_accessor part_two_progress: Progress? 21 | 22 | # Delegated to event 23 | def year: -> Integer 24 | 25 | attr_reader presenter: Presenters::PuzzlePresenter 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/puzzle_dir_sync_log.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class PuzzleDirSyncLog < Kangaru::Model 3 | attr_accessor id: Integer 4 | attr_accessor puzzle: Puzzle 5 | attr_accessor location: Location 6 | attr_accessor puzzle_status: Symbol 7 | attr_accessor input_status: Symbol 8 | 9 | STATUS_ENUM: Hash[Symbol, Integer] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /sig/aoc_cli/models/stats.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | class Stats < Kangaru::Model 3 | extend Forwardable 4 | 5 | attr_accessor id: Integer 6 | attr_accessor day_1: Integer 7 | attr_accessor day_2: Integer 8 | attr_accessor day_3: Integer 9 | attr_accessor day_4: Integer 10 | attr_accessor day_5: Integer 11 | attr_accessor day_6: Integer 12 | attr_accessor day_7: Integer 13 | attr_accessor day_8: Integer 14 | attr_accessor day_9: Integer 15 | attr_accessor day_10: Integer 16 | attr_accessor day_11: Integer 17 | attr_accessor day_12: Integer 18 | attr_accessor day_13: Integer 19 | attr_accessor day_14: Integer 20 | attr_accessor day_15: Integer 21 | attr_accessor day_16: Integer 22 | attr_accessor day_17: Integer 23 | attr_accessor day_18: Integer 24 | attr_accessor day_19: Integer 25 | attr_accessor day_20: Integer 26 | attr_accessor day_21: Integer 27 | attr_accessor day_22: Integer 28 | attr_accessor day_23: Integer 29 | attr_accessor day_24: Integer 30 | attr_accessor day_25: Integer 31 | attr_accessor created_at: Time 32 | attr_accessor updated_at: Time 33 | attr_accessor completed_at: Time 34 | 35 | attr_accessor event: Event 36 | attr_accessor event_dataset: Sequel::Dataset 37 | 38 | # Delegated to event 39 | def year: -> Integer 40 | 41 | attr_reader presenter: Presenters::StatsPresenter 42 | 43 | def total: -> Integer 44 | 45 | def progress: (Integer) -> Integer 46 | 47 | def current_level: (Integer) -> Integer? 48 | 49 | def complete?: (Integer) -> bool 50 | 51 | def advance_progress!: (Integer) -> void 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /sig/aoc_cli/presenters/attempt_presenter.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Presenters 3 | class AttemptPresenter 4 | attr_reader attempt: Attempt 5 | 6 | def initialize: (Attempt) -> void 7 | 8 | def status: -> String 9 | 10 | def hint: -> String 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/aoc_cli/presenters/puzzle_presenter.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Presenters 3 | class PuzzlePresenter 4 | attr_reader puzzle: Puzzle 5 | 6 | def initialize: (Puzzle) -> void 7 | 8 | def date: -> String 9 | 10 | def puzzle_filename: -> String 11 | 12 | def input_filename: -> String 13 | 14 | private 15 | 16 | def formatted_day: -> String 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/presenters/stats_presenter.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Presenters 3 | class StatsPresenter 4 | attr_reader stats: Stats 5 | 6 | def initialize: (Stats) -> void 7 | 8 | def total_progress: -> String 9 | 10 | def progress_icons: (Integer) -> String 11 | 12 | module Icons 13 | INCOMPLETE: String 14 | HALF_COMPLETE: String 15 | COMPLETE: String 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/event_initialiser.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class EventInitialiser < Core::Processor 4 | attr_accessor year: Integer 5 | attr_accessor dir: String 6 | 7 | def self.run!: (year: untyped, dir: untyped) -> Event 8 | 9 | private 10 | 11 | attr_reader event_dir: Pathname 12 | 13 | def create_event!: -> Event 14 | 15 | def initialise_stats!: (Event) -> void 16 | 17 | def make_event_directory!: -> void 18 | 19 | def attach_event!: (Event) -> void 20 | 21 | def validate_event_dir_does_not_exist!: -> void 22 | 23 | def validate_event_not_already_initialised!: -> void 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/progress_syncer.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class ProgressSyncer < Core::Processor 4 | attr_accessor puzzle: Puzzle 5 | attr_accessor stats: Stats 6 | 7 | def run: -> void 8 | 9 | def self.run!: (puzzle: untyped, stats: untyped) -> void 10 | 11 | private 12 | 13 | def part_one_progress: -> Progress? 14 | def part_two_progress: -> Progress? 15 | 16 | def current_progress: -> Integer 17 | 18 | def create_part_one_progress!: -> void 19 | 20 | def create_part_two_progress!: -> void 21 | 22 | def handle_not_complete!: -> void 23 | 24 | def handle_partially_complete!: -> void 25 | 26 | def handle_fully_complete!: -> void 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/puzzle_dir_synchroniser.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class PuzzleDirSynchroniser < Core::Processor 4 | attr_accessor puzzle: Puzzle 5 | attr_accessor location: Location 6 | attr_accessor skip_cache: bool 7 | 8 | def self.run!: ( 9 | puzzle: untyped, 10 | location: untyped, 11 | ?skip_cache: untyped 12 | )-> PuzzleDirSyncLog 13 | 14 | private 15 | 16 | # Delegated to Puzzle 17 | def year: -> Integer 18 | def day: -> Integer 19 | def presenter: -> Presenters::PuzzlePresenter 20 | 21 | # Delegated to Presenter 22 | def puzzle_filename: -> String 23 | def input_filename: -> String 24 | 25 | attr_reader puzzle_dir: Pathname 26 | attr_reader puzzle_file: Pathname 27 | attr_reader input_file: Pathname 28 | 29 | def refresh_puzzle!: -> void 30 | 31 | def create_puzzle_dir_sync_log!: -> PuzzleDirSyncLog 32 | 33 | def puzzle_status: -> Symbol 34 | 35 | def input_status: -> Symbol 36 | 37 | def validate_puzzle_dir_exists!: -> void 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/puzzle_initialiser.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class PuzzleInitialiser < Core::Processor 4 | attr_accessor event: Event 5 | attr_accessor day: Integer 6 | 7 | def run: -> Puzzle 8 | 9 | def self.run!: (event: untyped, day: untyped) -> Puzzle 10 | 11 | private 12 | 13 | # Delegated to Event 14 | def year: -> Integer 15 | 16 | attr_reader event_dir: Pathname 17 | attr_reader puzzle_dir: Pathname 18 | attr_reader existing_puzzle: Puzzle? 19 | 20 | def fetch_content!: -> String 21 | 22 | def fetch_input!: -> String 23 | 24 | def create_or_update_puzzle!: (String, String) -> Puzzle 25 | 26 | def sync_puzzle_progress!: (Puzzle) -> void 27 | 28 | def attach_puzzle_dir!: (Puzzle) -> Location 29 | 30 | def write_puzzle_files!: (Puzzle, Location) -> PuzzleDirSyncLog 31 | 32 | def validate_event_stats_set!: -> void 33 | 34 | def validate_event_location_set!: -> void 35 | 36 | def validate_event_dir_exists!: -> void 37 | 38 | def validate_puzzle_dir_does_not_exist!: -> void 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/puzzle_refresher.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class PuzzleRefresher < Core::Processor 4 | attr_accessor puzzle: Puzzle 5 | 6 | def self.run!: (puzzle: Puzzle) -> Puzzle 7 | 8 | private 9 | 10 | extend Forwardable 11 | 12 | def year: -> Integer 13 | 14 | def day: -> Integer 15 | 16 | def content: -> String 17 | 18 | def input: -> String 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/resource_attacher.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class ResourceAttacher < Core::Processor 4 | type resource = Event | Puzzle 5 | 6 | attr_accessor resource: resource 7 | attr_accessor path: String 8 | 9 | def run: -> Location 10 | 11 | def self.run!: (resource: resource, path: String) -> Location 12 | 13 | def location: -> Location? 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/solution_poster.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class SolutionPoster < Core::Processor 4 | attr_accessor puzzle: Puzzle 5 | attr_accessor answer: Integer 6 | 7 | def run: -> Attempt 8 | 9 | def self.run!: (puzzle: untyped, answer: untyped) -> Attempt 10 | 11 | private 12 | 13 | # Delegated to @puzzle 14 | def event: -> Event 15 | def location: -> Location 16 | def year: -> Integer 17 | def day: -> Integer 18 | 19 | attr_reader level: Integer 20 | 21 | def post_solution!: -> Hash[Symbol, untyped] 22 | 23 | def create_attempt!: (Hash[Symbol, untyped]) -> Attempt 24 | 25 | def advance_puzzle!: -> void 26 | 27 | def validate_puzzle_location_set!: -> void 28 | 29 | def validate_stats_associated!: -> void 30 | 31 | def validate_puzzle_not_complete!: -> void 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/stats_initialiser.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class StatsInitialiser < Core::Processor 4 | attr_accessor event: Event 5 | 6 | private 7 | 8 | BLANK_STATS: Hash[Symbol, Integer] 9 | 10 | def fetch_stats!: -> Hash[Symbol, Integer] 11 | 12 | def validate_stats_do_not_exist!: -> void 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/aoc_cli/processors/stats_refresher.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Processors 3 | class StatsRefresher < Core::Processor 4 | extend Forwardable 5 | 6 | attr_accessor stats: Stats 7 | 8 | def run: -> Stats 9 | 10 | def self.run!: (stats: Stats) -> Stats 11 | 12 | private 13 | 14 | def year: -> Integer 15 | 16 | def updated_stats: -> Hash[Symbol, Integer] 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/validators/collection_type_validator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class CollectionTypeValidator < Kangaru::Validator 4 | 5 | private 6 | 7 | VALIDATION_RULES: Array[Symbol] 8 | 9 | ERROR: String 10 | 11 | attr_reader validation_rule: Symbol 12 | 13 | attr_reader target_type: Class 14 | 15 | def validate_preconditions!: -> void 16 | 17 | def validate_all_elements!: -> void 18 | 19 | def validate_any_elements!: -> void 20 | 21 | def validate_none_elements!: -> void 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /sig/aoc_cli/validators/event_year_validator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class EventYearValidator < Kangaru::Validator 4 | def validate: -> untyped 5 | 6 | private 7 | 8 | ERRORS: Hash[Symbol, String] 9 | 10 | MIN_YEAR: Integer 11 | 12 | def max_year: -> untyped 13 | 14 | def in_december?: -> bool 15 | 16 | def validate_year!: -> void 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/aoc_cli/validators/included_validator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class IncludedValidator < Kangaru::Validator 4 | private 5 | 6 | ERRORS: Hash[Symbol, String] 7 | 8 | def allowed_values: -> Array[untyped] 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /sig/aoc_cli/validators/integer_validator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class IntegerValidator < Kangaru::Validator 4 | def validate: -> void 5 | 6 | private 7 | 8 | ERRORS: Hash[Symbol, String] 9 | 10 | def range: -> Range[Integer] 11 | 12 | def validate_range!: -> void 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/aoc_cli/validators/path_validator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class PathValidator < Kangaru::Validator 4 | def validate: -> void 5 | 6 | private 7 | 8 | ERRORS: Hash[Symbol, String] 9 | 10 | def path_exists?: -> bool 11 | 12 | def validate_path_does_not_exist!: -> void 13 | 14 | def validate_path_exists!: -> void 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /sig/aoc_cli/validators/type_validator.rbs: -------------------------------------------------------------------------------- 1 | module AocCli 2 | module Validators 3 | class TypeValidator < Kangaru::Validator 4 | 5 | private 6 | 7 | ERRORS: Hash[Symbol, String] 8 | 9 | def valid_type: -> Class? 10 | 11 | def valid_types: -> Array[Class]? 12 | 13 | def error: -> String 14 | 15 | def validate_target_type_set!: -> void 16 | 17 | def validate_type!: -> void 18 | 19 | def validate_type_from_list!: -> void 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /sig/http.rbs: -------------------------------------------------------------------------------- 1 | module HTTP 2 | def self.headers: (**untyped) -> Client 3 | end 4 | -------------------------------------------------------------------------------- /sig/kangaru.rbs: -------------------------------------------------------------------------------- 1 | module Kangaru 2 | class Config 3 | attr_reader session: AocCli::Configurators::SessionConfigurator 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/nokogiri.rbs: -------------------------------------------------------------------------------- 1 | module Nokogiri 2 | def self.HTML5: (String) -> HTML::Document 3 | end 4 | -------------------------------------------------------------------------------- /sig/reverse_markdown.rbs: -------------------------------------------------------------------------------- 1 | module ReverseMarkdown 2 | def self.convert: (String, **untyped) -> String 3 | end 4 | -------------------------------------------------------------------------------- /spec/aoc.yml: -------------------------------------------------------------------------------- 1 | session: 2 | token: "abcdef" 3 | -------------------------------------------------------------------------------- /spec/aoc_cli/components/docs_component_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Components::DocsComponent do 2 | subject(:docs_component) { described_class.new } 3 | 4 | around do |spec| 5 | String.disable_colorization = true 6 | spec.run 7 | String.disable_colorization = false 8 | end 9 | 10 | describe "#render" do 11 | subject(:render) { docs_component.render } 12 | 13 | let(:expected_text) do 14 | <<~TEXT 15 | 16 | Advent of Code CLI <#{AocCli::VERSION}> 17 | 18 | A command-line interface for the Advent of Code puzzles. 19 | 20 | Features include downloading puzzles and inputs, solving puzzles and 21 | tracking year progress from within the terminal. 22 | 23 | This is an unofficial project with no affiliation to Advent of Code. 24 | 25 | 26 | event 27 | 28 | Handle event directories 29 | 30 | Usage: aoc event 31 | 32 | init Create and initialise an event directory 33 | progress Check your progress for the current event 34 | 35 | 36 | puzzle 37 | 38 | Handle puzzle directories 39 | 40 | Usage: aoc puzzle 41 | 42 | init Fetch and initialise puzzles for the current event 43 | solve Submit and evaluate a puzzle solution 44 | sync Ensure puzzle files are up to date 45 | attempts Review previous attempts for the current puzzle 46 | 47 | TEXT 48 | end 49 | 50 | it "renders the expected text" do 51 | expect { render }.to output(expected_text).to_stdout 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/aoc_cli/components/errors_component_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Components::ErrorsComponent do 2 | subject(:errors_component) { described_class.new(*messages) } 3 | 4 | describe "#render" do 5 | subject(:render) { errors_component.render } 6 | 7 | context "when no messages are set" do 8 | let(:messages) { [] } 9 | 10 | it "does not render the component to stdout" do 11 | expect { render }.not_to output.to_stdout 12 | end 13 | end 14 | 15 | context "when one message is set" do 16 | let(:messages) { ["Attribute is not an integer"] } 17 | 18 | it "renders the error inline to stdout" do 19 | expect { render }.to output(<<~TEXT).to_stdout 20 | #{'Error'.red}: Attribute is not an integer 21 | TEXT 22 | end 23 | end 24 | 25 | context "when multiple messages are set" do 26 | let(:messages) do 27 | [ 28 | "Attribute can't be blank", 29 | "Attribute is not an integer" 30 | ] 31 | end 32 | 33 | it "renders the error list to stdout" do 34 | expect { render }.to output(<<~TEXT).to_stdout 35 | #{'Error'.red}: 36 | • Attribute can't be blank 37 | • Attribute is not an integer 38 | TEXT 39 | end 40 | end 41 | end 42 | 43 | describe ".from_model" do 44 | subject(:errors_component) { described_class.from_model(model) } 45 | 46 | let(:model) { model_class.new(foo:, bar:) } 47 | 48 | let(:model_class) do 49 | Class.new do 50 | include Kangaru::Validatable 51 | 52 | attr_reader :foo, :bar 53 | 54 | def initialize(foo:, bar:) 55 | @foo = foo 56 | @bar = bar 57 | end 58 | 59 | validates :foo, integer: true 60 | validates :bar, integer: true 61 | end 62 | end 63 | 64 | before { model.validate } 65 | 66 | shared_examples :builds_component do |options| 67 | let(:messages) { options[:messages] } 68 | 69 | it "returns an errors component" do 70 | expect(errors_component).to be_a(described_class) 71 | end 72 | 73 | it "sets the expected messages" do 74 | expect(errors_component.messages).to eq(messages) 75 | end 76 | end 77 | 78 | context "when model does not have any errors" do 79 | let(:foo) { 99 } 80 | let(:bar) { 21 } 81 | 82 | include_examples :builds_component, messages: [] 83 | end 84 | 85 | context "when model has one error" do 86 | let(:foo) { :foo } 87 | let(:bar) { 100 } 88 | 89 | include_examples :builds_component, messages: [ 90 | "Foo is not an integer" 91 | ] 92 | end 93 | 94 | context "when model has multiple errors" do 95 | let(:foo) { nil } 96 | let(:bar) { :bar } 97 | 98 | include_examples :builds_component, messages: [ 99 | "Foo can't be blank", 100 | "Bar is not an integer" 101 | ] 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/aoc_cli/controllers/application_controller_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::ApplicationController do 2 | describe "#execute" do 3 | before do 4 | stub_const "AocCli::TestController", controller_class 5 | end 6 | 7 | describe "handling processor errors" do 8 | subject(:make_request) { resolve "/test/run_processor", params: } 9 | 10 | let(:params) { { attribute: }.compact } 11 | 12 | let(:controller_class) do 13 | Class.new(described_class) do 14 | def run_processor 15 | AocCli::Processors::TestProcessor.run!( 16 | attribute: params[:attribute] 17 | ) 18 | end 19 | end 20 | end 21 | 22 | let(:processor_class) do 23 | Class.new(AocCli::Core::Processor) do 24 | include Kangaru::Attributable 25 | 26 | attr_accessor :attribute 27 | 28 | validates :attribute, required: true 29 | 30 | def run 31 | true 32 | end 33 | end 34 | end 35 | 36 | before do 37 | stub_const "AocCli::Processors::TestProcessor", processor_class 38 | end 39 | 40 | context "and processor error is not raised" do 41 | let(:attribute) { "attribute" } 42 | 43 | it "does not render any errors" do 44 | expect { make_request }.not_to render_errors 45 | end 46 | end 47 | 48 | context "and processor error is raised" do 49 | let(:attribute) { nil } 50 | 51 | it "renders the expected errors" do 52 | expect { make_request }.to render_errors("Attribute can't be blank") 53 | end 54 | end 55 | end 56 | 57 | describe "handling --help flag" do 58 | subject(:make_request) { resolve "/test/foobar", params: } 59 | 60 | let(:params) { { help:, param: }.compact } 61 | 62 | let(:param) { nil } 63 | 64 | let(:controller_class) do 65 | Class.new(described_class) { def foobar; end } 66 | end 67 | 68 | let(:help_controller_class) do 69 | Class.new(described_class) { def foobar; end } 70 | end 71 | 72 | before do 73 | stub_const "AocCli::Help::TestController", help_controller_class 74 | end 75 | 76 | context "when help flag is not passed" do 77 | let(:help) { nil } 78 | 79 | it "does not redirect to the help controller action" do 80 | expect { make_request }.not_to redirect_to("/help/test/foobar") 81 | end 82 | end 83 | 84 | context "when help flag is passed" do 85 | let(:help) { true } 86 | 87 | context "and no other params are specified" do 88 | let(:param) { nil } 89 | 90 | it "redirects to the help controller action without params" do 91 | expect { make_request } 92 | .to redirect_to("/help/test/foobar") 93 | .with_params({}) 94 | end 95 | end 96 | 97 | context "and other params are specified" do 98 | let(:param) { "value" } 99 | 100 | it "redirects to the help controller action with param" do 101 | expect { make_request } 102 | .to redirect_to("/help/test/foobar") 103 | .with_params(param:) 104 | end 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/aoc_cli/controllers/concerns/error_concern_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Concerns::ErrorConcern do 2 | describe "#render_error!" do 3 | subject(:render_error!) { controller.render_error!(message) } 4 | 5 | let(:message) { "This is an error" } 6 | 7 | it "renders the error" do 8 | expect { render_error! }.to render_errors(message) 9 | end 10 | 11 | it "returns a falsey value" do 12 | expect(render_error!).to be_falsey 13 | end 14 | end 15 | 16 | describe "#render_model_errors!" do 17 | subject(:render_model_errors!) { controller.render_model_errors!(model) } 18 | 19 | let(:model) { Struct.new(:errors).new([error]) } 20 | 21 | let(:error) { Struct.new(:full_message).new(message) } 22 | 23 | let(:message) { "This is an error" } 24 | 25 | it "renders the error" do 26 | expect { render_model_errors! }.to render_errors(message) 27 | end 28 | 29 | it "returns a falsey value" do 30 | expect(render_model_errors!).to be_falsey 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/aoc_cli/controllers/default_controller_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::DefaultController do 2 | describe "/" do 3 | subject(:make_request) { resolve "/", params: {} } 4 | 5 | it "renders the docs component" do 6 | expect { make_request }.to render_component( 7 | AocCli::Components::DocsComponent 8 | ) 9 | end 10 | end 11 | 12 | describe "/help" do 13 | subject(:make_request) { resolve "/help", params: {} } 14 | 15 | it "renders the docs component" do 16 | expect { make_request }.to render_component( 17 | AocCli::Components::DocsComponent 18 | ) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/aoc_cli/controllers/event/attach_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "/event/attach/:year", :with_temp_dir do 2 | subject(:make_request) { resolve path, params: } 3 | 4 | let(:path) { ["/event/attach", year].compact.join("/") } 5 | 6 | let(:params) { { dir: } } 7 | 8 | let(:dir) { nil } 9 | 10 | shared_examples :does_not_attach_event do |options| 11 | let(:errors) { options[:errors] } 12 | 13 | it "renders the expected errors" do 14 | expect { make_request }.to render_errors(*errors) 15 | end 16 | end 17 | 18 | shared_examples :attaches_event do 19 | it "updates the event location" do 20 | expect { make_request } 21 | .to change { event.location.reload.path } 22 | .from(source_path) 23 | .to(target_path) 24 | end 25 | 26 | it "does not render any errors" do 27 | expect { make_request }.not_to render_errors 28 | end 29 | 30 | it "renders the expected response" do 31 | expect { make_request }.to output(<<~TEXT).to_stdout 32 | Success: #{year} location updated 33 | from #{source_path} 34 | to #{target_path} 35 | TEXT 36 | end 37 | end 38 | 39 | context "when year is nil" do 40 | let(:year) { nil } 41 | 42 | include_examples :does_not_attach_event, errors: [ 43 | "Event does not exist" 44 | ] 45 | end 46 | 47 | context "when year is valid" do 48 | let(:year) { 2015 } 49 | 50 | context "and year event has not been initialised" do 51 | include_examples :does_not_attach_event, errors: [ 52 | "Event does not exist" 53 | ] 54 | end 55 | 56 | context "and year event has been initialised" do 57 | let!(:event) do 58 | create(:event, :with_location, year:, path: source_path) 59 | end 60 | 61 | let(:source_path) { temp_path("source").tap(&:mkdir).to_s } 62 | 63 | context "and no dir is specified" do 64 | let(:dir) { nil } 65 | 66 | include_examples :attaches_event do 67 | let(:target_path) { temp_dir } 68 | end 69 | end 70 | 71 | context "and dir is specified" do 72 | let(:dir) { temp_path("target").to_s } 73 | 74 | context "and specified dir does not exist" do 75 | include_examples :does_not_attach_event, errors: [ 76 | "Path does not exist" 77 | ] 78 | end 79 | 80 | context "and specified dir exists" do 81 | before { Dir.mkdir(dir) } 82 | 83 | include_examples :attaches_event do 84 | let(:target_path) { dir } 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/aoc_cli/controllers/event/progress_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "/event/progress", :with_temp_dir do 2 | subject(:make_request) { resolve "/event/progress", params: {} } 3 | 4 | context "when not in an AoC directory" do 5 | it "renders the expected error" do 6 | expect { make_request }.to render_errors( 7 | "Action can't be performed outside Advent of Code directory" 8 | ) 9 | end 10 | 11 | it "does not render the progress table" do 12 | expect { make_request } 13 | .not_to render_component(AocCli::Components::ProgressTable) 14 | end 15 | end 16 | 17 | context "when in an event dir" do 18 | include_context :in_event_dir 19 | 20 | it "does not render errors" do 21 | expect { make_request }.not_to render_errors 22 | end 23 | 24 | it "renders the progress table" do 25 | expect { make_request } 26 | .to render_component(AocCli::Components::ProgressTable) 27 | .with(event:) 28 | end 29 | end 30 | 31 | context "when in a puzzle dir" do 32 | include_context :in_puzzle_dir 33 | 34 | it "does not render errors" do 35 | expect { make_request }.not_to render_errors 36 | end 37 | 38 | it "renders the progress table" do 39 | expect { make_request } 40 | .to render_component(AocCli::Components::ProgressTable) 41 | .with(event:) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/aoc_cli/controllers/puzzle/attempts_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "/puzzle/attempts", :with_temp_dir do 2 | subject(:make_request) { resolve "/puzzle/attempts", params: {} } 3 | 4 | context "when not in an AoC directory" do 5 | it "does not render the attempts table" do 6 | expect { make_request } 7 | .not_to render_component(AocCli::Components::AttemptsTable) 8 | end 9 | 10 | it "renders the expected error" do 11 | expect { make_request }.to render_errors( 12 | "Action can't be performed outside puzzle directory" 13 | ) 14 | end 15 | end 16 | 17 | context "when in an event directory" do 18 | include_context :in_event_dir 19 | 20 | it "does not render the attempts table" do 21 | expect { make_request } 22 | .not_to render_component(AocCli::Components::AttemptsTable) 23 | end 24 | 25 | it "renders the expected error" do 26 | expect { make_request }.to render_errors( 27 | "Action can't be performed outside puzzle directory" 28 | ) 29 | end 30 | end 31 | 32 | context "when in a puzzle directory" do 33 | include_context :in_puzzle_dir 34 | 35 | it "does not render errors" do 36 | expect { make_request }.not_to render_errors 37 | end 38 | 39 | it "renders the attempts table" do 40 | expect { make_request } 41 | .to render_component(AocCli::Components::AttemptsTable) 42 | .with(puzzle:) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/aoc_cli/core/processor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Core::Processor do 2 | subject(:processor) { processor_class.new(attribute:) } 3 | 4 | let(:processor_class) do 5 | Class.new(described_class) do 6 | attr_accessor :attribute 7 | 8 | validates :attribute, required: true 9 | 10 | def run = nil 11 | end 12 | end 13 | 14 | describe "#run!" do 15 | subject(:run!) { processor.run! } 16 | 17 | context "when processor is not valid" do 18 | let(:attribute) { nil } 19 | 20 | it "raises an error" do 21 | expect { run! }.to raise_error(described_class::Error) 22 | end 23 | 24 | it "sets the processor reference" do 25 | run! 26 | rescue described_class::Error => e 27 | expect(e.processor).to eq(processor) 28 | end 29 | end 30 | 31 | context "when processor is valid" do 32 | let(:attribute) { true } 33 | 34 | it "does not raise an error" do 35 | expect { run! }.not_to raise_error 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/aoc_cli/core/request_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Core::Request do 2 | subject(:request) { described_class.new(token:) } 3 | 4 | let(:token) { "abcdef123456" } 5 | 6 | let(:client) { instance_double(HTTP::Client) } 7 | 8 | before do 9 | allow(HTTP).to receive(:headers).and_call_original 10 | allow(HTTP::Client).to receive(:new).and_return(client) 11 | end 12 | 13 | shared_examples :sets_up_client do 14 | let(:expected_cookie) { "session=#{token}" } 15 | 16 | it "sets the authentication cookie" do 17 | subject 18 | expect(HTTP).to have_received(:headers).with(Cookie: expected_cookie).once 19 | end 20 | 21 | it "instantiates an HTTP client" do 22 | subject 23 | expect(HTTP::Client).to have_received(:new).once 24 | end 25 | 26 | it "stores the client" do 27 | expect(subject.client).to eq(client) 28 | end 29 | end 30 | 31 | describe "#initialize" do 32 | include_examples :sets_up_client 33 | end 34 | 35 | describe ".build" do 36 | subject(:request) { described_class.build } 37 | 38 | before do 39 | allow(AocCli.config.session).to receive(:token).and_return(token) 40 | end 41 | 42 | context "when session token is not configured" do 43 | let(:token) { nil } 44 | 45 | it "raises an error" do 46 | expect { request }.to raise_error("session token not set") 47 | end 48 | end 49 | 50 | context "when session token is configured" do 51 | let(:token) { "123456abcdef" } 52 | 53 | it "does not raises any errors" do 54 | expect { request }.not_to raise_error 55 | end 56 | 57 | include_examples :sets_up_client 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/aoc_cli/core/stats_parser_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Core::StatsParser do 2 | subject(:stats_parser) { described_class.new(calendar_html) } 3 | 4 | let(:calendar_html) do 5 | <<~HTML 6 |
 7 |         
 8 |         
 9 |         
10 |       
11 | HTML 12 | end 13 | 14 | describe "#to_h" do 15 | subject(:stats) { stats_parser.to_h } 16 | 17 | let(:stat) { stats[:"day_#{day}"] } 18 | 19 | it "returns a hash" do 20 | expect(stats).to be_a(Hash) 21 | end 22 | 23 | it "sets the expected day keys" do 24 | expect(stats.keys).to include(:day_1, :day_2, :day_3) 25 | end 26 | 27 | it "sets integer values" do 28 | expect(stats.values).to all be_an(Integer) 29 | end 30 | 31 | context "when both parts of puzzle have been completed" do 32 | let(:day) { 1 } 33 | 34 | it "has a value of 2" do 35 | expect(stat).to eq(2) 36 | end 37 | end 38 | 39 | context "when the first part of puzzle has been completed" do 40 | let(:day) { 2 } 41 | 42 | it "has a value of 1" do 43 | expect(stat).to eq(1) 44 | end 45 | end 46 | 47 | context "when neither parts of puzzle have been completed" do 48 | let(:day) { 3 } 49 | 50 | it "has a value of 0" do 51 | expect(stat).to eq(0) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/aoc_cli/models/event_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Event do 2 | subject(:event) { described_class.new(**attributes) } 3 | 4 | let(:attributes) { { year: }.compact } 5 | 6 | describe "validations" do 7 | include_examples :validates_event_year 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/aoc_cli/models/location_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Location do 2 | subject(:location) { described_class.new(**attributes) } 3 | 4 | let(:attributes) { { path:, resource: } } 5 | 6 | let(:path) { "/foo/bar/baz" } 7 | 8 | let(:resource) { create(:event) } 9 | 10 | describe "validations" do 11 | let(:resource) { create(:event) } 12 | 13 | describe ":path" do 14 | context "when nil" do 15 | let(:path) { nil } 16 | 17 | include_examples :invalid, errors: ["Path can't be blank"] 18 | end 19 | 20 | context "when present" do 21 | let(:path) { "/foo/bar/baz" } 22 | 23 | include_examples :valid 24 | end 25 | end 26 | 27 | describe ":resource" do 28 | context "when resource is nil" do 29 | let(:resource) { nil } 30 | 31 | include_examples :invalid, errors: ["Resource can't be blank"] 32 | end 33 | 34 | context "when resource is an Event" do 35 | let(:resource) { create(:event) } 36 | 37 | include_examples :valid 38 | end 39 | 40 | context "when resource is a Puzzle" do 41 | let(:resource) { create(:puzzle) } 42 | 43 | include_examples :valid 44 | end 45 | end 46 | end 47 | 48 | describe "#exists?", :with_temp_dir do 49 | subject(:exists?) { location.exists? } 50 | 51 | context "when path does not exist" do 52 | let(:path) { temp_path("some-dir").to_s } 53 | 54 | it "returns false" do 55 | expect(exists?).to be(false) 56 | end 57 | end 58 | 59 | context "when path exists" do 60 | let(:path) { temp_dir.to_s } 61 | 62 | it "returns true" do 63 | expect(exists?).to be(true) 64 | end 65 | end 66 | end 67 | 68 | describe "#event_dir?" do 69 | context "when resource is an Event" do 70 | let(:resource) { create(:event) } 71 | 72 | it "returns true" do 73 | expect(location).to be_an_event_dir 74 | end 75 | end 76 | 77 | context "when resource is a Puzzle" do 78 | let(:resource) { create(:puzzle) } 79 | 80 | it "returns false" do 81 | expect(location).not_to be_an_event_dir 82 | end 83 | end 84 | end 85 | 86 | describe "#puzzle_dir?" do 87 | context "when resource is an Event" do 88 | let(:resource) { create(:event) } 89 | 90 | it "returns false" do 91 | expect(location).not_to be_a_puzzle_dir 92 | end 93 | end 94 | 95 | context "when resource is a Puzzle" do 96 | let(:resource) { create(:puzzle) } 97 | 98 | it "returns true" do 99 | expect(location).to be_a_puzzle_dir 100 | end 101 | end 102 | end 103 | 104 | describe "#to_pathname" do 105 | subject(:pathname) { location.to_pathname } 106 | 107 | it "returns a Pathname object" do 108 | expect(pathname).to be_a(Pathname) 109 | end 110 | 111 | it "wraps the location path" do 112 | expect(pathname.to_s).to eq(path) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/aoc_cli/models/progress_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Progress do 2 | subject(:progress) { described_class.create(**attributes) } 3 | 4 | let(:attributes) { { puzzle:, level:, started_at:, completed_at: }.compact } 5 | 6 | let(:puzzle) { create(:puzzle) } 7 | 8 | let(:level) { 1 } 9 | 10 | let(:started_at) { now } 11 | 12 | let(:completed_at) { nil } 13 | 14 | let(:now) { Time.now.round(6) } 15 | 16 | before { allow(Time).to receive(:now).and_return(now) } 17 | 18 | describe "validations" do 19 | subject(:progress) { described_class.new(**attributes) } 20 | 21 | describe ":puzzle" do 22 | context "when nil" do 23 | let(:puzzle) { nil } 24 | 25 | include_examples :invalid, errors: ["Puzzle can't be blank"] 26 | end 27 | 28 | context "when present" do 29 | let(:puzzle) { create(:puzzle) } 30 | 31 | include_examples :valid 32 | end 33 | end 34 | 35 | describe ":level" do 36 | context "when nil" do 37 | let(:level) { nil } 38 | 39 | include_examples :invalid, errors: ["Level can't be blank"] 40 | end 41 | 42 | context "when not an integer" do 43 | let(:level) { :level } 44 | 45 | include_examples :invalid, errors: ["Level is not an integer"] 46 | end 47 | 48 | context "when too low" do 49 | let(:level) { 0 } 50 | 51 | include_examples :invalid, errors: ["Level is too small"] 52 | end 53 | 54 | context "when too high" do 55 | let(:level) { 3 } 56 | 57 | include_examples :invalid, errors: ["Level is too large"] 58 | end 59 | 60 | context "when 1" do 61 | let(:level) { 1 } 62 | 63 | include_examples :valid 64 | end 65 | 66 | context "when 2" do 67 | let(:level) { 2 } 68 | 69 | include_examples :valid 70 | end 71 | end 72 | 73 | describe ":started_at" do 74 | context "when nil" do 75 | let(:started_at) { nil } 76 | 77 | include_examples :invalid, errors: ["Started at can't be blank"] 78 | end 79 | 80 | context "when present" do 81 | let(:started_at) { now } 82 | 83 | include_examples :valid 84 | end 85 | end 86 | end 87 | 88 | describe "#complete?" do 89 | let(:started_at) { now - 5000 } 90 | 91 | context "when incomplete" do 92 | it "returns false" do 93 | expect(progress).not_to be_complete 94 | end 95 | end 96 | 97 | context "when complete" do 98 | before { progress.complete! } 99 | 100 | it "returns true" do 101 | expect(progress).to be_complete 102 | end 103 | end 104 | end 105 | 106 | describe "#complete!" do 107 | subject(:complete!) { progress.complete! } 108 | 109 | let(:started_at) { now - 5000 } 110 | 111 | it "updates the completed_at timestamp" do 112 | expect { complete! } 113 | .to change { progress.reload.completed_at } 114 | .from(nil) 115 | .to(now) 116 | end 117 | end 118 | 119 | describe "#reset!" do 120 | subject(:reset!) { progress.reset! } 121 | 122 | let(:started_at) { now - 5000 } 123 | 124 | it "updates the started_at timestamp" do 125 | expect { reset! } 126 | .to change { progress.reload.started_at } 127 | .from(started_at) 128 | .to(now) 129 | end 130 | end 131 | 132 | describe "#time_taken" do 133 | subject(:time_taken) { progress.time_taken } 134 | 135 | let(:started_at) { now - 10_000 } 136 | 137 | context "when progress is complete" do 138 | let(:completed_at) { started_at + 5000 } 139 | 140 | it "returns the time taken to complete the puzzle" do 141 | expect(time_taken).to eq(completed_at - started_at) 142 | end 143 | end 144 | 145 | context "when progress not complete" do 146 | it "returns the time elapsed since starting the puzzle" do 147 | expect(time_taken).to eq(now - started_at) 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/aoc_cli/models/puzzle_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Puzzle do 2 | subject(:puzzle) { described_class.create(**attributes) } 3 | 4 | let(:attributes) { { event:, day:, content:, input: }.compact } 5 | 6 | let(:event) { AocCli::Event.create(year: 2020) } 7 | let(:day) { 1 } 8 | let(:content) { "Puzzle contents" } 9 | let(:input) { "foo bar baz" } 10 | 11 | describe "validations" do 12 | subject(:puzzle) { described_class.new(**attributes) } 13 | 14 | describe ":event" do 15 | context "when nil" do 16 | let(:event) { nil } 17 | 18 | include_examples :invalid, errors: ["Event can't be blank"] 19 | end 20 | 21 | context "when present" do 22 | let(:event) { AocCli::Event.create(year: 2020) } 23 | 24 | include_examples :valid 25 | end 26 | end 27 | 28 | describe ":day" do 29 | include_examples :validates_puzzle_day 30 | end 31 | 32 | describe ":contents" do 33 | context "when nil" do 34 | let(:content) { nil } 35 | 36 | include_examples :invalid, errors: ["Content can't be blank"] 37 | end 38 | 39 | context "when present" do 40 | let(:content) { "Puzzle content" } 41 | 42 | include_examples :valid 43 | end 44 | end 45 | 46 | describe ":input" do 47 | context "when nil" do 48 | let(:input) { nil } 49 | 50 | include_examples :invalid, errors: ["Input can't be blank"] 51 | end 52 | 53 | context "when present" do 54 | let(:input) { "foo bar baz" } 55 | 56 | include_examples :valid 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/aoc_cli/presenters/attempt_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Presenters::AttemptPresenter do 2 | subject(:presenter) { described_class.new(attempt) } 3 | 4 | let(:attempt) { create(:attempt, trait) } 5 | 6 | describe "#status" do 7 | subject(:status) { presenter.status } 8 | 9 | context "when :wrong_level" do 10 | let(:trait) { :wrong_level } 11 | 12 | it "returns the expected text" do 13 | expect(status).to eq("Wrong level") 14 | end 15 | end 16 | 17 | context "when :rate_limited" do 18 | let(:trait) { :rate_limited } 19 | 20 | it "returns the expected text" do 21 | expect(status).to eq("Rate limited") 22 | end 23 | end 24 | 25 | context "when :incorrect" do 26 | let(:trait) { :incorrect } 27 | 28 | it "returns the expected text" do 29 | expect(status).to eq("Incorrect") 30 | end 31 | end 32 | 33 | context "when :correct" do 34 | let(:trait) { :correct } 35 | 36 | it "returns the expected text" do 37 | expect(status).to eq("Correct") 38 | end 39 | end 40 | end 41 | 42 | describe "#hint" do 43 | subject(:hint) { presenter.hint } 44 | 45 | context "when :wrong_level" do 46 | let(:trait) { :wrong_level } 47 | 48 | it "returns the expected text" do 49 | expect(hint).to eq("-") 50 | end 51 | end 52 | 53 | context "when :rate_limited" do 54 | let(:trait) { :rate_limited } 55 | 56 | it "returns the expected text" do 57 | expect(hint).to eq("-") 58 | end 59 | end 60 | 61 | context "when :incorrect" do 62 | let(:trait) { :incorrect } 63 | 64 | it "returns the expected text" do 65 | expect(hint).to eq("-") 66 | end 67 | end 68 | 69 | context "when :too_low" do 70 | let(:trait) { :too_low } 71 | 72 | it "returns the expected text" do 73 | expect(hint).to eq("Too low") 74 | end 75 | end 76 | 77 | context "when :too_high" do 78 | let(:trait) { :too_high } 79 | 80 | it "returns the expected text" do 81 | expect(hint).to eq("Too high") 82 | end 83 | end 84 | 85 | context "when :correct" do 86 | let(:trait) { :correct } 87 | 88 | it "returns the expected text" do 89 | expect(hint).to eq("-") 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/aoc_cli/presenters/puzzle_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Presenters::PuzzlePresenter do 2 | subject(:presenter) { described_class.new(puzzle) } 3 | 4 | let(:event) { create(:event, year:) } 5 | 6 | let(:puzzle) { create(:puzzle, event:, day:) } 7 | 8 | let(:year) { 2016 } 9 | 10 | describe "#date" do 11 | subject(:date) { presenter.date } 12 | 13 | context "when day is single digit" do 14 | let(:day) { 8 } 15 | 16 | it "returns the expected date" do 17 | expect(date).to eq("2016-12-08") 18 | end 19 | end 20 | 21 | context "when day is double digit" do 22 | let(:day) { 10 } 23 | 24 | it "returns the expected date" do 25 | expect(date).to eq("2016-12-10") 26 | end 27 | end 28 | end 29 | 30 | describe "#puzzle_filename" do 31 | subject(:puzzle_filename) { presenter.puzzle_filename } 32 | 33 | context "when day is single digit" do 34 | let(:day) { 8 } 35 | 36 | it "returns the expected filename" do 37 | expect(puzzle_filename).to eq("day_08.md") 38 | end 39 | end 40 | 41 | context "when day is double digit" do 42 | let(:day) { 10 } 43 | 44 | it "returns the expected date" do 45 | expect(puzzle_filename).to eq("day_10.md") 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/aoc_cli/presenters/stats_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Presenters::StatsPresenter do 2 | subject(:presenter) { described_class.new(stats) } 3 | 4 | let(:stats) { create(:stats, day_1: 0, day_2: 1, day_3: 2) } 5 | 6 | describe "#progress_icons" do 7 | subject(:icons) { presenter.progress_icons(day) } 8 | 9 | context "when day is incomplete" do 10 | let(:day) { 1 } 11 | 12 | it "returns the expected_icons" do 13 | expect(icons).to eq(described_class::Icons::INCOMPLETE) 14 | end 15 | end 16 | 17 | context "when day is half complete" do 18 | let(:day) { 2 } 19 | 20 | it "returns the expected_icons" do 21 | expect(icons).to eq(described_class::Icons::HALF_COMPLETE) 22 | end 23 | end 24 | 25 | context "when day is complete" do 26 | let(:day) { 3 } 27 | 28 | it "returns the expected_icons" do 29 | expect(icons).to eq(described_class::Icons::COMPLETE) 30 | end 31 | end 32 | end 33 | 34 | describe "#total_progress" do 35 | subject(:total_progress) { presenter.total_progress } 36 | 37 | it "returns the expected string fraction" do 38 | expect(total_progress).to eq("#{stats.total}/50") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/aoc_cli/processors/puzzle_refresher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Processors::PuzzleRefresher do 2 | describe ".run!" do 3 | subject(:run!) { described_class.run!(puzzle:) } 4 | 5 | context "when puzzle is not a Puzzle record" do 6 | let(:puzzle) { create(:event) } 7 | 8 | include_examples :failed_process, errors: [ 9 | "Puzzle has incompatible type" 10 | ] 11 | end 12 | 13 | context "when puzzle is a Puzzle record" do 14 | let(:puzzle) { create(:puzzle) } 15 | 16 | before do 17 | stub_request(:get, puzzle_url(puzzle)) 18 | .to_return(body: wrap_in_html(fetched_puzzle)) 19 | 20 | stub_request(:get, input_url(puzzle)) 21 | .to_return(body: fetched_input) 22 | end 23 | 24 | context "when cached puzzle is still valid" do 25 | let(:fetched_puzzle) { puzzle.content } 26 | let(:fetched_input) { puzzle.input } 27 | 28 | it "requests the puzzle" do 29 | run! 30 | assert_requested(:get, puzzle_url(puzzle)) 31 | end 32 | 33 | it "requests the input" do 34 | run! 35 | assert_requested(:get, input_url(puzzle)) 36 | end 37 | 38 | it "does not update the puzzle" do 39 | expect { run! }.not_to change { puzzle.reload.values } 40 | end 41 | 42 | it "returns the Puzzle" do 43 | expect(run!).to eq(puzzle) 44 | end 45 | end 46 | 47 | context "when cached puzzle is invalid" do 48 | let(:fetched_puzzle) { puzzle.content.reverse } 49 | let(:fetched_input) { puzzle.input.reverse } 50 | 51 | it "requests the puzzle" do 52 | run! 53 | assert_requested(:get, puzzle_url(puzzle)) 54 | end 55 | 56 | it "requests the input" do 57 | run! 58 | assert_requested(:get, input_url(puzzle)) 59 | end 60 | 61 | it "updates the puzzle to the fetched values" do 62 | expect { run! } 63 | .to change { puzzle.reload.values } 64 | .to include(content: fetched_puzzle, input: fetched_input) 65 | end 66 | 67 | it "returns the Puzzle" do 68 | expect(run!).to eq(puzzle) 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/aoc_cli/processors/stats_initialiser_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Processors::StatsInitialiser do 2 | describe ".run!" do 3 | subject(:run_process) { described_class.run!(event:) } 4 | 5 | context "when event is nil" do 6 | let(:event) { nil } 7 | 8 | include_examples :failed_process, errors: ["Event can't be blank"] 9 | end 10 | 11 | context "when event is not an Event record" do 12 | let(:event) { :event } 13 | 14 | include_examples :failed_process, errors: ["Event has incompatible type"] 15 | end 16 | 17 | context "when event is an Event record" do 18 | let(:event) { create(:event) } 19 | 20 | context "and stats have already been initialised" do 21 | before { create(:stats, event:) } 22 | 23 | include_examples :failed_process, errors: ["Stats already exist"] 24 | end 25 | 26 | context "and stats have not already been initialised" do 27 | before { stub_request(:get, stats_url(event)).to_return(body:) } 28 | 29 | let(:body) do 30 | wrap_in_html(<<~HTML, tag: :pre) 31 |
#{progress_tags.join}
32 | HTML 33 | end 34 | 35 | let(:progress_tags) do 36 | fetched_stats.map do |day, progress| 37 | <<~HTML 38 | 39 | HTML 40 | end 41 | end 42 | 43 | def day_class(day) 44 | "calendar-day#{day.to_s.delete_prefix('day_')}" 45 | end 46 | 47 | def progress_class(progress) 48 | case progress 49 | when 1 then "calendar-complete" 50 | when 2 then "calendar-verycomplete" 51 | end 52 | end 53 | 54 | shared_examples :initialises_stats do 55 | let(:stats) { AocCli::Stats.last } 56 | 57 | it "requests the stats" do 58 | expect { run_process }.to request_url(stats_url(event)) 59 | end 60 | 61 | it "creates a Stats record" do 62 | expect { run_process } 63 | .to create_model(AocCli::Stats) 64 | .with_attributes(event:, **fetched_stats) 65 | end 66 | 67 | it "returns the Stats record" do 68 | expect(run_process).to eq(stats) 69 | end 70 | end 71 | 72 | context "and event is ongoing" do 73 | let(:fetched_stats) { { day_1: 2, day_2: 1, day_3: 0 } } 74 | 75 | include_examples :initialises_stats 76 | end 77 | 78 | context "and event is not ongoing" do 79 | let(:fetched_stats) do 80 | 1.upto(25).to_h { |day| [:"day_#{day}", [0, 1, 2].sample] } 81 | end 82 | 83 | include_examples :initialises_stats 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/aoc_cli/processors/stats_refresher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Processors::StatsRefresher do 2 | subject(:stats) { create(:stats, **initial_stats) } 3 | 4 | let(:initial_stats) { { day_1: 2, day_2: 1 } } 5 | 6 | describe "#run!" do 7 | subject(:run_process) { described_class.run!(stats:) } 8 | 9 | context "when stats is nil" do 10 | let(:stats) { nil } 11 | 12 | include_examples :failed_process, errors: ["Stats can't be blank"] 13 | end 14 | 15 | context "when stats is not a Stats record" do 16 | let(:stats) { :foobar } 17 | 18 | include_examples :failed_process, errors: ["Stats has incompatible type"] 19 | end 20 | 21 | context "when stats is a Stats record" do 22 | before do 23 | stub_request(:get, stats_url(stats)).to_return(body: updated_stats) 24 | end 25 | 26 | let(:updated_stats) do 27 | wrap_in_html <<~HTML, tag: :pre 28 | 29 | 30 | HTML 31 | end 32 | 33 | let(:day_2_class_name) do 34 | case day_2_progress 35 | when 1 then "complete" 36 | when 2 then "verycomplete" 37 | end 38 | end 39 | 40 | context "and cache is up to date" do 41 | let(:day_2_progress) { stats.day_2 } 42 | 43 | it "does not raise any errors" do 44 | expect { run_process }.not_to raise_error 45 | end 46 | 47 | it "requests the stats" do 48 | expect { run_process }.to request_url(stats_url(stats)).via(:get) 49 | end 50 | 51 | it "does not update the stats" do 52 | expect { run_process }.not_to change { stats.reload.values } 53 | end 54 | 55 | it "returns the stats" do 56 | expect(run_process).to eq(stats) 57 | end 58 | end 59 | 60 | context "and cache is out of date" do 61 | let(:day_2_progress) { stats.day_2 + 1 } 62 | 63 | it "does not raise any errors" do 64 | expect { run_process }.not_to raise_error 65 | end 66 | 67 | it "requests the stats" do 68 | expect { run_process }.to request_url(stats_url(stats)).via(:get) 69 | end 70 | 71 | it "updates the stats" do 72 | expect { run_process }.to change { stats.reload.day_2 }.from(1).to(2) 73 | end 74 | 75 | it "returns the stats" do 76 | expect(run_process).to eq(stats) 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/aoc_cli/validators/event_year_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Validators::EventYearValidator do 2 | subject(:model) { model_class.new(year:) } 3 | 4 | let(:model_class) do 5 | Class.new do 6 | include Kangaru::Validatable 7 | 8 | attr_reader :year 9 | 10 | def initialize(year:) 11 | @year = year 12 | end 13 | 14 | validates :year, event_year: true 15 | end 16 | end 17 | 18 | describe "#validate" do 19 | context "when year is nil" do 20 | let(:year) { nil } 21 | 22 | include_examples :invalid, errors: ["Year can't be blank"] 23 | end 24 | 25 | context "when year is not an integer" do 26 | let(:year) { :foobar } 27 | 28 | include_examples :invalid, errors: ["Year is not an integer"] 29 | end 30 | 31 | context "when year is an integer" do 32 | context "and year is before first AoC event" do 33 | let(:year) { 2014 } 34 | 35 | include_examples :invalid, errors: [ 36 | "Year is before first Advent of Code event (2015)" 37 | ] 38 | end 39 | 40 | context "and year is in the future" do 41 | let(:year) { 3010 } 42 | 43 | include_examples :invalid, errors: ["Year is in the future"] 44 | end 45 | 46 | context "and year is valid" do 47 | let(:year) { 2023 } 48 | 49 | include_examples :valid 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/aoc_cli/validators/included_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Validators::IncludedValidator do 2 | subject(:model) { model_class.new(attribute:) } 3 | 4 | let(:model_class) do 5 | Class.new do 6 | include Kangaru::Validatable 7 | 8 | attr_accessor :attribute 9 | 10 | validates :attribute, included: { in: %i[foo bar baz] } 11 | 12 | def initialize(attribute:) 13 | @attribute = attribute 14 | end 15 | end 16 | end 17 | 18 | describe "#validate" do 19 | context "when attribute is not included in the list" do 20 | let(:attribute) { :invalid } 21 | 22 | include_examples :invalid, errors: ["Attribute is not a valid option"] 23 | end 24 | 25 | context "when attribute is included in the list" do 26 | let(:attribute) { :foo } 27 | 28 | include_examples :valid 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/aoc_cli/validators/integer_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Validators::IntegerValidator do 2 | subject(:model) { model_class.new(attribute:) } 3 | 4 | let(:base_model_class) do 5 | Class.new do 6 | include Kangaru::Validatable 7 | 8 | attr_accessor :attribute 9 | 10 | def initialize(attribute:) 11 | @attribute = attribute 12 | end 13 | end 14 | end 15 | 16 | describe "#validate" do 17 | subject(:validate) { model.validate } 18 | 19 | shared_examples :invalid do |options| 20 | let(:errors) { options[:errors] } 21 | 22 | it "is not valid" do 23 | expect(model).not_to be_valid 24 | end 25 | 26 | it "sets the expected errors" do 27 | validate 28 | expect(model.errors.map(&:full_message)).to match_array(errors) 29 | end 30 | end 31 | 32 | shared_examples :valid do 33 | it "is valid" do 34 | expect(model).to be_valid 35 | end 36 | end 37 | 38 | describe ":between" do 39 | let(:model_class) do 40 | Class.new(base_model_class) do 41 | validates :attribute, integer: { between: 5..10 } 42 | end 43 | end 44 | 45 | context "when nil" do 46 | let(:attribute) { nil } 47 | 48 | include_examples :invalid, errors: ["Attribute can't be blank"] 49 | end 50 | 51 | context "when not an integer" do 52 | let(:attribute) { :attribute } 53 | 54 | include_examples :invalid, errors: ["Attribute is not an integer"] 55 | end 56 | 57 | context "when integer is less than minimum in range" do 58 | let(:attribute) { 4 } 59 | 60 | include_examples :invalid, errors: ["Attribute is too small"] 61 | end 62 | 63 | context "when integer is more than maximum in range" do 64 | let(:attribute) { 11 } 65 | 66 | include_examples :invalid, errors: ["Attribute is too large"] 67 | end 68 | 69 | context "when integer is in range" do 70 | let(:attribute) { 8 } 71 | 72 | include_examples :valid 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/aoc_cli/validators/path_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Validators::PathValidator, :with_temp_dir do 2 | subject(:model) { model_class.new(path:) } 3 | 4 | let(:path) { temp_path("foobar").to_s } 5 | 6 | let(:base_model_class) do 7 | Class.new do 8 | include Kangaru::Validatable 9 | 10 | attr_accessor :path 11 | 12 | def initialize(path:) 13 | @path = path 14 | end 15 | end 16 | end 17 | 18 | let(:path_does_not_exist_class) do 19 | Class.new(base_model_class) do 20 | validates :path, path: { exists: false } 21 | end 22 | end 23 | 24 | let(:path_exists_class) do 25 | Class.new(base_model_class) do 26 | validates :path, path: { exists: true } 27 | end 28 | end 29 | 30 | let(:no_options_class) do 31 | Class.new(base_model_class) do 32 | validates :path, path: true 33 | end 34 | end 35 | 36 | shared_context :path_exists do 37 | before { File.write(path, "contents") } 38 | end 39 | 40 | describe "#validate" do 41 | subject(:validate) { model.validate } 42 | 43 | shared_examples :invalid do |options| 44 | let(:errors) { options[:errors] } 45 | it "is not valid" do 46 | expect(model).not_to be_valid 47 | end 48 | 49 | it "sets the expected errors" do 50 | validate 51 | expect(model.errors.map(&:full_message)).to match_array(errors) 52 | end 53 | end 54 | 55 | shared_examples :valid do 56 | it "is valid" do 57 | expect(model).to be_valid 58 | end 59 | end 60 | 61 | shared_examples :validates_path_does_not_exist do 62 | context "and path is nil" do 63 | let(:path) { nil } 64 | 65 | include_examples :invalid, errors: ["Path can't be blank"] 66 | end 67 | 68 | context "and path is set" do 69 | context "and path does not exist" do 70 | include_examples :valid 71 | end 72 | 73 | context "and path exists" do 74 | include_context :path_exists 75 | 76 | include_examples :invalid, errors: ["Path already exists"] 77 | end 78 | end 79 | end 80 | 81 | shared_examples :validates_path_exists do 82 | context "and path is nil" do 83 | let(:path) { nil } 84 | 85 | include_examples :invalid, errors: ["Path can't be blank"] 86 | end 87 | 88 | context "and path is set" do 89 | context "and path does not exist" do 90 | include_examples :invalid, errors: ["Path does not exist"] 91 | end 92 | 93 | context "and path exists" do 94 | include_context :path_exists 95 | 96 | include_examples :valid 97 | end 98 | end 99 | end 100 | 101 | context "when exists is set to false" do 102 | let(:model_class) { path_does_not_exist_class } 103 | 104 | include_examples :validates_path_does_not_exist 105 | end 106 | 107 | context "when exists is set to true" do 108 | let(:model_class) { path_exists_class } 109 | 110 | include_examples :validates_path_exists 111 | end 112 | 113 | context "when exists is not set" do 114 | let(:model_class) { no_options_class } 115 | 116 | include_examples :validates_path_exists 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/aoc_cli/validators/type_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe AocCli::Validators::TypeValidator, :with_temp_dir do 2 | subject(:model) { model_class.new(object:) } 3 | 4 | let(:base_model_class) do 5 | Class.new do 6 | include Kangaru::Validatable 7 | 8 | attr_accessor :object 9 | 10 | def initialize(object:) 11 | @object = object 12 | end 13 | end 14 | end 15 | 16 | let(:string_only_class) do 17 | Class.new(base_model_class) do 18 | validates :object, type: { equals: String } 19 | end 20 | end 21 | 22 | let(:string_or_symbol_class) do 23 | Class.new(base_model_class) do 24 | validates :object, type: { one_of: [String, Symbol] } 25 | end 26 | end 27 | 28 | let(:no_options_class) do 29 | Class.new(base_model_class) do 30 | validates :object, type: true 31 | end 32 | end 33 | 34 | describe "#validate" do 35 | let(:object) { :foobar } 36 | 37 | context "when no options are declared" do 38 | let(:model_class) { no_options_class } 39 | 40 | it "raises an error" do 41 | expect { model.validate }.to raise_error( 42 | "type must be specified via :equals or :one_of options" 43 | ) 44 | end 45 | end 46 | 47 | context "when single type validation is declared" do 48 | let(:model_class) { string_only_class } 49 | 50 | context "and value is nil" do 51 | let(:object) { nil } 52 | 53 | include_examples :invalid, errors: ["Object can't be blank"] 54 | end 55 | 56 | context "and value is present" do 57 | context "and value is not of specified type" do 58 | let(:object) { :symbol } 59 | 60 | include_examples :invalid, errors: ["Object has incompatible type"] 61 | end 62 | 63 | context "and value is of specified type" do 64 | let(:object) { "string" } 65 | 66 | include_examples :valid 67 | end 68 | end 69 | end 70 | 71 | context "when multiple type validation is declared" do 72 | let(:model_class) { string_or_symbol_class } 73 | 74 | context "and value is nil" do 75 | let(:object) { nil } 76 | 77 | include_examples :invalid, errors: ["Object can't be blank"] 78 | end 79 | 80 | context "and value is present" do 81 | context "and value is not of specified type" do 82 | let(:object) { false } 83 | 84 | include_examples :invalid, errors: ["Object has incompatible type"] 85 | end 86 | 87 | context "and value is one of specified types" do 88 | let(:object) { :symbol } 89 | 90 | include_examples :valid 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/factories/attempt_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :attempt, class: "AocCli::Attempt" do 3 | puzzle { association :puzzle } 4 | level { [1, 2].sample } 5 | answer { 123 } 6 | 7 | trait :part_one do 8 | level { 1 } 9 | end 10 | 11 | trait :part_two do 12 | level { 2 } 13 | end 14 | 15 | trait :wrong_level do 16 | status { :wrong_level } 17 | end 18 | 19 | trait :rate_limited do 20 | status { :rate_limited } 21 | wait_time { 1 } 22 | end 23 | 24 | trait :incorrect do 25 | status { :incorrect } 26 | wait_time { 1 } 27 | end 28 | 29 | trait :too_low do 30 | incorrect 31 | hint { :too_low } 32 | end 33 | 34 | trait :too_high do 35 | incorrect 36 | hint { :too_high } 37 | end 38 | 39 | trait :correct do 40 | status { :correct } 41 | hint { nil } 42 | wait_time { nil } 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/factories/event_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :event, class: "AocCli::Event" do 3 | year { (2015..2023).to_a.sample } 4 | 5 | trait :with_location do 6 | transient do 7 | path { "foo/bar/baz" } 8 | end 9 | 10 | after(:create) do |event, evaluator| 11 | create(:location, :year_dir, event:, path: evaluator.path) 12 | end 13 | end 14 | 15 | trait :with_stats do 16 | after(:create) { |event| create(:stats, event:) } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/factories/location_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :location, class: "AocCli::Location" do 3 | path { "foo/bar/baz" } 4 | 5 | trait :year_dir do 6 | transient do 7 | event { association :event } 8 | end 9 | 10 | resource { event } 11 | end 12 | 13 | trait :puzzle_dir do 14 | transient do 15 | puzzle { association :puzzle } 16 | end 17 | 18 | resource { puzzle } 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/factories/progress_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :progress, class: "AocCli::Progress" do 3 | puzzle { association :puzzle } 4 | 5 | started_at { Time.now } 6 | 7 | trait :part_one do 8 | level { 1 } 9 | end 10 | 11 | trait :part_two do 12 | level { 2 } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/factories/puzzle_dir_sync_log_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :puzzle_dir_sync_log, class: "AocCli::PuzzleDirSyncLog" do 3 | puzzle { association :puzzle, :with_location } 4 | location { puzzle.location } 5 | puzzle_status { :unmodified } 6 | input_status { :unmodified } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/factories/puzzle_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :puzzle, class: "AocCli::Puzzle" do 3 | transient do 4 | year { (2015..2023).to_a.sample } 5 | end 6 | 7 | event { association(:event, year:) } 8 | 9 | day { (1..25).to_a.sample } 10 | 11 | content { "Markdown" } 12 | input { "input" } 13 | 14 | trait :with_location do 15 | transient do 16 | path { "foo/bar/baz" } 17 | end 18 | 19 | after(:create) do |puzzle, evaluator| 20 | create(:location, :puzzle_dir, puzzle:, path: evaluator.path) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/factories/stats_factory.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :stats, class: "AocCli::Stats" do 3 | event { association(:event) } 4 | 5 | 1.upto(25) do |i| 6 | send(:"day_#{i}") { 0 } 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/input-2016-08: -------------------------------------------------------------------------------- 1 | rect 1x1 2 | rotate row y=0 by 5 3 | rect 1x1 4 | rotate row y=0 by 6 5 | rect 1x1 6 | rotate row y=0 by 5 7 | rect 1x1 8 | rotate row y=0 by 2 9 | rect 1x1 10 | rotate row y=0 by 5 11 | rect 2x1 12 | rotate row y=0 by 2 13 | rect 1x1 14 | rotate row y=0 by 4 15 | rect 1x1 16 | rotate row y=0 by 3 17 | rect 2x1 18 | rotate row y=0 by 7 19 | rect 3x1 20 | rotate row y=0 by 3 21 | rect 1x1 22 | rotate row y=0 by 3 23 | rect 1x2 24 | rotate row y=1 by 13 25 | rotate column x=0 by 1 26 | rect 2x1 27 | rotate row y=0 by 5 28 | rotate column x=0 by 1 29 | rect 3x1 30 | rotate row y=0 by 18 31 | rotate column x=13 by 1 32 | rotate column x=7 by 2 33 | rotate column x=2 by 3 34 | rotate column x=0 by 1 35 | rect 17x1 36 | rotate row y=3 by 13 37 | rotate row y=1 by 37 38 | rotate row y=0 by 11 39 | rotate column x=7 by 1 40 | rotate column x=6 by 1 41 | rotate column x=4 by 1 42 | rotate column x=0 by 1 43 | rect 10x1 44 | rotate row y=2 by 37 45 | rotate column x=19 by 2 46 | rotate column x=9 by 2 47 | rotate row y=3 by 5 48 | rotate row y=2 by 1 49 | rotate row y=1 by 4 50 | rotate row y=0 by 4 51 | rect 1x4 52 | rotate column x=25 by 3 53 | rotate row y=3 by 5 54 | rotate row y=2 by 2 55 | rotate row y=1 by 1 56 | rotate row y=0 by 1 57 | rect 1x5 58 | rotate row y=2 by 10 59 | rotate column x=39 by 1 60 | rotate column x=35 by 1 61 | rotate column x=29 by 1 62 | rotate column x=19 by 1 63 | rotate column x=7 by 2 64 | rotate row y=4 by 22 65 | rotate row y=3 by 5 66 | rotate row y=1 by 21 67 | rotate row y=0 by 10 68 | rotate column x=2 by 2 69 | rotate column x=0 by 2 70 | rect 4x2 71 | rotate column x=46 by 2 72 | rotate column x=44 by 2 73 | rotate column x=42 by 1 74 | rotate column x=41 by 1 75 | rotate column x=40 by 2 76 | rotate column x=38 by 2 77 | rotate column x=37 by 3 78 | rotate column x=35 by 1 79 | rotate column x=33 by 2 80 | rotate column x=32 by 1 81 | rotate column x=31 by 2 82 | rotate column x=30 by 1 83 | rotate column x=28 by 1 84 | rotate column x=27 by 3 85 | rotate column x=26 by 1 86 | rotate column x=23 by 2 87 | rotate column x=22 by 1 88 | rotate column x=21 by 1 89 | rotate column x=20 by 1 90 | rotate column x=19 by 1 91 | rotate column x=18 by 2 92 | rotate column x=16 by 2 93 | rotate column x=15 by 1 94 | rotate column x=13 by 1 95 | rotate column x=12 by 1 96 | rotate column x=11 by 1 97 | rotate column x=10 by 1 98 | rotate column x=7 by 1 99 | rotate column x=6 by 1 100 | rotate column x=5 by 1 101 | rotate column x=3 by 2 102 | rotate column x=2 by 1 103 | rotate column x=1 by 1 104 | rotate column x=0 by 1 105 | rect 49x1 106 | rotate row y=2 by 34 107 | rotate column x=44 by 1 108 | rotate column x=40 by 2 109 | rotate column x=39 by 1 110 | rotate column x=35 by 4 111 | rotate column x=34 by 1 112 | rotate column x=30 by 4 113 | rotate column x=29 by 1 114 | rotate column x=24 by 1 115 | rotate column x=15 by 4 116 | rotate column x=14 by 1 117 | rotate column x=13 by 3 118 | rotate column x=10 by 4 119 | rotate column x=9 by 1 120 | rotate column x=5 by 4 121 | rotate column x=4 by 3 122 | rotate row y=5 by 20 123 | rotate row y=4 by 20 124 | rotate row y=3 by 48 125 | rotate row y=2 by 20 126 | rotate row y=1 by 41 127 | rotate column x=47 by 5 128 | rotate column x=46 by 5 129 | rotate column x=45 by 4 130 | rotate column x=43 by 5 131 | rotate column x=41 by 5 132 | rotate column x=33 by 1 133 | rotate column x=32 by 3 134 | rotate column x=23 by 5 135 | rotate column x=22 by 1 136 | rotate column x=21 by 2 137 | rotate column x=18 by 2 138 | rotate column x=17 by 3 139 | rotate column x=16 by 2 140 | rotate column x=13 by 5 141 | rotate column x=12 by 5 142 | rotate column x=11 by 5 143 | rotate column x=3 by 5 144 | rotate column x=2 by 5 145 | rotate column x=1 by 5 146 | -------------------------------------------------------------------------------- /spec/fixtures/input-2017-15: -------------------------------------------------------------------------------- 1 | Generator A starts with 277 2 | Generator B starts with 349 3 | -------------------------------------------------------------------------------- /spec/fixtures/input-2017-15.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://adventofcode.com/2017/day/15/input 6 | body: 7 | encoding: ASCII-8BIT 8 | string: '' 9 | headers: 10 | Connection: 11 | - close 12 | Host: 13 | - adventofcode.com 14 | User-Agent: 15 | - http.rb/5.1.1 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Date: 22 | - Fri, 01 Dec 2023 23:14:03 GMT 23 | Content-Type: 24 | - text/plain 25 | Content-Length: 26 | - '56' 27 | Connection: 28 | - close 29 | Server: 30 | - Apache 31 | Server-Ip: 32 | - 172.31.16.87 33 | Vary: 34 | - Accept-Encoding 35 | Strict-Transport-Security: 36 | - max-age=300 37 | body: 38 | encoding: ASCII-8BIT 39 | string: | 40 | Generator A starts with 277 41 | Generator B starts with 349 42 | recorded_at: Fri, 01 Dec 2023 23:14:03 GMT 43 | recorded_with: VCR 6.2.0 44 | -------------------------------------------------------------------------------- /spec/fixtures/input-2018-19: -------------------------------------------------------------------------------- 1 | #ip 4 2 | addi 4 16 4 3 | seti 1 7 2 4 | seti 1 1 5 5 | mulr 2 5 3 6 | eqrr 3 1 3 7 | addr 3 4 4 8 | addi 4 1 4 9 | addr 2 0 0 10 | addi 5 1 5 11 | gtrr 5 1 3 12 | addr 4 3 4 13 | seti 2 7 4 14 | addi 2 1 2 15 | gtrr 2 1 3 16 | addr 3 4 4 17 | seti 1 3 4 18 | mulr 4 4 4 19 | addi 1 2 1 20 | mulr 1 1 1 21 | mulr 4 1 1 22 | muli 1 11 1 23 | addi 3 3 3 24 | mulr 3 4 3 25 | addi 3 9 3 26 | addr 1 3 1 27 | addr 4 0 4 28 | seti 0 1 4 29 | setr 4 9 3 30 | mulr 3 4 3 31 | addr 4 3 3 32 | mulr 4 3 3 33 | muli 3 14 3 34 | mulr 3 4 3 35 | addr 1 3 1 36 | seti 0 6 0 37 | seti 0 7 4 38 | -------------------------------------------------------------------------------- /spec/fixtures/input-2018-19.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://adventofcode.com/2018/day/19/input 6 | body: 7 | encoding: ASCII-8BIT 8 | string: '' 9 | headers: 10 | Connection: 11 | - close 12 | Host: 13 | - adventofcode.com 14 | User-Agent: 15 | - http.rb/5.1.1 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Date: 22 | - Fri, 01 Dec 2023 23:14:03 GMT 23 | Content-Type: 24 | - text/plain 25 | Content-Length: 26 | - '405' 27 | Connection: 28 | - close 29 | Server: 30 | - Apache 31 | Server-Ip: 32 | - 172.31.59.243 33 | Vary: 34 | - Accept-Encoding 35 | Strict-Transport-Security: 36 | - max-age=300 37 | body: 38 | encoding: ASCII-8BIT 39 | string: | 40 | #ip 4 41 | addi 4 16 4 42 | seti 1 7 2 43 | seti 1 1 5 44 | mulr 2 5 3 45 | eqrr 3 1 3 46 | addr 3 4 4 47 | addi 4 1 4 48 | addr 2 0 0 49 | addi 5 1 5 50 | gtrr 5 1 3 51 | addr 4 3 4 52 | seti 2 7 4 53 | addi 2 1 2 54 | gtrr 2 1 3 55 | addr 3 4 4 56 | seti 1 3 4 57 | mulr 4 4 4 58 | addi 1 2 1 59 | mulr 1 1 1 60 | mulr 4 1 1 61 | muli 1 11 1 62 | addi 3 3 3 63 | mulr 3 4 3 64 | addi 3 9 3 65 | addr 1 3 1 66 | addr 4 0 4 67 | seti 0 1 4 68 | setr 4 9 3 69 | mulr 3 4 3 70 | addr 4 3 3 71 | mulr 4 3 3 72 | muli 3 14 3 73 | mulr 3 4 3 74 | addr 1 3 1 75 | seti 0 6 0 76 | seti 0 7 4 77 | recorded_at: Fri, 01 Dec 2023 23:14:03 GMT 78 | recorded_with: VCR 6.2.0 79 | -------------------------------------------------------------------------------- /spec/fixtures/input-2019-01: -------------------------------------------------------------------------------- 1 | 71764 2 | 58877 3 | 107994 4 | 72251 5 | 74966 6 | 87584 7 | 118260 8 | 144961 9 | 86889 10 | 136710 11 | 52493 12 | 131045 13 | 101496 14 | 124341 15 | 71936 16 | 88967 17 | 106520 18 | 125454 19 | 113463 20 | 81854 21 | 99918 22 | 105217 23 | 120383 24 | 61105 25 | 103842 26 | 125151 27 | 139191 28 | 143365 29 | 102168 30 | 69845 31 | 57343 32 | 93401 33 | 140910 34 | 121997 35 | 107964 36 | 53358 37 | 57397 38 | 141456 39 | 94052 40 | 127395 41 | 99180 42 | 143838 43 | 130749 44 | 126809 45 | 70165 46 | 92007 47 | 83343 48 | 55163 49 | 95270 50 | 101323 51 | 99877 52 | 105721 53 | 129657 54 | 61213 55 | 130120 56 | 108549 57 | 90539 58 | 111382 59 | 61665 60 | 95121 61 | 53216 62 | 103144 63 | 134367 64 | 101251 65 | 105118 66 | 73220 67 | 56270 68 | 50846 69 | 77314 70 | 59134 71 | 98495 72 | 113654 73 | 89711 74 | 68676 75 | 98991 76 | 109068 77 | 129630 78 | 58999 79 | 132095 80 | 98685 81 | 91762 82 | 88589 83 | 73846 84 | 124940 85 | 106944 86 | 133882 87 | 104073 88 | 78475 89 | 76545 90 | 144728 91 | 72449 92 | 118320 93 | 65363 94 | 83523 95 | 124634 96 | 96222 97 | 128252 98 | 112848 99 | 139027 100 | 108208 101 | -------------------------------------------------------------------------------- /spec/fixtures/input-2019-01.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://adventofcode.com/2019/day/1/input 6 | body: 7 | encoding: ASCII-8BIT 8 | string: '' 9 | headers: 10 | Connection: 11 | - close 12 | Host: 13 | - adventofcode.com 14 | User-Agent: 15 | - http.rb/5.1.1 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Date: 22 | - Fri, 01 Dec 2023 23:14:03 GMT 23 | Content-Type: 24 | - text/plain 25 | Content-Length: 26 | - '650' 27 | Connection: 28 | - close 29 | Server: 30 | - Apache 31 | Server-Ip: 32 | - 172.31.63.108 33 | Vary: 34 | - Accept-Encoding 35 | Strict-Transport-Security: 36 | - max-age=300 37 | body: 38 | encoding: ASCII-8BIT 39 | string: | 40 | 71764 41 | 58877 42 | 107994 43 | 72251 44 | 74966 45 | 87584 46 | 118260 47 | 144961 48 | 86889 49 | 136710 50 | 52493 51 | 131045 52 | 101496 53 | 124341 54 | 71936 55 | 88967 56 | 106520 57 | 125454 58 | 113463 59 | 81854 60 | 99918 61 | 105217 62 | 120383 63 | 61105 64 | 103842 65 | 125151 66 | 139191 67 | 143365 68 | 102168 69 | 69845 70 | 57343 71 | 93401 72 | 140910 73 | 121997 74 | 107964 75 | 53358 76 | 57397 77 | 141456 78 | 94052 79 | 127395 80 | 99180 81 | 143838 82 | 130749 83 | 126809 84 | 70165 85 | 92007 86 | 83343 87 | 55163 88 | 95270 89 | 101323 90 | 99877 91 | 105721 92 | 129657 93 | 61213 94 | 130120 95 | 108549 96 | 90539 97 | 111382 98 | 61665 99 | 95121 100 | 53216 101 | 103144 102 | 134367 103 | 101251 104 | 105118 105 | 73220 106 | 56270 107 | 50846 108 | 77314 109 | 59134 110 | 98495 111 | 113654 112 | 89711 113 | 68676 114 | 98991 115 | 109068 116 | 129630 117 | 58999 118 | 132095 119 | 98685 120 | 91762 121 | 88589 122 | 73846 123 | 124940 124 | 106944 125 | 133882 126 | 104073 127 | 78475 128 | 76545 129 | 144728 130 | 72449 131 | 118320 132 | 65363 133 | 83523 134 | 124634 135 | 96222 136 | 128252 137 | 112848 138 | 139027 139 | 108208 140 | recorded_at: Fri, 01 Dec 2023 23:14:03 GMT 141 | recorded_with: VCR 6.2.0 142 | -------------------------------------------------------------------------------- /spec/fixtures/input-2020-23: -------------------------------------------------------------------------------- 1 | 247819356 2 | -------------------------------------------------------------------------------- /spec/fixtures/input-2020-23.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://adventofcode.com/2020/day/23/input 6 | body: 7 | encoding: ASCII-8BIT 8 | string: '' 9 | headers: 10 | Connection: 11 | - close 12 | Host: 13 | - adventofcode.com 14 | User-Agent: 15 | - http.rb/5.1.1 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Date: 22 | - Fri, 01 Dec 2023 23:14:01 GMT 23 | Content-Type: 24 | - text/plain 25 | Content-Length: 26 | - '10' 27 | Connection: 28 | - close 29 | Server: 30 | - Apache 31 | Server-Ip: 32 | - 172.31.59.243 33 | Vary: 34 | - Accept-Encoding 35 | Strict-Transport-Security: 36 | - max-age=300 37 | body: 38 | encoding: ASCII-8BIT 39 | string: '247819356 40 | 41 | ' 42 | recorded_at: Fri, 01 Dec 2023 23:14:01 GMT 43 | recorded_with: VCR 6.2.0 44 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2015-03.md: -------------------------------------------------------------------------------- 1 | ## --- Day 3: Perfectly Spherical Houses in a Vacuum --- 2 | 3 | Santa is delivering presents to an infinite two-dimensional grid of houses. 4 | 5 | He begins by delivering a present to the house at his starting location, and then an elf at the North Pole calls him via radio and tells him where to move next. Moves are always exactly one house to the north (`^`), south (`v`), east (`>`), or west (`<`). After each move, he delivers another present to the house at his new location. 6 | 7 | However, the elf back at the north pole has had a little too much eggnog, and so his directions are a little off, and Santa ends up visiting some houses more than once. How many houses receive _at least one present_? 8 | 9 | For example: 10 | 11 | - `>` delivers presents to `2` houses: one at the starting location, and one to the east. 12 | - `^>v<` delivers presents to `4` houses in a square, including twice to the house at his starting/ending location. 13 | - `^v^v^v^v^v` delivers a bunch of presents to some very lucky children at only `2` houses. 14 | 15 | ## --- Part Two --- 16 | 17 | The next year, to speed up the process, Santa creates a robot version of himself, _Robo-Santa_, to deliver presents with him. 18 | 19 | Santa and Robo-Santa start at the same location (delivering two presents to the same starting house), then take turns moving based on instructions from the elf, who is eggnoggedly reading from the same script as the previous year. 20 | 21 | This year, how many houses receive _at least one present_? 22 | 23 | For example: 24 | 25 | - `^v` delivers presents to `3` houses, because Santa goes north, and then Robo-Santa goes south. 26 | - `^>v<` now delivers presents to `3` houses, and Santa and Robo-Santa end up back where they started. 27 | - `^v^v^v^v^v` now delivers presents to `11` houses, with Santa going one direction and Robo-Santa going the other. 28 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2016-08.md: -------------------------------------------------------------------------------- 1 | ## --- Day 8: Two-Factor Authentication --- 2 | 3 | You come across a door implementing what you can only assume is an implementation of [two-factor authentication](https://en.wikipedia.org/wiki/Multi-factor_authentication) after a long game of [requirements](https://en.wikipedia.org/wiki/Requirement) [telephone](https://en.wikipedia.org/wiki/Chinese_whispers). 4 | 5 | To get past the door, you first swipe a keycard (no problem; there was one on a nearby desk). Then, it displays a code on a [little screen](https://www.google.com/search?q=tiny+lcd&tbm=isch), and you type that code on a keypad. Then, presumably, the door unlocks. 6 | 7 | Unfortunately, the screen has been smashed. After a few minutes, you've taken everything apart and figured out how it works. Now you just have to work out what the screen _would_ have displayed. 8 | 9 | The magnetic strip on the card you swiped encodes a series of instructions for the screen; these instructions are your puzzle input. The screen is _`50` pixels wide and `6` pixels tall_, all of which start _off_, and is capable of three somewhat peculiar operations: 10 | 11 | - `rect AxB` turns _on_ all of the pixels in a rectangle at the top-left of the screen which is `A` wide and `B` tall. 12 | - `rotate row y=A by B` shifts all of the pixels in row `A` (0 is the top row) _right_ by `B` pixels. Pixels that would fall off the right end appear at the left end of the row. 13 | - `rotate column x=A by B` shifts all of the pixels in column `A` (0 is the left column) _down_ by `B` pixels. Pixels that would fall off the bottom appear at the top of the column. 14 | 15 | For example, here is a simple sequence on a smaller screen: 16 | 17 | - `rect 3x2` creates a small rectangle in the top-left corner: 18 | 19 | - `rotate column x=1 by 1` rotates the second column down by one pixel: 20 | 21 | - `rotate row y=0 by 4` rotates the top row right by four pixels: 22 | 23 | - `rotate column x=1 by 1` again rotates the second column down by one pixel, causing the bottom pixel to wrap back to the top: 24 | 25 | As you can see, this display technology is extremely powerful, and will soon dominate the tiny-code-displaying-screen market. That's what the advertisement on the back of the display tries to convince you, anyway. 26 | 27 | There seems to be an intermediate check of the voltage used by the display: after you swipe your card, if the screen did work, _how many pixels should be lit?_ 28 | 29 | ## --- Part Two --- 30 | 31 | You notice that the screen is only capable of displaying capital letters; in the font it uses, each letter is `5` pixels wide and `6` tall. 32 | 33 | After you swipe your card, _what code is the screen trying to display?_ 34 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2017-15.md: -------------------------------------------------------------------------------- 1 | ## --- Day 15: Dueling Generators --- 2 | 3 | Here, you encounter a pair of dueling generators. The generators, called _generator A_ and _generator B_, are trying to agree on a sequence of numbers. However, one of them is malfunctioning, and so the sequences don't always match. 4 | 5 | As they do this, a _judge_ waits for each of them to generate its next value, compares the lowest 16 bits of both values, and keeps track of the number of times those parts of the values match. 6 | 7 | The generators both work on the same principle. To create its next value, a generator will take the previous value it produced, multiply it by a _factor_ (generator A uses `16807`; generator B uses `48271`), and then keep the remainder of dividing that resulting product by `2147483647`. That final remainder is the value it produces next. 8 | 9 | To calculate each generator's first value, it instead uses a specific starting value as its "previous value" (as listed in your puzzle input). 10 | 11 | For example, suppose that for starting values, generator A uses `65`, while generator B uses `8921`. Then, the first five pairs of generated values are: 12 | 13 | --Gen. A-- --Gen. B-- 14 | 1092455 430625591 15 | 1181022009 1233683848 16 | 245556042 1431495498 17 | 1744312007 137874439 18 | 1352636452 285222916 19 | 20 | In binary, these pairs are (with generator A's value first in each pair): 21 | 22 | 00000000000100001010101101100111 23 | 00011001101010101101001100110111 24 | 25 | 01000110011001001111011100111001 26 | 01001001100010001000010110001000 27 | 28 | 00001110101000101110001101001010 29 | 01010101010100101110001101001010 30 | 31 | 01100111111110000001011011000111 32 | 00001000001101111100110000000111 33 | 34 | 01010000100111111001100000100100 35 | 00010001000000000010100000000100 36 | 37 | Here, you can see that the lowest (here, rightmost) 16 bits of the third value match: `1110001101001010`. Because of this one match, after processing these five pairs, the judge would have added only `1` to its total. 38 | 39 | To get a significant sample, the judge would like to consider _40 million_ pairs. (In the example above, the judge would eventually find a total of `588` pairs that match in their lowest 16 bits.) 40 | 41 | After 40 million pairs, _what is the judge's final count_? 42 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2019-01.md: -------------------------------------------------------------------------------- 1 | ## --- Day 1: The Tyranny of the Rocket Equation --- 2 | 3 | Santa has become stranded at the edge of the Solar System while delivering presents to other planets! To accurately calculate his position in space, safely align his warp drive, and return to Earth in time to save Christmas, he needs you to bring him measurements from _fifty stars_. 4 | 5 | Collect stars by solving puzzles. Two puzzles will be made available on each day in the Advent calendar; the second puzzle is unlocked when you complete the first. Each puzzle grants _one star_. Good luck! 6 | 7 | The Elves quickly load you into a spacecraft and prepare to launch. 8 | 9 | At the first Go / No Go poll, every Elf is Go until the Fuel Counter-Upper. They haven't determined the amount of fuel required yet. 10 | 11 | Fuel required to launch a given _module_ is based on its _mass_. Specifically, to find the fuel required for a module, take its mass, divide by three, round down, and subtract 2. 12 | 13 | For example: 14 | 15 | - For a mass of `12`, divide by 3 and round down to get `4`, then subtract 2 to get `2`. 16 | - For a mass of `14`, dividing by 3 and rounding down still yields `4`, so the fuel required is also `2`. 17 | - For a mass of `1969`, the fuel required is `654`. 18 | - For a mass of `100756`, the fuel required is `33583`. 19 | 20 | The Fuel Counter-Upper needs to know the total fuel requirement. To find it, individually calculate the fuel needed for the mass of each module (your puzzle input), then add together all the fuel values. 21 | 22 | _What is the sum of the fuel requirements_ for all of the modules on your spacecraft? 23 | 24 | ## --- Part Two --- 25 | 26 | During the second Go / No Go poll, the Elf in charge of the Rocket Equation Double-Checker stops the launch sequence. Apparently, you forgot to include additional fuel for the fuel you just added. 27 | 28 | Fuel itself requires fuel just like a module - take its mass, divide by three, round down, and subtract 2. However, that fuel _also_ requires fuel, and _that_ fuel requires fuel, and so on. Any mass that would require _negative fuel_ should instead be treated as if it requires _zero fuel_; the remaining mass, if any, is instead handled by _wishing really hard_, which has no mass and is outside the scope of this calculation. 29 | 30 | So, for each module mass, calculate its fuel and add it to the total. Then, treat the fuel amount you just calculated as the input mass and repeat the process, continuing until a fuel requirement is zero or negative. For example: 31 | 32 | - A module of mass `14` requires `2` fuel. This fuel requires no further fuel (2 divided by 3 and rounded down is `0`, which would call for a negative fuel), so the total fuel required is still just `2`. 33 | - At first, a module of mass `1969` requires `654` fuel. Then, this fuel requires `216` more fuel (`654 / 3 - 2`). `216` then requires `70` more fuel, which requires `21` fuel, which requires `5` fuel, which requires no further fuel. So, the total fuel required for a module of mass `1969` is `654 + 216 + 70 + 21 + 5 = 966`. 34 | - The fuel required by a module of mass `100756` and its fuel is: `33583 + 11192 + 3728 + 1240 + 411 + 135 + 43 + 12 + 2 = 50346`. 35 | 36 | _What is the sum of the fuel requirements_ for all of the modules on your spacecraft when also taking into account the mass of the added fuel? (Calculate the fuel requirements for each module separately, then add them all up at the end.) 37 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2020-23.md: -------------------------------------------------------------------------------- 1 | ## --- Day 23: Crab Cups --- 2 | 3 | The small crab challenges _you_ to a game! The crab is going to mix up some cups, and you have to predict where they'll end up. 4 | 5 | The cups will be arranged in a circle and labeled _clockwise_ (your puzzle input). For example, if your labeling were `32415`, there would be five cups in the circle; going clockwise around the circle from the first cup, the cups would be labeled `3`, `2`, `4`, `1`, `5`, and then back to `3` again. 6 | 7 | Before the crab starts, it will designate the first cup in your list as the _current cup_. The crab is then going to do _100 moves_. 8 | 9 | Each _move_, the crab does the following actions: 10 | 11 | - The crab picks up the _three cups_ that are immediately _clockwise_ of the _current cup_. They are removed from the circle; cup spacing is adjusted as necessary to maintain the circle. 12 | - The crab selects a _destination cup_: the cup with a _label_ equal to the _current cup's_ label minus one. If this would select one of the cups that was just picked up, the crab will keep subtracting one until it finds a cup that wasn't just picked up. If at any point in this process the value goes below the lowest value on any cup's label, it _wraps around_ to the highest value on any cup's label instead. 13 | - The crab places the cups it just picked up so that they are _immediately clockwise_ of the destination cup. They keep the same order as when they were picked up. 14 | - The crab selects a new _current cup_: the cup which is immediately clockwise of the current cup. 15 | 16 | For example, suppose your cup labeling were `389125467`. If the crab were to do merely 10 moves, the following changes would occur: 17 | 18 | -- move 1 -- 19 | cups: (3) 8 9 1 2 5 4 6 7 20 | pick up: 8, 9, 1 21 | destination: 2 22 | 23 | -- move 2 -- 24 | cups: 3 (2) 8 9 1 5 4 6 7 25 | pick up: 8, 9, 1 26 | destination: 7 27 | 28 | -- move 3 -- 29 | cups: 3 2 (5) 4 6 7 8 9 1 30 | pick up: 4, 6, 7 31 | destination: 3 32 | 33 | -- move 4 -- 34 | cups: 7 2 5 (8) 9 1 3 4 6 35 | pick up: 9, 1, 3 36 | destination: 7 37 | 38 | -- move 5 -- 39 | cups: 3 2 5 8 (4) 6 7 9 1 40 | pick up: 6, 7, 9 41 | destination: 3 42 | 43 | -- move 6 -- 44 | cups: 9 2 5 8 4 (1) 3 6 7 45 | pick up: 3, 6, 7 46 | destination: 9 47 | 48 | -- move 7 -- 49 | cups: 7 2 5 8 4 1 (9) 3 6 50 | pick up: 3, 6, 7 51 | destination: 8 52 | 53 | -- move 8 -- 54 | cups: 8 3 6 7 4 1 9 (2) 5 55 | pick up: 5, 8, 3 56 | destination: 1 57 | 58 | -- move 9 -- 59 | cups: 7 4 1 5 8 3 9 2 (6) 60 | pick up: 7, 4, 1 61 | destination: 5 62 | 63 | -- move 10 -- 64 | cups: (5) 7 4 1 8 3 9 2 6 65 | pick up: 7, 4, 1 66 | destination: 3 67 | 68 | -- final -- 69 | cups: 5 (8) 3 7 4 1 9 2 6 70 | 71 | In the above example, the cups' values are the labels as they appear moving clockwise around the circle; the _current cup_ is marked with `( )`. 72 | 73 | After the crab is done, what order will the cups be in? Starting _after the cup labeled `1`_, collect the other cups' labels clockwise into a single string with no extra characters; each number except `1` should appear exactly once. In the above example, after 10 moves, the cups clockwise from `1` are labeled `9`, `2`, `6`, `5`, and so on, producing _`92658374`_. If the crab were to complete all 100 moves, the order after cup `1` would be _`67384529`_. 74 | 75 | Using your labeling, simulate 100 moves. _What are the labels on the cups after cup `1`?_ 76 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2022-08.md: -------------------------------------------------------------------------------- 1 | ## --- Day 8: Treetop Tree House --- 2 | 3 | The expedition comes across a peculiar patch of tall trees all planted carefully in a grid. The Elves explain that a previous expedition planted these trees as a reforestation effort. Now, they're curious if this would be a good location for a [tree house](https://en.wikipedia.org/wiki/Tree_house). 4 | 5 | First, determine whether there is enough tree cover here to keep a tree house _hidden_. To do this, you need to count the number of trees that are _visible from outside the grid_ when looking directly along a row or column. 6 | 7 | The Elves have already launched a [quadcopter](https://en.wikipedia.org/wiki/Quadcopter) to generate a map with the height of each tree (your puzzle input). For example: 8 | 9 | 30373 10 | 25512 11 | 65332 12 | 33549 13 | 35390 14 | 15 | Each tree is represented as a single digit whose value is its height, where `0` is the shortest and `9` is the tallest. 16 | 17 | A tree is _visible_ if all of the other trees between it and an edge of the grid are _shorter_ than it. Only consider trees in the same row or column; that is, only look up, down, left, or right from any given tree. 18 | 19 | All of the trees around the edge of the grid are _visible_ - since they are already on the edge, there are no trees to block the view. In this example, that only leaves the _interior nine trees_ to consider: 20 | 21 | - The top-left `5` is _visible_ from the left and top. (It isn't visible from the right or bottom since other trees of height `5` are in the way.) 22 | - The top-middle `5` is _visible_ from the top and right. 23 | - The top-right `1` is not visible from any direction; for it to be visible, there would need to only be trees of height _0_ between it and an edge. 24 | - The left-middle `5` is _visible_, but only from the right. 25 | - The center `3` is not visible from any direction; for it to be visible, there would need to be only trees of at most height `2` between it and an edge. 26 | - The right-middle `3` is _visible_ from the right. 27 | - In the bottom row, the middle `5` is _visible_, but the `3` and `4` are not. 28 | 29 | With 16 trees visible on the edge and another 5 visible in the interior, a total of `21` trees are visible in this arrangement. 30 | 31 | Consider your map; _how many trees are visible from outside the grid?_ 32 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2023-01.md: -------------------------------------------------------------------------------- 1 | ## --- Day 1: Trebuchet?! --- 2 | 3 | Something is wrong with global snow production, and you've been selected to take a look. The Elves have even given you a map; on it, they've used stars to mark the top fifty locations that are likely to be having problems. 4 | 5 | You've been doing this long enough to know that to restore snow operations, you need to check all _fifty stars_ by December 25th. 6 | 7 | Collect stars by solving puzzles. Two puzzles will be made available on each day in the Advent calendar; the second puzzle is unlocked when you complete the first. Each puzzle grants _one star_. Good luck! 8 | 9 | You try to ask why they can't just use a [weather machine](/2015/day/1) ("not powerful enough") and where they're even sending you ("the sky") and why your map looks mostly blank ("you sure ask a lot of questions") and hang on did you just say the sky ("of course, where do you think snow comes from") when you realize that the Elves are already loading you into a [trebuchet](https://en.wikipedia.org/wiki/Trebuchet) ("please hold still, we need to strap you in"). 10 | 11 | As they're making the final adjustments, they discover that their calibration document (your puzzle input) has been _amended_ by a very young Elf who was apparently just excited to show off her art skills. Consequently, the Elves are having trouble reading the values on the document. 12 | 13 | The newly-improved calibration document consists of lines of text; each line originally contained a specific _calibration value_ that the Elves now need to recover. On each line, the calibration value can be found by combining the _first digit_ and the _last digit_ (in that order) to form a single _two-digit number_. 14 | 15 | For example: 16 | 17 | 1abc2 18 | pqr3stu8vwx 19 | a1b2c3d4e5f 20 | treb7uchet 21 | 22 | In this example, the calibration values of these four lines are `12`, `38`, `15`, and `77`. Adding these together produces `142`. 23 | 24 | Consider your entire calibration document. _What is the sum of all of the calibration values?_ 25 | -------------------------------------------------------------------------------- /spec/fixtures/puzzle-2023-02.md: -------------------------------------------------------------------------------- 1 | ## --- Day 2: Cube Conundrum --- 2 | 3 | You're launched high into the atmosphere! The apex of your trajectory just barely reaches the surface of a large island floating in the sky. You gently land in a fluffy pile of leaves. It's quite cold, but you don't see much snow. An Elf runs over to greet you. 4 | 5 | The Elf explains that you've arrived at _Snow Island_ and apologizes for the lack of snow. He'll be happy to explain the situation, but it's a bit of a walk, so you have some time. They don't get many visitors up here; would you like to play a game in the meantime? 6 | 7 | As you walk, the Elf shows you a small bag and some cubes which are either red, green, or blue. Each time you play this game, he will hide a secret number of cubes of each color in the bag, and your goal is to figure out information about the number of cubes. 8 | 9 | To get information, once a bag has been loaded with cubes, the Elf will reach into the bag, grab a handful of random cubes, show them to you, and then put them back in the bag. He'll do this a few times per game. 10 | 11 | You play several games and record the information from each game (your puzzle input). Each game is listed with its ID number (like the `11` in `Game 11: ...`) followed by a semicolon-separated list of subsets of cubes that were revealed from the bag (like `3 red, 5 green, 4 blue`). 12 | 13 | For example, the record of a few games might look like this: 14 | 15 | Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green 16 | Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue 17 | Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red 18 | Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red 19 | Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green 20 | 21 | In game 1, three sets of cubes are revealed from the bag (and then put back again). The first set is 3 blue cubes and 4 red cubes; the second set is 1 red cube, 2 green cubes, and 6 blue cubes; the third set is only 2 green cubes. 22 | 23 | The Elf would first like to know which games would have been possible if the bag contained _only 12 red cubes, 13 green cubes, and 14 blue cubes_? 24 | 25 | In the example above, games 1, 2, and 5 would have been _possible_ if the bag had been loaded with that configuration. However, game 3 would have been _impossible_ because at one point the Elf showed you 20 red cubes at once; similarly, game 4 would also have been _impossible_ because the Elf showed you 15 blue cubes at once. If you add up the IDs of the games that would have been possible, you get `8`. 26 | 27 | Determine which games would have been possible if the bag had been loaded with only 12 red cubes, 13 green cubes, and 14 blue cubes. _What is the sum of the IDs of those games?_ 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "aoc_cli" 2 | require "factory_bot" 3 | require "simplecov" 4 | require "simplecov_json_formatter" 5 | require "webmock/rspec" 6 | require "vcr" 7 | 8 | SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter 9 | SimpleCov.start 10 | 11 | RSpec.configure do |config| 12 | Kernel.srand(config.seed) 13 | 14 | Dir[File.join(__dir__, "support/**/*.rb")].each { |file| require file } 15 | 16 | config.disable_monkey_patching! 17 | 18 | config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true } 19 | 20 | config.order = :random 21 | 22 | config.default_formatter = :doc if config.files_to_run.one? 23 | 24 | config.before(:suite) do 25 | FactoryBot.find_definitions 26 | 27 | FactoryBot.define { to_create(&:save) } 28 | end 29 | 30 | VCR.configure do |vcr| 31 | vcr.hook_into :webmock 32 | vcr.cassette_library_dir = "spec/fixtures" 33 | end 34 | 35 | config.include FactoryBot::Syntax::Methods 36 | 37 | config.include Matchers 38 | 39 | config.include CassetteHelpers 40 | config.include FixtureHelpers 41 | config.include PathHelpers 42 | config.include RequestHelpers 43 | 44 | def spec_dir 45 | Pathname(File.expand_path(__dir__)) 46 | end 47 | 48 | def formatted_day(day) 49 | day&.to_s&.rjust(2, "0") 50 | end 51 | 52 | # TODO: Controller class should not be cached by Kangaru. This raises an 53 | # error when more than one controller is requested as it is cached. 54 | config.before(type: :request) do 55 | router = Kangaru.application.router 56 | 57 | if router.instance_variable_defined?(:@controller_class) 58 | router.remove_instance_variable(:@controller_class) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/support/cassette_helpers.rb: -------------------------------------------------------------------------------- 1 | module CassetteHelpers 2 | def use_stats_cassette(year:, tag: nil, &) 3 | use_cassette(:stats, year:, tag:, &) 4 | end 5 | 6 | def use_puzzle_cassette(year:, day:, tag: nil, &) 7 | use_cassette(:puzzle, year:, day:, tag:, &) 8 | end 9 | 10 | def use_input_cassette(year:, day:, tag: nil, &) 11 | use_cassette(:input, year:, day:, tag:, &) 12 | end 13 | 14 | def use_solution_cassette(year:, day:, level:, tag: nil, &) 15 | use_cassette(:solution, year:, day:, level:, tag:, &) 16 | end 17 | 18 | private 19 | 20 | def use_cassette(type, year:, day: nil, level: nil, tag: nil, &) 21 | VCR.use_cassette(cassette_name(type, year, day, level, tag), &) 22 | end 23 | 24 | def cassette_name(type, year, day, level, tag) 25 | [type, year, formatted_day(day), level, tag] 26 | .compact 27 | .map { |part| part.to_s.gsub("_", "-") } 28 | .join("-") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/concern_helpers.rb: -------------------------------------------------------------------------------- 1 | module ConcernHelpers 2 | def controller 3 | controller_class.new 4 | end 5 | 6 | def controller_class 7 | Class.new(AocCli::ApplicationController) do 8 | def initialize; end # rubocop:disable Style/RedundantInitialize 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/concern_specs.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | file_path = %r{spec/.*/controllers/concerns/} 3 | 4 | config.define_derived_metadata(file_path:) do |metadata| 5 | metadata[:type] = :concern 6 | end 7 | 8 | config.include ConcernHelpers, type: :concern 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/contexts/in_event_dir.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context :in_event_dir do |**options| 2 | let(:year) { options[:year] || rand(2015..2023) } 3 | let(:event) { create(:event, year:) } 4 | let!(:stats) { create(:stats, event:) } 5 | let!(:location) { create(:location, :year_dir, event:, path: temp_dir) } 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/contexts/in_puzzle_dir.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context :in_puzzle_dir do |**options| 2 | let(:year) { options[:year] || rand(2015..2023) } 3 | let(:day) { options[:day] || rand(1..25) } 4 | let(:event) { create(:event, year:) } 5 | let!(:stats) { create(:stats, event:) } 6 | let(:puzzle) { create(:puzzle, event:, day:) } 7 | let!(:location) { create(:location, :puzzle_dir, puzzle:, path: temp_dir) } 8 | 9 | let(:puzzle_file) { puzzle_content_path(dir: temp_dir, day:) } 10 | let(:input_file) { puzzle_input_path(dir: temp_dir) } 11 | 12 | shared_context :puzzle_files_exist do 13 | before do 14 | puzzle_file.write(current_puzzle) 15 | input_file.write(current_input) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/examples/processing.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_examples :failed_process do |options| 2 | let(:errors) { options[:errors] } 3 | 4 | def actual_errors(processor) 5 | processor.errors.map(&:full_message) 6 | end 7 | 8 | it "raises an error" do 9 | expect { subject }.to raise_error(described_class::Error) 10 | end 11 | 12 | it "sets the expected errors" do 13 | subject 14 | rescue described_class::Error => e 15 | expect(actual_errors(e.processor)).to eq(errors) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/examples/validations.rb: -------------------------------------------------------------------------------- 1 | # vi: ft=rspec 2 | 3 | RSpec.shared_examples :invalid do |options| 4 | let(:expected_errors) { options[:errors] } 5 | let(:actual_errors) { subject.errors.map(&:full_message) } 6 | 7 | it "is not valid" do 8 | expect(subject).not_to be_valid 9 | end 10 | 11 | it "sets the expected error messages" do 12 | subject.validate 13 | expect(actual_errors).to match_array(expected_errors) 14 | end 15 | end 16 | 17 | RSpec.shared_examples :valid do 18 | it "is valid" do 19 | expect(subject).to be_valid 20 | end 21 | end 22 | 23 | RSpec.shared_examples :validates_event_year do 24 | context "when nil" do 25 | let(:year) { nil } 26 | 27 | include_examples :invalid, errors: ["Year can't be blank"] 28 | end 29 | 30 | context "when not an integer" do 31 | let(:year) { :foobar } 32 | 33 | include_examples :invalid, errors: ["Year is not an integer"] 34 | end 35 | 36 | context "when before first event" do 37 | let(:year) { 2014 } 38 | 39 | include_examples :invalid, errors: [ 40 | "Year is before first Advent of Code event (2015)" 41 | ] 42 | end 43 | 44 | context "when after most recent event" do 45 | let(:year) { 2030 } 46 | 47 | include_examples :invalid, errors: ["Year is in the future"] 48 | end 49 | 50 | context "when valid" do 51 | let(:year) { 2023 } 52 | 53 | include_examples :valid 54 | end 55 | end 56 | 57 | RSpec.shared_examples :validates_puzzle_day do 58 | context "when nil" do 59 | let(:day) { nil } 60 | 61 | include_examples :invalid, errors: ["Day can't be blank"] 62 | end 63 | 64 | context "when not an integer" do 65 | let(:day) { :day } 66 | 67 | include_examples :invalid, errors: ["Day is not an integer"] 68 | end 69 | 70 | context "when negative" do 71 | let(:day) { -1 } 72 | 73 | include_examples :invalid, errors: ["Day is too small"] 74 | end 75 | 76 | context "when too low" do 77 | let(:day) { 0 } 78 | 79 | include_examples :invalid, errors: ["Day is too small"] 80 | end 81 | 82 | context "when too high" do 83 | let(:day) { 26 } 84 | 85 | include_examples :invalid, errors: ["Day is too large"] 86 | end 87 | 88 | context "when valid" do 89 | let(:day) { 1 } 90 | 91 | include_examples :valid 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/support/fixture_helpers.rb: -------------------------------------------------------------------------------- 1 | module FixtureHelpers 2 | def fixture(file) 3 | spec_dir.join("fixtures").join(file).read 4 | end 5 | 6 | def puzzle_fixture(year:, day:) 7 | fixture("puzzle-#{year}-#{formatted_day(day)}.md") 8 | end 9 | 10 | def input_fixture(year:, day:) 11 | fixture("input-#{year}-#{formatted_day(day)}") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/matchers/create_model.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | extend RSpec::Matchers::DSL 3 | 4 | matcher :create_model do |model_class| 5 | supports_block_expectations 6 | 7 | match do |action| 8 | count_before = model_class.count 9 | action.call 10 | count_after = model_class.count 11 | 12 | created = count_after - count_before == 1 13 | 14 | return created unless @attributes 15 | 16 | expect(created).to be(true) 17 | expect(model_class.last).to have_attributes(**@attributes) 18 | end 19 | 20 | chain :with_attributes do |attributes| 21 | @attributes = attributes 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/matchers/match_html.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | extend RSpec::Matchers::DSL 3 | 4 | matcher :match_html do |expected| 5 | def strip_indentation(html) 6 | html.gsub(/(?=(^|\n))\s*/, "") 7 | end 8 | 9 | match do |actual| 10 | expected = strip_indentation(expected) 11 | actual = strip_indentation(actual) 12 | 13 | expect(actual).to eq(expected) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/matchers/redirect_to.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | extend RSpec::Matchers::DSL 3 | 4 | matcher :redirect_to do |path| 5 | supports_block_expectations 6 | 7 | match do |action| 8 | params = @params || {} 9 | 10 | allow(Kangaru.application!.router) 11 | .to receive(:resolve) 12 | .and_call_original 13 | 14 | expect(Kangaru.application!.router) 15 | .not_to have_received(:resolve) 16 | .with(an_object_having_attributes(path:, params:)) 17 | 18 | action.call 19 | 20 | expect(Kangaru.application!.router) 21 | .to have_received(:resolve) 22 | .with(an_object_having_attributes(path:, params:)) 23 | .once 24 | end 25 | 26 | chain :with_params do |params| 27 | @params = params 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/matchers/render_component.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | extend RSpec::Matchers::DSL 3 | 4 | matcher :render_component do |component_class| 5 | supports_block_expectations 6 | 7 | match do |action| 8 | component_double = instance_double(component_class, render: nil) 9 | 10 | allow(component_class).to receive(:new).and_return(component_double) 11 | 12 | expect(component_class).not_to have_received(:new) 13 | 14 | action.call 15 | 16 | if @with 17 | expect(component_class).to have_received(:new).with(@with) 18 | else 19 | expect(component_class).to have_received(:new) 20 | end 21 | 22 | expect(component_double).to have_received(:render).once 23 | end 24 | 25 | chain :with do |with| 26 | @with = with 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/matchers/render_errors.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | extend RSpec::Matchers::DSL 3 | 4 | matcher :render_errors do |*messages| 5 | def error_bullet = " \u2022".freeze 6 | 7 | def error_list(messages) 8 | messages.map { |message| [error_bullet, message].join(" ") }.join("\n") 9 | end 10 | 11 | def errors_as_list(*messages) 12 | <<~TEXT 13 | #{'Error'.red}: 14 | #{error_list(messages)} 15 | TEXT 16 | end 17 | 18 | def error_as_line(message) 19 | <<~TEXT 20 | #{'Error'.red}: #{message} 21 | TEXT 22 | end 23 | 24 | supports_block_expectations 25 | 26 | match do |action| 27 | expected = if messages.count > 1 28 | errors_as_list(*messages) 29 | else 30 | error_as_line(messages.first) 31 | end 32 | 33 | expect { action.call }.to output(expected).to_stdout 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/matchers/request_url.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | extend RSpec::Matchers::DSL 3 | 4 | matcher :request_url do |url| 5 | supports_block_expectations 6 | 7 | match do |action| 8 | @method ||= :get 9 | 10 | assert_not_requested(@method, url) 11 | action.call 12 | assert_requested(@method, url) 13 | 14 | true 15 | end 16 | 17 | chain :via do |method| 18 | @method = method 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/path_helpers.rb: -------------------------------------------------------------------------------- 1 | module PathHelpers 2 | def puzzle_content_path(dir:, day:) 3 | file = "day_#{formatted_day(day)}.md" 4 | 5 | Pathname(dir).join(file) 6 | end 7 | 8 | def puzzle_input_path(dir:) 9 | Pathname(dir).join("input") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/request_helpers.rb: -------------------------------------------------------------------------------- 1 | module RequestHelpers 2 | BASE = "https://adventofcode.com".freeze 3 | 4 | def stats_url(stats) 5 | "#{BASE}/#{stats.year}" 6 | end 7 | 8 | def puzzle_url(puzzle) 9 | "#{BASE}/#{puzzle.year}/day/#{puzzle.day}" 10 | end 11 | 12 | def input_url(puzzle) 13 | "#{BASE}/#{puzzle.year}/day/#{puzzle.day}/input" 14 | end 15 | 16 | def solution_url(puzzle) 17 | "#{BASE}/#{puzzle.year}/day/#{puzzle.day}/answer" 18 | end 19 | 20 | def wrap_in_html(content, tag: :article) 21 | <<~HTML 22 | 23 | 24 | 25 |
26 | <#{tag}>#{content} 27 |
28 | 29 | 30 | HTML 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/request_specs.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | # Do not render views with color in request specs for clearer assertions 3 | config.around(type: :request) do |spec| 4 | String.disable_colorization = true 5 | spec.run 6 | String.disable_colorization = false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/temp_dir_helper.rb: -------------------------------------------------------------------------------- 1 | module TempDirHelper 2 | extend RSpec::Matchers::DSL 3 | 4 | attr_reader :temp_dir 5 | 6 | def temp_path(file) 7 | Pathname(temp_dir).join(file) 8 | end 9 | 10 | matcher :create_temp_dir do |dir| 11 | supports_block_expectations 12 | 13 | match do |action| 14 | path = temp_path(dir) 15 | exists_before = Dir.exist?(path) 16 | action.call 17 | exists_after = Dir.exist?(path) 18 | 19 | !exists_before && exists_after 20 | end 21 | end 22 | 23 | matcher :create_temp_file do |file| 24 | supports_block_expectations 25 | 26 | match do |action| 27 | path = temp_path(file) 28 | 29 | exists_before = File.exist?(path) 30 | action.call 31 | exists_after = File.exist?(path) 32 | 33 | created = !exists_before && exists_after 34 | 35 | return created unless @contents 36 | 37 | if @contents.is_a?(Proc) 38 | created && File.read(path) == @contents.call 39 | else 40 | created && File.read(path) == @contents 41 | end 42 | end 43 | 44 | chain :with_contents do |contents = nil, &block| 45 | @contents = contents || block 46 | end 47 | end 48 | 49 | matcher :update_temp_file do |file| 50 | supports_block_expectations 51 | 52 | match do |action| 53 | path = temp_path(file) 54 | 55 | value_before = File.read(path) 56 | action.call 57 | value_after = File.read(path) 58 | 59 | updated = value_after != value_before 60 | 61 | return updated unless @contents 62 | 63 | if @contents.is_a?(Proc) 64 | updated && value_after == @contents.call 65 | else 66 | updated && value_after == @contents 67 | end 68 | end 69 | 70 | chain :to do |contents = nil, &block| 71 | @contents = contents || block 72 | end 73 | end 74 | end 75 | 76 | RSpec.configure do |config| 77 | config.include TempDirHelper, with_temp_dir: true 78 | 79 | config.around(with_temp_dir: true) do |spec| 80 | env_key = "TMPDIR" 81 | tmpdir_before = ENV.fetch(env_key, nil) 82 | 83 | ENV[env_key] = spec_dir.join("tmp").tap do |dir| 84 | dir.mkdir unless dir.exist? 85 | end.to_s 86 | 87 | Dir.mktmpdir do |temp_dir| 88 | Dir.chdir(temp_dir) do 89 | @temp_dir = temp_dir 90 | 91 | spec.run 92 | 93 | remove_instance_variable(:@temp_dir) 94 | end 95 | end 96 | 97 | ENV[env_key] = tmpdir_before 98 | end 99 | end 100 | --------------------------------------------------------------------------------