diff --git a/serverside_challenge_2/challenge/.rspec b/serverside_challenge_2/challenge/.rspec new file mode 100644 index 000000000..c99d2e739 --- /dev/null +++ b/serverside_challenge_2/challenge/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/serverside_challenge_2/challenge/Dockerfile b/serverside_challenge_2/challenge/Dockerfile index 166bd4432..2bf93e030 100644 --- a/serverside_challenge_2/challenge/Dockerfile +++ b/serverside_challenge_2/challenge/Dockerfile @@ -1,8 +1,12 @@ FROM ruby:3.1.2 -RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim + +RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim graphviz +RUN useradd -ms /bin/bash rails RUN mkdir /app +# Gemfile をコピー(権限を rails に変更) +COPY --chown=rails:rails Gemfile Gemfile.lock /app/ +USER rails WORKDIR /app -ADD Gemfile /app/Gemfile -ADD Gemfile.lock /app/Gemfile.lock RUN bundle install -ADD . /app \ No newline at end of file +# アプリ本体をコピー(rails ユーザーに所有権) +COPY --chown=rails:rails . /app \ No newline at end of file diff --git a/serverside_challenge_2/challenge/Gemfile b/serverside_challenge_2/challenge/Gemfile index 43bf67fe3..da965188c 100644 --- a/serverside_challenge_2/challenge/Gemfile +++ b/serverside_challenge_2/challenge/Gemfile @@ -36,13 +36,22 @@ gem "bootsnap", require: false # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible # gem "rack-cors" +gem "importmap-rails" +gem "stimulus-rails" +gem "propshaft" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] + gem 'rspec-rails', '~> 6.0' + gem 'shoulda-matchers', '~> 5.0' + gem 'factory_bot_rails' + gem "rails-erd" end group :development do # Speed up commands on slow machines / big apps [https://github.com/rails/spring] # gem "spring" + gem 'annotate' end diff --git a/serverside_challenge_2/challenge/Gemfile.lock b/serverside_challenge_2/challenge/Gemfile.lock index a47fb85f5..1e25c474c 100644 --- a/serverside_challenge_2/challenge/Gemfile.lock +++ b/serverside_challenge_2/challenge/Gemfile.lock @@ -66,20 +66,34 @@ GEM i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) + annotate (3.2.0) + activerecord (>= 3.2, < 8.0) + rake (>= 10.4, < 14.0) bootsnap (1.18.3) msgpack (~> 1.2) builder (3.2.4) + choice (0.2.0) concurrent-ruby (1.2.3) crass (1.0.6) date (3.3.4) debug (1.9.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) erubi (1.12.0) + factory_bot (6.5.5) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.1) concurrent-ruby (~> 1.0) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) io-console (0.7.2) irb (1.11.2) rdoc @@ -112,6 +126,10 @@ GEM nokogiri (1.16.2-x86_64-linux) racc (~> 1.4) pg (1.5.4) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack psych (5.1.2) stringio puma (5.6.8) @@ -138,6 +156,11 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) + rails-erd (1.7.2) + activerecord (>= 4.2) + activesupport (>= 4.2) + choice (~> 0.2.0) + ruby-graphviz (~> 1.2) rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) @@ -153,6 +176,30 @@ GEM psych (>= 4.0.0) reline (0.4.2) io-console (~> 0.5) + rexml (3.4.4) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.6) + ruby-graphviz (1.2.5) + rexml + shoulda-matchers (5.3.0) + activesupport (>= 5.2.0) + stimulus-rails (1.3.4) + railties (>= 6.0.0) stringio (3.1.0) thor (1.3.0) timeout (0.4.1) @@ -168,11 +215,19 @@ PLATFORMS x86_64-linux DEPENDENCIES + annotate bootsnap debug + factory_bot_rails + importmap-rails pg (~> 1.1) + propshaft puma (~> 5.0) rails (~> 7.0.8) + rails-erd + rspec-rails (~> 6.0) + shoulda-matchers (~> 5.0) + stimulus-rails tzinfo-data RUBY VERSION diff --git a/serverside_challenge_2/challenge/README.md b/serverside_challenge_2/challenge/README.md index 7db80e4ca..937fa63df 100644 --- a/serverside_challenge_2/challenge/README.md +++ b/serverside_challenge_2/challenge/README.md @@ -1,24 +1,62 @@ -# README - -This README would normally document whatever steps are necessary to get the -application up and running. - -Things you may want to cover: - -* Ruby version - -* System dependencies - -* Configuration - -* Database creation - -* Database initialization - -* How to run the test suite - -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... +# これは +電気料金のシミュレーションを行えるサービスです。 + +# 構成 +- Back-end + - Ruby 3.1.2, Ruby on Rails 7.0.8 +- DB + - PostgreSQL +- Front-end + - erb, Stimulus, CSS +- Asset + - importmap, propshaft +- Test + - Rspec, FactoryBot, Shoulda Matchers +- Dev + - annotate, rails-erd + +# 開発環境 +## 初回セットアップ +```sh +./scripts/setup.sh +``` + +- DB作成 +- seedデータ作成 +- railsサーバー起動 + +まで + +## 電気料金のシミュレーション + +http://localhost:3000/plans にアクセス + +## 基本コマンド +### railsサーバー起動 +```sh +docker compose up -d +``` + +### データベース初期化、seedデータ作成 +```sh +docker compose run --rm web rails db:reset +``` + +### rspec実行 +```sh +docker compose run --rm web rspec +``` + +# ドキュメントの更新 + +annotationの強制更新 ※ 基本的にmigrate実行時に更新されるので通常は不要 + +```sh +docker compose run --rm web bundle exec annotate --force +``` + +`erd.pdf`の更新 + +```sh +docker compose run --rm web rake erd +``` \ No newline at end of file diff --git a/serverside_challenge_2/challenge/app/assets/stylesheets/application.css b/serverside_challenge_2/challenge/app/assets/stylesheets/application.css new file mode 100644 index 000000000..03961ee5d --- /dev/null +++ b/serverside_challenge_2/challenge/app/assets/stylesheets/application.css @@ -0,0 +1,46 @@ +.sim-form { + display: flex; + gap: 16px; + align-items: flex-end; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-control { + padding: 6px 10px; + font-size: 14px; +} + +.btn { + padding: 8px 16px; + background-color: #0070f3; + color: #fff; + border: none; + cursor: pointer; + border-radius: 6px; +} + +.btn:hover { + background-color: #005bb5; +} + +.result-table { + width: 100%; + border-collapse: collapse; +} + +.result-table th, +.result-table td { + padding: 10px; + border-bottom: 1px solid #ddd; + text-align: left; +} + +.result-table tr:nth-child(even) { + background-color: #f9f9f9; +} diff --git a/serverside_challenge_2/challenge/app/controllers/application_controller.rb b/serverside_challenge_2/challenge/app/controllers/application_controller.rb index 4ac8823b0..09705d12a 100644 --- a/serverside_challenge_2/challenge/app/controllers/application_controller.rb +++ b/serverside_challenge_2/challenge/app/controllers/application_controller.rb @@ -1,2 +1,2 @@ -class ApplicationController < ActionController::API +class ApplicationController < ActionController::Base end diff --git a/serverside_challenge_2/challenge/app/controllers/plans_controller.rb b/serverside_challenge_2/challenge/app/controllers/plans_controller.rb new file mode 100644 index 000000000..c0abda766 --- /dev/null +++ b/serverside_challenge_2/challenge/app/controllers/plans_controller.rb @@ -0,0 +1,38 @@ +class PlansController < ApplicationController + PERMITTED_AMPERES = [10, 15, 20, 30, 40, 50, 60].freeze + + def index; end + + # return json : [{ provider_name: ‘Looopでんき’, plan_name: ‘おうちプラン’, price: ‘1234’ }, …] + def prices + validate_prices_params + + ampere = params[:ampere].to_i + usage = params[:usage].to_i + hash = Plan.plan_prices(ampere:, usage:) + render json: hash, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :bad_request + end + + private + + def validate_prices_params + ampere = params[:ampere] + usage = params[:usage] + + unless ampere.present? && usage.present? + raise ArgumentError, 'Ampere and usage must be provided' + end + + # 契約アンペア数 : PERMITTED_AMPERES のいずれかとする(単位A) + unless ampere.match?(/\A\d+\z/) && PERMITTED_AMPERES.include?(ampere.to_i) + raise ArgumentError, "Ampere must be one of #{PERMITTED_AMPERES.join(', ')}" + end + + # 使用量 : 0以上の整数(単位kWh) + unless usage.match?(/\A\d+\z/) && usage.to_i >= 0 + raise ArgumentError, 'Usage must be a non-negative integer' + end + end +end diff --git a/serverside_challenge_2/challenge/app/javascript/application.js b/serverside_challenge_2/challenge/app/javascript/application.js new file mode 100644 index 000000000..c275ea839 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/application.js @@ -0,0 +1,2 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "controllers" diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/application.js b/serverside_challenge_2/challenge/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/index.js b/serverside_challenge_2/challenge/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js new file mode 100644 index 000000000..3fdce2dd9 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["ampere", "usage", "results"] + + async search(event) { + event.preventDefault(); + + const ampere = this.ampereTarget.value; + const usage = this.usageTarget.value; + this.resultsTarget.innerHTML = ""; + + try { + const response = await fetch(`/plans/prices?ampere=${ampere}&usage=${usage}`); + if (!response.ok) throw new Error("API request failed"); + const data = await response.json(); + if (Array.isArray(data) && data.length > 0) { + data.forEach(plan => { + const row = this.buildRow(plan); + this.resultsTarget.appendChild(row); + }); + } else { + alert("該当するプランがありません。"); + } + } catch (error) { + alert("検索に失敗しました: " + error.message); + } + } + + buildRow(plan) { + const row = document.createElement("tr"); + row.innerHTML = ` + ${plan.provider_name} + ${plan.plan_name} + ${plan.price} + `; + return row; + } +} diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb new file mode 100644 index 000000000..6b5da3987 --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -0,0 +1,33 @@ +class BasicFee < ApplicationRecord + belongs_to :plan, primary_key: :code, foreign_key: :plan_code + + validates :ampere, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :ampere, uniqueness: { scope: :plan_code } + validates :fee, presence: true, numericality: { greater_than_or_equal_to: 0 } + + # @param ampere [Integer] 契約アンペア数(A) + scope :by_ampere, ->(ampere) { where(ampere:) } +end + +# == Schema Information +# +# Table name: basic_fees +# +# id :bigint not null, primary key +# ampere(契約アンペア数(A)) :integer not null +# code :string not null +# fee(基本料金(円)) :decimal(10, 2) not null +# plan_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_code (code) UNIQUE +# index_basic_fees_on_plan_code_and_ampere (plan_code,ampere) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (plan_code => plans.code) +# diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb new file mode 100644 index 000000000..cc6589bd0 --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -0,0 +1,76 @@ +class Plan < ApplicationRecord + belongs_to :provider, primary_key: :code, foreign_key: :provider_code + has_many :basic_fees, dependent: :destroy, primary_key: :code, foreign_key: :plan_code + has_many :usage_charges, dependent: :destroy, primary_key: :code, foreign_key: :plan_code + + validates :name, presence: true + + # 契約アンペア数と電気使用量に基づき、該当する料金プランの一覧を取得する + # @param ampere [Integer] 契約アンペア数(A) + # @param usage [Integer] 電気使用量(kWh) + # @return [ActiveRecord::Relation] + scope :by_ampere_and_usage, ->(ampere, usage) do + joins(:basic_fees, :usage_charges) + .includes(:provider, :basic_fees, :usage_charges) + .merge(BasicFee.by_ampere(ampere)) + .merge(UsageCharge.lower_than(usage)) + .distinct + end + + # 契約アンペア数と電気使用量に基づき、該当する料金プランの一覧と料金を取得する + # @param ampere [Integer] 契約アンペア数(A) + # @param usage [Integer] 電気使用量(kWh) + # @return [Array] [{ provider_name: ‘Looopでんき’, plan_name: ‘おうちプラン’, price: ‘1234’ }, …] + def self.plan_prices(ampere:, usage:) + raise ArgumentError, 'ampere must be an Integer' unless ampere.is_a?(Integer) + raise ArgumentError, 'usage must be an Integer' unless usage.is_a?(Integer) + + plans = self.by_ampere_and_usage(ampere, usage) + plans.map do |plan| + basic_fee = plan.basic_fees.sole # 基本料金は契約アンペア数に対して1つだけ存在する想定 + usage_charges = plan.usage_charges # すでに絞り込まれている想定 + + { + provider_name: plan.provider.name, + plan_name: plan.name, + price: self.calc_price(basic_fee:, usage_charges:, usage:), + } + end + end + + private + + # 電気料金 = ①基本料金 + ②従量料金 + ③そのほか + # NOTE: 現時点では③そのほかは考慮しない + # NOTE: もし他の箇所で同じように料金を計算する必要が出てきた場合や、そのほかを計算し複雑化する場合は、電気料金の値オブジェクト化を検討すること + # @param basic_fee [BasicFee] 基本料金オブジェクト + # @param usage_charges [Array] 従量料金オブジェクトの配列 + # @param usage [Integer] 電気使用量(kWh) + # @return [Integer] 合計電気料金 (円) + def self.calc_price(basic_fee:, usage_charges:, usage:) + fee = basic_fee.fee + total_charge = usage_charges.sum { |uc| uc.calc_charge(usage) } + (fee + total_charge).floor + end +end + +# == Schema Information +# +# Table name: plans +# +# id :bigint not null, primary key +# code :string not null +# name :string not null +# provider_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_plans_on_code (code) UNIQUE +# index_plans_on_provider_code (provider_code) +# +# Foreign Keys +# +# fk_rails_... (provider_code => providers.code) +# diff --git a/serverside_challenge_2/challenge/app/models/provider.rb b/serverside_challenge_2/challenge/app/models/provider.rb new file mode 100644 index 000000000..ec42422d8 --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/provider.rb @@ -0,0 +1,20 @@ +class Provider < ApplicationRecord + has_many :plans, dependent: :destroy, primary_key: :code, foreign_key: :provider_code + + validates :name, presence: true +end + +# == Schema Information +# +# Table name: providers +# +# id :bigint not null, primary key +# code :string not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_providers_on_code (code) UNIQUE +# diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb new file mode 100644 index 000000000..a9501ba8e --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -0,0 +1,66 @@ +class UsageCharge < ApplicationRecord + belongs_to :plan, primary_key: :code, foreign_key: :plan_code + + validates :unit_price, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :usage_lower, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :usage_upper, numericality: { only_integer: true, greater_than: :usage_lower }, allow_nil: true + validate :no_overlapping_ranges + + # @param usage [Integer] 電気使用量(kWh) + scope :lower_than, ->(usage) { where("usage_lower <= ?", usage) } + + # 従量料金 = 従量料金単価(円/kWh) × 電気使用量(kWh) + # ※ 計算で用いる 電気使用量(kWh) は、usage(またはusage_upper) - usage_lower で計算する + # @param usage [Integer] 電気使用量(kWh) + # @return [BigDecimal] 従量料金(円) + def calc_charge(usage) + return 0.0 if usage_lower >= usage + + upper = usage_upper.present? && usage_upper < usage ? usage_upper : usage + applicable_usage = upper - usage_lower + unit_price * applicable_usage + end + + private + + # 同じ料金プラン内で、電気使用量の範囲が重複しないこと + # upper_usageと次のlower_usageが重複しても良い (例: 0-120, 120-300) + # NOTE: usage_upper = nullの場合は上限なしとみなす + def no_overlapping_ranges + max_usage = 2_147_483_647 # PostgreSQL INTEGER 最大値 + upper = usage_upper || max_usage + + overlapping = UsageCharge.where(plan_code:).where.not(id:) + .where("(usage_lower < ? AND (usage_upper IS NULL OR usage_upper > ?))", + upper, + usage_lower) + + return unless overlapping.exists? + + errors.add(:base, "overlapping usage range exists") + end +end + +# == Schema Information +# +# Table name: usage_charges +# +# id :bigint not null, primary key +# code :string not null +# plan_code :string not null +# unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null +# usage_lower(電気使用量(kWh) 下限) :integer not null +# usage_upper(電気使用量(kWh) 上限) :integer +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_usage_charges_on_code (code) UNIQUE +# index_usage_charges_on_plan_code_and_usage_range (plan_code,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# +# Foreign Keys +# +# fk_rails_... (plan_code => plans.code) +# diff --git a/serverside_challenge_2/challenge/app/views/layouts/application.html.erb b/serverside_challenge_2/challenge/app/views/layouts/application.html.erb new file mode 100644 index 000000000..3cead9655 --- /dev/null +++ b/serverside_challenge_2/challenge/app/views/layouts/application.html.erb @@ -0,0 +1,12 @@ + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= javascript_importmap_tags %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + + + <%= yield %> + + diff --git a/serverside_challenge_2/challenge/app/views/plans/index.html.erb b/serverside_challenge_2/challenge/app/views/plans/index.html.erb new file mode 100644 index 000000000..4f1dacdec --- /dev/null +++ b/serverside_challenge_2/challenge/app/views/plans/index.html.erb @@ -0,0 +1,31 @@ +

電気料金のシミュレーション

+ +
+
+
+ + <%= select_tag :ampere, options_for_select(PlansController::PERMITTED_AMPERES), data: { plans_target: "ampere" }, class: "form-control" %> +
+ +
+ + +
+ + +
+ + + + + + + + + + + +
電力会社名プラン名電気料金(円)
+
diff --git a/serverside_challenge_2/challenge/bin/importmap b/serverside_challenge_2/challenge/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/serverside_challenge_2/challenge/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/serverside_challenge_2/challenge/config/application.rb b/serverside_challenge_2/challenge/config/application.rb index b39913bf7..dba5bb221 100644 --- a/serverside_challenge_2/challenge/config/application.rb +++ b/serverside_challenge_2/challenge/config/application.rb @@ -22,6 +22,6 @@ class Application < Rails::Application # Only loads a smaller set of middleware suitable for API only apps. # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. - config.api_only = true + config.api_only = false end end diff --git a/serverside_challenge_2/challenge/config/importmap.rb b/serverside_challenge_2/challenge/config/importmap.rb new file mode 100644 index 000000000..5013c58b6 --- /dev/null +++ b/serverside_challenge_2/challenge/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2 +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" + diff --git a/serverside_challenge_2/challenge/config/routes.rb b/serverside_challenge_2/challenge/config/routes.rb index 262ffd547..525948ecc 100644 --- a/serverside_challenge_2/challenge/config/routes.rb +++ b/serverside_challenge_2/challenge/config/routes.rb @@ -3,4 +3,10 @@ # Defines the root path route ("/") # root "articles#index" + + resources :plans, only: [:index] do + collection do + get :prices + end + end end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb new file mode 100644 index 000000000..56ab4f2ca --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb @@ -0,0 +1,11 @@ +class CreateProviders < ActiveRecord::Migration[7.0] + def change + create_table :providers, comment: '電力会社' do |t| + t.string :code, null: false + t.string :name, null: false + t.timestamps + end + + add_index :providers, :code, unique: true + end +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb new file mode 100644 index 000000000..89f0ccf61 --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb @@ -0,0 +1,14 @@ +class CreatePlans < ActiveRecord::Migration[7.0] + def change + create_table :plans, comment: 'プラン' do |t| + t.string :code, null: false + t.string :provider_code, null: false + t.string :name, null: false + t.timestamps + end + + add_index :plans, :code, unique: true + add_index :plans, :provider_code + add_foreign_key :plans, :providers, column: :provider_code, primary_key: :code + end +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb new file mode 100644 index 000000000..33caaf00e --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb @@ -0,0 +1,16 @@ +class CreateBasicFees < ActiveRecord::Migration[7.0] + def change + create_table :basic_fees, comment: '基本料金' do |t| + t.string :code, null: false + t.string :plan_code, null: false + t.integer :ampere, null: false, comment: "契約アンペア数(A)" + t.decimal :fee, precision: 10, scale: 2, null: false, comment: "基本料金(円)" + t.timestamps + end + + add_index :basic_fees, :code, unique: true + add_index :basic_fees, [:plan_code, :ampere], unique: true + add_index :basic_fees, :ampere + add_foreign_key :basic_fees, :plans, column: :plan_code, primary_key: :code + end +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb new file mode 100644 index 000000000..d73e32f14 --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb @@ -0,0 +1,17 @@ +class CreateUsageCharges < ActiveRecord::Migration[7.0] + def change + create_table :usage_charges, comment: '従量料金' do |t| + t.string :code, null: false + t.string :plan_code, null: false + t.integer :usage_lower, null: false, comment: "電気使用量(kWh) 下限" + t.integer :usage_upper, comment: "電気使用量(kWh) 上限" + t.decimal :unit_price, precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" + t.timestamps + end + + add_index :usage_charges, :code, unique: true + add_index :usage_charges, [:usage_lower, :usage_upper] + add_index :usage_charges, [:plan_code, :usage_lower, :usage_upper], name: 'index_usage_charges_on_plan_code_and_usage_range' + add_foreign_key :usage_charges, :plans, column: :plan_code, primary_key: :code + end +end diff --git a/serverside_challenge_2/challenge/db/schema.rb b/serverside_challenge_2/challenge/db/schema.rb new file mode 100644 index 000000000..5d3cad040 --- /dev/null +++ b/serverside_challenge_2/challenge/db/schema.rb @@ -0,0 +1,63 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.0].define(version: 2025_09_28_070132) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "basic_fees", comment: "基本料金", force: :cascade do |t| + t.string "code", null: false + t.string "plan_code", null: false + t.integer "ampere", null: false, comment: "契約アンペア数(A)" + t.decimal "fee", precision: 10, scale: 2, null: false, comment: "基本料金(円)" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["ampere"], name: "index_basic_fees_on_ampere" + t.index ["code"], name: "index_basic_fees_on_code", unique: true + t.index ["plan_code", "ampere"], name: "index_basic_fees_on_plan_code_and_ampere", unique: true + end + + create_table "plans", comment: "プラン", force: :cascade do |t| + t.string "code", null: false + t.string "provider_code", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_plans_on_code", unique: true + t.index ["provider_code"], name: "index_plans_on_provider_code" + end + + create_table "providers", comment: "電力会社", force: :cascade do |t| + t.string "code", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_providers_on_code", unique: true + end + + create_table "usage_charges", comment: "従量料金", force: :cascade do |t| + t.string "code", null: false + t.string "plan_code", null: false + t.integer "usage_lower", null: false, comment: "電気使用量(kWh) 下限" + t.integer "usage_upper", comment: "電気使用量(kWh) 上限" + t.decimal "unit_price", precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_usage_charges_on_code", unique: true + t.index ["plan_code", "usage_lower", "usage_upper"], name: "index_usage_charges_on_plan_code_and_usage_range" + t.index ["usage_lower", "usage_upper"], name: "index_usage_charges_on_usage_lower_and_usage_upper" + end + + add_foreign_key "basic_fees", "plans", column: "plan_code", primary_key: "code" + add_foreign_key "plans", "providers", column: "provider_code", primary_key: "code" + add_foreign_key "usage_charges", "plans", column: "plan_code", primary_key: "code" +end diff --git a/serverside_challenge_2/challenge/db/seeds.rb b/serverside_challenge_2/challenge/db/seeds.rb index bc25fce30..dcb7aa8bd 100644 --- a/serverside_challenge_2/challenge/db/seeds.rb +++ b/serverside_challenge_2/challenge/db/seeds.rb @@ -5,3 +5,22 @@ # # movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) # Character.create(name: "Luke", movie: movies.first) + +require 'csv' +csv_dir = Rails.root.join("db", "seeds") + +seeds = [ + { file: "providers.csv", model: Provider, attributes: %w(code name) }, + { file: "plans.csv", model: Plan, attributes: %w(code provider_code name) }, + { file: "basic_fees.csv", model: BasicFee, attributes: %w(code plan_code ampere fee) }, + { file: "usage_charges.csv", model: UsageCharge, attributes: %w(code plan_code usage_lower usage_upper unit_price) } +] + +ActiveRecord::Base.transaction do + seeds.each do |seed| + CSV.foreach(csv_dir.join(seed[:file]), headers: true) do |row| + attributes = seed[:attributes].map { |attr| [attr.to_sym, row[attr]] }.to_h + seed[:model].create!(attributes) + end + end +end diff --git a/serverside_challenge_2/challenge/db/seeds/basic_fees.csv b/serverside_challenge_2/challenge/db/seeds/basic_fees.csv new file mode 100644 index 000000000..580fdbb40 --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/basic_fees.csv @@ -0,0 +1,26 @@ +code,plan_code,ampere,fee +basic_fee_1,plan_1,10,286.00 +basic_fee_2,plan_1,15,429.00 +basic_fee_3,plan_1,20,572.00 +basic_fee_4,plan_1,30,858.00 +basic_fee_5,plan_1,40,1144.00 +basic_fee_6,plan_1,50,1430.00 +basic_fee_7,plan_1,60,1716.00 +basic_fee_8,plan_2,10,311.75 +basic_fee_9,plan_2,15,467.63 +basic_fee_10,plan_2,20,623.50 +basic_fee_11,plan_2,30,935.25 +basic_fee_12,plan_2,40,1247.00 +basic_fee_13,plan_2,50,1558.75 +basic_fee_14,plan_2,60,1870.50 +basic_fee_15,plan_3,30,858.00 +basic_fee_16,plan_3,40,1144.00 +basic_fee_17,plan_3,50,1430.00 +basic_fee_18,plan_3,60,1716.00 +basic_fee_19,plan_4,10,0.00 +basic_fee_20,plan_4,15,0.00 +basic_fee_21,plan_4,20,0.00 +basic_fee_22,plan_4,30,0.00 +basic_fee_23,plan_4,40,0.00 +basic_fee_24,plan_4,50,0.00 +basic_fee_25,plan_4,60,0.00 diff --git a/serverside_challenge_2/challenge/db/seeds/plans.csv b/serverside_challenge_2/challenge/db/seeds/plans.csv new file mode 100644 index 000000000..ae9fc439d --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/plans.csv @@ -0,0 +1,5 @@ +code,provider_code,name +plan_1,provider_1,従量電灯B +plan_2,provider_1,スタンダードS +plan_3,provider_2,ずっとも電気1 +plan_4,provider_3,おうちプラン diff --git a/serverside_challenge_2/challenge/db/seeds/providers.csv b/serverside_challenge_2/challenge/db/seeds/providers.csv new file mode 100644 index 000000000..cd6192b0a --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/providers.csv @@ -0,0 +1,4 @@ +code,name +provider_1,東京電力エナジーパートナー +provider_2,東京ガス +provider_3,Looopでんき diff --git a/serverside_challenge_2/challenge/db/seeds/usage_charges.csv b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv new file mode 100644 index 000000000..07f36477e --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv @@ -0,0 +1,11 @@ +code,plan_code,usage_lower,usage_upper,unit_price +usage_charge_1,plan_1,0,120,19.88 +usage_charge_2,plan_1,120,300,26.48 +usage_charge_3,plan_1,300,,30.57 +usage_charge_4,plan_2,0,120,29.80 +usage_charge_5,plan_2,120,300,36.40 +usage_charge_6,plan_2,300,,40.49 +usage_charge_7,plan_3,0,140,23.67 +usage_charge_8,plan_3,140,350,23.88 +usage_charge_9,plan_3,350,,26.41 +usage_charge_10,plan_4,0,,28.8 diff --git a/serverside_challenge_2/challenge/erd.pdf b/serverside_challenge_2/challenge/erd.pdf new file mode 100644 index 000000000..49416b4ab Binary files /dev/null and b/serverside_challenge_2/challenge/erd.pdf differ diff --git a/serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake b/serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake new file mode 100644 index 000000000..b7a09c439 --- /dev/null +++ b/serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake @@ -0,0 +1,59 @@ +# NOTE: only doing this in development as some production environments (Heroku) +# NOTE: are sensitive to local FS writes, and besides -- it's just not proper +# NOTE: to have a dev-mode tool do its thing in production. +if Rails.env.development? + require 'annotate' + task :set_annotation_options do + # You can override any of these by setting an environment variable of the + # same name. + Annotate.set_defaults( + 'active_admin' => 'false', + 'additional_file_patterns' => [], + 'routes' => 'false', + 'models' => 'true', + 'position_in_routes' => 'after', + 'position_in_class' => 'after', + 'position_in_test' => 'after', + 'position_in_fixture' => 'after', + 'position_in_factory' => 'after', + 'position_in_serializer' => 'after', + 'show_foreign_keys' => 'true', + 'show_complete_foreign_keys' => 'false', + 'show_indexes' => 'true', + 'simple_indexes' => 'false', + 'model_dir' => 'app/models', + 'root_dir' => '', + 'include_version' => 'false', + 'require' => '', + 'exclude_tests' => 'false', + 'exclude_fixtures' => 'false', + 'exclude_factories' => 'false', + 'exclude_serializers' => 'false', + 'exclude_scaffolds' => 'true', + 'exclude_controllers' => 'true', + 'exclude_helpers' => 'true', + 'exclude_sti_subclasses' => 'false', + 'ignore_model_sub_dir' => 'false', + 'ignore_columns' => nil, + 'ignore_routes' => nil, + 'ignore_unknown_models' => 'false', + 'hide_limit_column_types' => 'integer,bigint,boolean', + 'hide_default_column_types' => 'json,jsonb,hstore', + 'skip_on_db_migrate' => 'false', + 'format_bare' => 'true', + 'format_rdoc' => 'false', + 'format_yard' => 'false', + 'format_markdown' => 'false', + 'sort' => 'false', + 'force' => 'false', + 'frozen' => 'false', + 'classified_sort' => 'true', + 'trace' => 'false', + 'wrapper_open' => nil, + 'wrapper_close' => nil, + 'with_comment' => 'true' + ) + end + + Annotate.load_tasks +end diff --git a/serverside_challenge_2/challenge/scripts/setup.sh b/serverside_challenge_2/challenge/scripts/setup.sh new file mode 100755 index 000000000..5cf83fa76 --- /dev/null +++ b/serverside_challenge_2/challenge/scripts/setup.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -ex + +docker compose build +docker compose run --rm web rails db:setup +docker compose up -d diff --git a/serverside_challenge_2/challenge/spec/factories/basic_fees.rb b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb new file mode 100644 index 000000000..134d5c497 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb @@ -0,0 +1,31 @@ +FactoryBot.define do + factory :basic_fee do + plan + sequence(:code) { |n| "basic_fee_code_#{n}" } + ampere { rand(10..60) } + fee { rand(500.00..2000.00).round(2) } + end +end + +# == Schema Information +# +# Table name: basic_fees +# +# id :bigint not null, primary key +# ampere(契約アンペア数(A)) :integer not null +# code :string not null +# fee(基本料金(円)) :decimal(10, 2) not null +# plan_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_code (code) UNIQUE +# index_basic_fees_on_plan_code_and_ampere (plan_code,ampere) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (plan_code => plans.code) +# diff --git a/serverside_challenge_2/challenge/spec/factories/plans.rb b/serverside_challenge_2/challenge/spec/factories/plans.rb new file mode 100644 index 000000000..8469193b7 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/plans.rb @@ -0,0 +1,28 @@ +FactoryBot.define do + factory :plan do + provider + sequence(:code) { |n| "plan_code_#{n}" } + sequence(:name) { |n| "Plan_#{n}" } + end +end + +# == Schema Information +# +# Table name: plans +# +# id :bigint not null, primary key +# code :string not null +# name :string not null +# provider_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_plans_on_code (code) UNIQUE +# index_plans_on_provider_code (provider_code) +# +# Foreign Keys +# +# fk_rails_... (provider_code => providers.code) +# diff --git a/serverside_challenge_2/challenge/spec/factories/providers.rb b/serverside_challenge_2/challenge/spec/factories/providers.rb new file mode 100644 index 000000000..4898ee9de --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/providers.rb @@ -0,0 +1,21 @@ +FactoryBot.define do + factory :provider do + sequence(:code) { |n| "provider_code_#{n}" } + sequence(:name) { |n| "Provider_#{n}" } + end +end + +# == Schema Information +# +# Table name: providers +# +# id :bigint not null, primary key +# code :string not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_providers_on_code (code) UNIQUE +# diff --git a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb new file mode 100644 index 000000000..f1b886d8d --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb @@ -0,0 +1,33 @@ +FactoryBot.define do + factory :usage_charge do + plan + sequence(:code) { |n| "usage_charge_code_#{n}" } + usage_lower { rand(0..100) } + usage_upper { usage_lower + rand(1..100) } + unit_price { rand(10.00..50.00).round(2) } + end +end + +# == Schema Information +# +# Table name: usage_charges +# +# id :bigint not null, primary key +# code :string not null +# plan_code :string not null +# unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null +# usage_lower(電気使用量(kWh) 下限) :integer not null +# usage_upper(電気使用量(kWh) 上限) :integer +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_usage_charges_on_code (code) UNIQUE +# index_usage_charges_on_plan_code_and_usage_range (plan_code,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# +# Foreign Keys +# +# fk_rails_... (plan_code => plans.code) +# diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb new file mode 100644 index 000000000..559ad73aa --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -0,0 +1,79 @@ +require 'rails_helper' + +RSpec.describe BasicFee, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:plan) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:ampere) } + it { is_expected.to validate_numericality_of(:ampere).only_integer.is_greater_than(0) } + it { is_expected.to validate_presence_of(:fee) } + it { is_expected.to validate_numericality_of(:fee).is_greater_than_or_equal_to(0) } + + describe 'uniqueness of plan_code and ampere combination' do + let(:plan) { create(:plan) } + let!(:existing_basic_fee) { create(:basic_fee, plan:, ampere: 30, fee: 1000) } + + context 'when combination already exists' do + it 'is invalid with duplicate plan_code and ampere' do + new_basic_fee = build(:basic_fee, plan:, ampere: 30, fee: 1500) + expect(new_basic_fee).not_to be_valid + expect(new_basic_fee.errors[:ampere]).to include('has already been taken') + end + end + + context 'when combination is unique' do + it 'is valid with different plan' do + other_plan = create(:plan) + new_basic_fee = build(:basic_fee, plan: other_plan, ampere: 30, fee: 1000) + expect(new_basic_fee).to be_valid + end + + it 'is valid with different ampere' do + new_basic_fee = build(:basic_fee, plan:, ampere: 40, fee: 1000) + expect(new_basic_fee).to be_valid + end + end + end + end + + describe 'scopes' do + describe '.by_ampere' do + subject { described_class.by_ampere(ampere) } + + let(:ampere) { 30 } + + let!(:basic_fee_30A) { create(:basic_fee, ampere: 30) } + let!(:basic_fee_40A) { create(:basic_fee, ampere: 40) } + let!(:basic_fee_50A) { create(:basic_fee, ampere: 50) } + + it 'returns basic fees with the specified ampere' do + expect(subject).to contain_exactly(basic_fee_30A) + end + end + end +end + +# == Schema Information +# +# Table name: basic_fees +# +# id :bigint not null, primary key +# ampere(契約アンペア数(A)) :integer not null +# code :string not null +# fee(基本料金(円)) :decimal(10, 2) not null +# plan_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_code (code) UNIQUE +# index_basic_fees_on_plan_code_and_ampere (plan_code,ampere) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (plan_code => plans.code) +# diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb new file mode 100644 index 000000000..ac8441fa9 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -0,0 +1,255 @@ +require 'rails_helper' + +RSpec.describe Plan, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:provider) } + it { is_expected.to have_many(:basic_fees).dependent(:destroy) } + it { is_expected.to have_many(:usage_charges).dependent(:destroy) } + end + + describe 'scopes' do + describe '.by_ampere_and_usage' do + subject { described_class.by_ampere_and_usage(ampere, usage) } + + let(:plan) { create(:plan) } + + context 'アンペアと使用量に該当するものがある場合/usage_chargesが一部ヒットする場合' do + let(:ampere) { 30 } + let(:usage) { 100 } + + let!(:basic_fee) { create(:basic_fee, plan:, ampere:) } + let!(:usage_charge1) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: usage) } + let!(:usage_charge2) { create(:usage_charge, plan:, usage_lower: usage, usage_upper: usage + 1) } + let!(:usage_charge3) { create(:usage_charge, plan:, usage_lower: usage + 1, usage_upper: usage + 2) } # not included + + it 'returns target plans and selected associations' do + is_expected.to contain_exactly(plan) + + subject.first.tap do |p| + expect(p.basic_fees).to contain_exactly(basic_fee) + expect(p.usage_charges).to contain_exactly(usage_charge1, usage_charge2) # 正しく絞り込まれた状態で関連を取得できていること + end + end + end + + context 'アンペアに該当するものがない場合' do + let(:ampere) { 30 } + let(:usage) { 100 } + + let!(:basic_fee) { create(:basic_fee, plan:, ampere: ampere + 1) } + let!(:usage_charge) { create(:usage_charge, plan:, usage_lower: usage, usage_upper: usage + 1) } + + it 'returns an empty' do + is_expected.to be_empty + end + end + + context '使用料に該当するものがない場合' do + let(:ampere) { 30 } + let(:usage) { 100 } + + let!(:basic_fee) { create(:basic_fee, plan:, ampere:) } + let!(:usage_charge) { create(:usage_charge, plan:, usage_lower: usage + 1, usage_upper: nil) } + + it 'returns an empty' do + is_expected.to be_empty + end + end + end + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + end + + describe '.plan_prices' do + subject { described_class.plan_prices(ampere:, usage:) } + + before do + provider1 = create(:provider, name: '東京電力エナジーパートナー') + provider2 = create(:provider, name: '東京ガス') + provider3 = create(:provider, name: 'Looopでんき') + + plan1 = create(:plan, name: '従量電灯B', provider: provider1) + plan2 = create(:plan, name: 'スタンダードS', provider: provider1) + plan3 = create(:plan, name: 'ずっとも電気1', provider: provider2) + plan4 = create(:plan, name: 'おうちプラン', provider: provider3) + + create(:basic_fee, plan: plan1, ampere: 10, fee: 286.0) + create(:basic_fee, plan: plan1, ampere: 30, fee: 858.00) + create(:basic_fee, plan: plan1, ampere: 40, fee: 1144.00) + + create(:basic_fee, plan: plan2, ampere: 10, fee: 311.75) + create(:basic_fee, plan: plan2, ampere: 30, fee: 935.25) + create(:basic_fee, plan: plan2, ampere: 40, fee: 1247.00) + + # plan3は10Aの基本料金がないパターン + create(:basic_fee, plan: plan3, ampere: 30, fee: 858.00) + create(:basic_fee, plan: plan3, ampere: 40, fee: 1144.00) + + create(:basic_fee, plan: plan4, ampere: 10, fee: 0.00) + create(:basic_fee, plan: plan4, ampere: 30, fee: 0.00) + create(:basic_fee, plan: plan4, ampere: 40, fee: 0.00) + + create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 19.88) + create(:usage_charge, plan: plan1, usage_lower: 120, usage_upper: 300, unit_price: 26.48) + create(:usage_charge, plan: plan1, usage_lower: 300, usage_upper: nil, unit_price: 30.57) + + create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: 120, unit_price: 29.80) + create(:usage_charge, plan: plan2, usage_lower: 120, usage_upper: 300, unit_price: 36.40) + create(:usage_charge, plan: plan2, usage_lower: 300, usage_upper: nil, unit_price: 40.49) + + create(:usage_charge, plan: plan3, usage_lower: 0, usage_upper: 120, unit_price: 23.67) + create(:usage_charge, plan: plan3, usage_lower: 120, usage_upper: 300, unit_price: 23.88) + create(:usage_charge, plan: plan3, usage_lower: 300, usage_upper: nil, unit_price: 26.41) + + create(:usage_charge, plan: plan4, usage_lower: 0, usage_upper: nil, unit_price: 28.8) + end + + context 'アンペア10A、使用量120kWhの場合' do + let(:ampere) { 10 } + let(:usage) { 120 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 2671 }, # 286 + (19.88 * 120) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 3887 }, # 311.75 + (29.80 * 120) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, # 0 + (28.8 * 120) + ]) + end + end + + context 'アンペア30A、使用量120kWhの場合' do + let(:ampere) { 30 } + let(:usage) { 120 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3243 }, # 858 + (19.88 * 120) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4511 }, # 935.25 + (29.80 * 120) + { provider_name: '東京ガス', plan_name: 'ずっとも電気1', price: 3698 }, # 858 + (23.67 * 120) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, # 0 + (28.8 * 120) + ]) + end + end + + context 'アンペア10A、使用量121kWhの場合' do + # ※ ひとつのplanに対して、複数のusage_chargeが該当するパターン + # 使用量の計算は段階的に計算されること + let(:ampere) { 10 } + let(:usage) { 121 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 2698 }, # 286 + (19.88 * 120) + (26.48 * 1) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 3924 }, # 311.75 + (29.80 * 120) + (36.40 * 1) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3484 }, # 0 + (28.8 * 121) + ]) + end + end + + context 'アンペア10A、使用量302kWhの場合' do + let(:ampere) { 10 } + let(:usage) { 302 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 7499 }, # 286 + (19.88 * 120) + (26.48 * 180) + (30.57 * 2) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 10520 }, # 311.75 + (29.80 * 120) + (36.40 * 180) + (40.49 * 2) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 8697 }, # 0 + (28.8 * 302) + ]) + end + end + + context 'アンペア40A、使用量121kWhの場合' do + let(:ampere) { 40 } + let(:usage) { 121 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3556 }, # 1144 + (19.88 * 120) + (26.48 * 1) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4859 }, # 1247.00 + (29.80 * 120) + (36.40 * 1) + { provider_name: '東京ガス', plan_name: 'ずっとも電気1', price: 4008 }, # 1144 + (23.67 * 120) + (23.88 * 1) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3484}, # 0 + (28.8 * 121) + ]) + end + end + + context '該当するアンペアがない場合' do + let(:ampere) { 5 } + let(:usage) { 120 } + + it 'returns an empty array' do + is_expected.to be_empty + end + end + + context '該当する使用料がない場合' do + let(:ampere) { 30 } + let(:usage) { -1} + + it 'returns an empty array' do + is_expected.to be_empty + end + end + + describe '異常系' do + context 'アンペアがnilの場合' do + let(:ampere) { nil } + let(:usage) { 120 } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context '使用量がnilの場合' do + let(:ampere) { 30 } + let(:usage) { nil } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'アンペアがFloatの場合' do + let(:ampere) { 30.5 } + let(:usage) { 120 } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context '使用量がFloatの場合' do + let(:ampere) { 30 } + let(:usage) { 120.5 } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + end + end +end + +# == Schema Information +# +# Table name: plans +# +# id :bigint not null, primary key +# code :string not null +# name :string not null +# provider_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_plans_on_code (code) UNIQUE +# index_plans_on_provider_code (provider_code) +# +# Foreign Keys +# +# fk_rails_... (provider_code => providers.code) +# diff --git a/serverside_challenge_2/challenge/spec/models/provider_spec.rb b/serverside_challenge_2/challenge/spec/models/provider_spec.rb new file mode 100644 index 000000000..9d01c9b18 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/provider_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe Provider, type: :model do + describe 'associations' do + it { is_expected.to have_many(:plans).dependent(:destroy) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + end +end + +# == Schema Information +# +# Table name: providers +# +# id :bigint not null, primary key +# code :string not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_providers_on_code (code) UNIQUE +# diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb new file mode 100644 index 000000000..dc218ae0c --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -0,0 +1,299 @@ +require 'rails_helper' + +RSpec.describe UsageCharge, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:plan) } + end + + describe 'scopes' do + describe '.lower_than' do + subject { described_class.lower_than(usage) } + + let(:usage) { 100 } + + let!(:usage_charge_1) { create(:usage_charge, usage_lower: 0) } + let!(:usage_charge_2) { create(:usage_charge, usage_lower: 99) } + let!(:usage_charge_3) { create(:usage_charge, usage_lower: 100) } + let!(:usage_charge_4) { create(:usage_charge, usage_lower: 101) } + + it 'returns usage charges that include the specified usage' do + expect(subject).to contain_exactly(usage_charge_1, usage_charge_2, usage_charge_3) + end + end + end + + describe '#calc_charge' do + subject { usage_charge.calc_charge(usage) } + + context '使用量が0kWhの場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let(:usage) { 0 } + + it '0を返すこと' do + expect(subject).to eq 0 + end + end + + context '第1段階内での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let(:usage) { 50 } + + it '期待される従量料金を返すこと' do + expect(subject).to eq 994.0 # 19.88 * 50 + end + end + + context '第1段階の上限を超える使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let(:usage) { 121 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(2385.6) # 19.88 * 120(上限) + end + end + + context '第2段階の境界値(下限-1)での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 119 } + + it '0を返すこと' do + expect(subject).to eq 0.0 + end + end + + context '第2段階の境界値(下限)での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 120 } + + it '0を返すこと' do + expect(subject).to eq 0.0 + end + end + + context '第2段階内での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 200 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(2118.4) # 26.48 * (200 - 120) + end + end + + context '第2段階の上限を超える使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 301 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(4766.4) # 26.48 * (300 - 120) + end + end + + context '上限なしの段階での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 300, usage_upper: nil, unit_price: 26.48) } + let(:usage) { 350 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(1324) # 26.48 * (350 - 300) + end + end + end + + describe 'validations' do + it { should validate_presence_of(:unit_price) } + it { should validate_numericality_of(:unit_price).is_greater_than_or_equal_to(0) } + it { should validate_presence_of(:usage_lower) } + + describe '#no_overlapping_ranges' do + let(:plan) { create(:plan) } + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: 200) } + + + shared_examples 'invalid' do + it '重複しているため無効であること' do + expect(new_charge).not_to be_valid + expect(new_charge.errors[:base]).to include('overlapping usage range exists') + end + end + + shared_examples 'valid' do + it '重複していないため有効であること' do + expect(new_charge).to be_valid + end + end + + context '新規上限 = 既存下限 - 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 99) } + it_behaves_like 'valid' + end + + context '新規上限 = 既存下限' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 100) } + it_behaves_like 'valid' + end + + context '新規上限 = 既存下限 + 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 101) } + it_behaves_like 'invalid' + end + + context '新規下限 = 既存上限 - 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 199, usage_upper: 300) } + it_behaves_like 'invalid' + end + + context '新規下限 = 既存上限' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 200, usage_upper: 300) } + it_behaves_like 'valid' + end + + context '新規下限 = 既存上限 + 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 201, usage_upper: 300) } + it_behaves_like 'valid' + end + + context '完全に同じ範囲の場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 100, usage_upper: 200) } + it_behaves_like 'invalid' + end + + context '既存範囲の中に含まれる場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 101, usage_upper: 199) } + it_behaves_like 'invalid' + end + + context '既存範囲の中に含む場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 99, usage_upper: 201) } + it_behaves_like 'invalid' + end + + context '別のプランに属する場合' do + let(:new_charge) { build(:usage_charge, plan: create(:plan), usage_lower: 100, usage_upper: 200) } + it_behaves_like 'valid' + end + + context '既存の usage_upper が nil(上限なし)の場合' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: nil) } + + context '新規上限 = 既存下限 - 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 99) } + it_behaves_like 'valid' + end + + context '新規上限 = 既存下限' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 100) } + it_behaves_like 'valid' + end + + context '新規上限 = 既存下限 + 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 101) } + it_behaves_like 'invalid' + end + + context '上限が存在せず下限が既存範囲より小さい場合([99, nil])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 99, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '上限が存在せず下限が一致する場合([100, nil])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 100, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '上限が存在せず下限が既存範囲より少し大きい場合([101, nil])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 101, usage_upper: nil) } + it_behaves_like 'invalid' + end + end + + context '新しい usage_upper が nil(上限なし)の場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 150, usage_upper: nil) } + + context '新規上限 = 既存下限 - 1' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: 149) } + + it_behaves_like 'valid' + end + + context '新規上限 = 既存下限' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: 150) } + + it_behaves_like 'valid' + end + + context '新規上限 = 既存下限 + 1' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: 151) } + + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil で範囲が重なる場合([149, nil])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 149, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil で下限が一致する場合([150, nil])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 150, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil で下限が既存範囲より大きい場合([151, nil])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 151, usage_upper: nil) } + it_behaves_like 'invalid' + end + end + end + + describe 'usage_upper is greater than usage_lower' do + it 'is valid if usage_lower < usage_upper' do + record = build(:usage_charge, + usage_lower: 100, + usage_upper: 101, + ) + + expect(record).to be_valid + end + + it 'is invalid if usage_upper usage_lower > usage_upper' do + record = build(:usage_charge, + usage_lower: 100, + usage_upper: 99, + ) + + expect(record).to be_invalid + end + + it 'is invalid if usage_upper is usage_lower == usage_upper' do + record = build(:usage_charge, + usage_lower: 100, + usage_upper: 100, + ) + + expect(record).to be_invalid + end + end + end +end + +# == Schema Information +# +# Table name: usage_charges +# +# id :bigint not null, primary key +# code :string not null +# plan_code :string not null +# unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null +# usage_lower(電気使用量(kWh) 下限) :integer not null +# usage_upper(電気使用量(kWh) 上限) :integer +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_usage_charges_on_code (code) UNIQUE +# index_usage_charges_on_plan_code_and_usage_range (plan_code,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# +# Foreign Keys +# +# fk_rails_... (plan_code => plans.code) +# diff --git a/serverside_challenge_2/challenge/spec/rails_helper.rb b/serverside_challenge_2/challenge/spec/rails_helper.rb new file mode 100644 index 000000000..25f23190e --- /dev/null +++ b/serverside_challenge_2/challenge/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Checks for pending migrations and applies them before tests are run. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_path = Rails.root.join('spec/fixtures') + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails can automatically mix in different behaviours to your tests + # based on their file location, for example enabling you to call `get` and + # `post` in specs under `spec/controllers`. + # + # You can disable this behaviour by removing the line below, and instead + # explicitly tag your specs with their type, e.g.: + # + # RSpec.describe UsersController, type: :controller do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/6-0/rspec-rails + config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") + + Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end + end + # FactoryBot configuration + config.include FactoryBot::Syntax::Methods +end diff --git a/serverside_challenge_2/challenge/spec/requests/plans_spec.rb b/serverside_challenge_2/challenge/spec/requests/plans_spec.rb new file mode 100644 index 000000000..09d50c2e8 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/requests/plans_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe "Plans", type: :request do + describe "GET /plans/prices" do + subject { get '/plans/prices', params: } + + let(:provider1) { create(:provider, name: '東京電力エナジーパートナー') } + let(:plan1) { create(:plan, name: 'スタンダードS', provider: provider1) } + let!(:basic_fee1) { create(:basic_fee, plan: plan1, ampere: 10, fee: 311.75) } + let!(:usage_charge1) { create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 29.80) } + + let(:provider2) { create(:provider, name: 'Looopでんき') } + let(:plan2) { create(:plan, name: 'おうちプラン', provider: provider2) } + let!(:basic_fee2) { create(:basic_fee, plan: plan2, ampere: 10, fee: 0.00) } + let!(:usage_charge2) { create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: nil, unit_price: 28.8) } + + shared_examples 'bad request' do |expected_message| + it 'response bad_request and error message' do + subject + expect(response).to have_http_status(:bad_request) + expect(response.content_type).to eq('application/json; charset=utf-8') + json = JSON.parse(response.body) + expect(json).to be_a(Hash) + expect(json['error']).to include(expected_message) + end + end + + shared_examples 'ok' do |expected_array| + it 'response ok and valid json' do + subject + expect(response).to have_http_status(:ok) + expect(response.content_type).to eq('application/json; charset=utf-8') + json = JSON.parse(response.body) + expect(json).to be_an(Array) + expect(json).to match_array(expected_array) + end + end + + describe '正常系' do + context 'typical case' do + let(:params) { { ampere: 10, usage: 120 } } + + it_behaves_like 'ok', [ + { + 'provider_name' => '東京電力エナジーパートナー', + 'plan_name' => 'スタンダードS', + 'price' => 3887 + }, + { + 'provider_name' => 'Looopでんき', + 'plan_name' => 'おうちプラン', + 'price' => 3456 + } + ] + end + + context 'usageが0の時(0以上バリデーションの境界値)' do + let(:params) { { ampere: 10, usage: 0 } } + + it_behaves_like 'ok', [ + { + 'provider_name' => '東京電力エナジーパートナー', + 'plan_name' => 'スタンダードS', + 'price' => 311 + }, + { + 'provider_name' => 'Looopでんき', + 'plan_name' => 'おうちプラン', + 'price' => 0 + } + ] + end + end + + describe 'validations' do + context 'ampere is missing' do + let(:params) { { usage: 100 } } + it_behaves_like 'bad request', 'Ampere and usage must be provided' + end + + context 'usage is missing' do + let(:params) { { ampere: 30 } } + it_behaves_like 'bad request', 'Ampere and usage must be provided' + end + + context 'ampere is not permitted' do + let(:params) { { ampere: 99, usage: 100 } } + it_behaves_like 'bad request', 'Ampere must be one of' + end + + context 'usage is negative' do + let(:params) { { ampere: 30, usage: -1 } } + it_behaves_like 'bad request', 'Usage must be a non-negative integer' + end + + context 'ampere is float' do + let(:params) { { ampere: 30.5, usage: 100 } } + it_behaves_like 'bad request', 'Ampere must be one of' + end + + context 'usage is float' do + let(:params) { { ampere: 30, usage: 100.5 } } + it_behaves_like 'bad request', 'Usage must be a non-negative integer' + end + end + end +end diff --git a/serverside_challenge_2/challenge/spec/spec_helper.rb b/serverside_challenge_2/challenge/spec/spec_helper.rb new file mode 100644 index 000000000..327b58ea1 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/serverside_challenge_2/challenge/vendor/javascript/.keep b/serverside_challenge_2/challenge/vendor/javascript/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js b/serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js new file mode 100644 index 000000000..07dd4a6cc --- /dev/null +++ b/serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js @@ -0,0 +1,4 @@ +// @hotwired/stimulus@3.2.2 downloaded from https://ga.jspm.io/npm:@hotwired/stimulus@3.2.2/dist/stimulus.js + +class EventListener{constructor(e,t,r){this.eventTarget=e;this.eventName=t;this.eventOptions=r;this.unorderedBindings=new Set}connect(){this.eventTarget.addEventListener(this.eventName,this,this.eventOptions)}disconnect(){this.eventTarget.removeEventListener(this.eventName,this,this.eventOptions)}bindingConnected(e){this.unorderedBindings.add(e)}bindingDisconnected(e){this.unorderedBindings.delete(e)}handleEvent(e){const t=extendEvent(e);for(const e of this.bindings){if(t.immediatePropagationStopped)break;e.handleEvent(t)}}hasBindings(){return this.unorderedBindings.size>0}get bindings(){return Array.from(this.unorderedBindings).sort(((e,t)=>{const r=e.index,s=t.index;return rs?1:0}))}}function extendEvent(e){if("immediatePropagationStopped"in e)return e;{const{stopImmediatePropagation:t}=e;return Object.assign(e,{immediatePropagationStopped:false,stopImmediatePropagation(){this.immediatePropagationStopped=true;t.call(this)}})}}class Dispatcher{constructor(e){this.application=e;this.eventListenerMaps=new Map;this.started=false}start(){if(!this.started){this.started=true;this.eventListeners.forEach((e=>e.connect()))}}stop(){if(this.started){this.started=false;this.eventListeners.forEach((e=>e.disconnect()))}}get eventListeners(){return Array.from(this.eventListenerMaps.values()).reduce(((e,t)=>e.concat(Array.from(t.values()))),[])}bindingConnected(e){this.fetchEventListenerForBinding(e).bindingConnected(e)}bindingDisconnected(e,t=false){this.fetchEventListenerForBinding(e).bindingDisconnected(e);t&&this.clearEventListenersForBinding(e)}handleError(e,t,r={}){this.application.handleError(e,`Error ${t}`,r)}clearEventListenersForBinding(e){const t=this.fetchEventListenerForBinding(e);if(!t.hasBindings()){t.disconnect();this.removeMappedEventListenerFor(e)}}removeMappedEventListenerFor(e){const{eventTarget:t,eventName:r,eventOptions:s}=e;const n=this.fetchEventListenerMapForEventTarget(t);const i=this.cacheKey(r,s);n.delete(i);0==n.size&&this.eventListenerMaps.delete(t)}fetchEventListenerForBinding(e){const{eventTarget:t,eventName:r,eventOptions:s}=e;return this.fetchEventListener(t,r,s)}fetchEventListener(e,t,r){const s=this.fetchEventListenerMapForEventTarget(e);const n=this.cacheKey(t,r);let i=s.get(n);if(!i){i=this.createEventListener(e,t,r);s.set(n,i)}return i}createEventListener(e,t,r){const s=new EventListener(e,t,r);this.started&&s.connect();return s}fetchEventListenerMapForEventTarget(e){let t=this.eventListenerMaps.get(e);if(!t){t=new Map;this.eventListenerMaps.set(e,t)}return t}cacheKey(e,t){const r=[e];Object.keys(t).sort().forEach((e=>{r.push(`${t[e]?"":"!"}${e}`)}));return r.join(":")}}const e={stop({event:e,value:t}){t&&e.stopPropagation();return true},prevent({event:e,value:t}){t&&e.preventDefault();return true},self({event:e,value:t,element:r}){return!t||r===e.target}};const t=/^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/;function parseActionDescriptorString(e){const r=e.trim();const s=r.match(t)||[];let n=s[2];let i=s[3];if(i&&!["keydown","keyup","keypress"].includes(n)){n+=`.${i}`;i=""}return{eventTarget:parseEventTarget(s[4]),eventName:n,eventOptions:s[7]?parseEventOptions(s[7]):{},identifier:s[5],methodName:s[6],keyFilter:s[1]||i}}function parseEventTarget(e){return"window"==e?window:"document"==e?document:void 0}function parseEventOptions(e){return e.split(":").reduce(((e,t)=>Object.assign(e,{[t.replace(/^!/,"")]:!/^!/.test(t)})),{})}function stringifyEventTarget(e){return e==window?"window":e==document?"document":void 0}function camelize(e){return e.replace(/(?:[_-])([a-z0-9])/g,((e,t)=>t.toUpperCase()))}function namespaceCamelize(e){return camelize(e.replace(/--/g,"-").replace(/__/g,"_"))}function capitalize(e){return e.charAt(0).toUpperCase()+e.slice(1)}function dasherize(e){return e.replace(/([A-Z])/g,((e,t)=>`-${t.toLowerCase()}`))}function tokenize(e){return e.match(/[^\s]+/g)||[]}function isSomething(e){return null!==e&&void 0!==e}function hasProperty(e,t){return Object.prototype.hasOwnProperty.call(e,t)}const r=["meta","ctrl","alt","shift"];class Action{constructor(e,t,r,s){this.element=e;this.index=t;this.eventTarget=r.eventTarget||e;this.eventName=r.eventName||getDefaultEventNameForElement(e)||error("missing event name");this.eventOptions=r.eventOptions||{};this.identifier=r.identifier||error("missing identifier");this.methodName=r.methodName||error("missing method name");this.keyFilter=r.keyFilter||"";this.schema=s}static forToken(e,t){return new this(e.element,e.index,parseActionDescriptorString(e.content),t)}toString(){const e=this.keyFilter?`.${this.keyFilter}`:"";const t=this.eventTargetName?`@${this.eventTargetName}`:"";return`${this.eventName}${e}${t}->${this.identifier}#${this.methodName}`}shouldIgnoreKeyboardEvent(e){if(!this.keyFilter)return false;const t=this.keyFilter.split("+");if(this.keyFilterDissatisfied(e,t))return true;const s=t.filter((e=>!r.includes(e)))[0];if(!s)return false;hasProperty(this.keyMappings,s)||error(`contains unknown key filter: ${this.keyFilter}`);return this.keyMappings[s].toLowerCase()!==e.key.toLowerCase()}shouldIgnoreMouseEvent(e){if(!this.keyFilter)return false;const t=[this.keyFilter];return!!this.keyFilterDissatisfied(e,t)}get params(){const e={};const t=new RegExp(`^data-${this.identifier}-(.+)-param$`,"i");for(const{name:r,value:s}of Array.from(this.element.attributes)){const n=r.match(t);const i=n&&n[1];i&&(e[camelize(i)]=typecast(s))}return e}get eventTargetName(){return stringifyEventTarget(this.eventTarget)}get keyMappings(){return this.schema.keyMappings}keyFilterDissatisfied(e,t){const[s,n,i,o]=r.map((e=>t.includes(e)));return e.metaKey!==s||e.ctrlKey!==n||e.altKey!==i||e.shiftKey!==o}}const s={a:()=>"click",button:()=>"click",form:()=>"submit",details:()=>"toggle",input:e=>"submit"==e.getAttribute("type")?"click":"input",select:()=>"change",textarea:()=>"input"};function getDefaultEventNameForElement(e){const t=e.tagName.toLowerCase();if(t in s)return s[t](e)}function error(e){throw new Error(e)}function typecast(e){try{return JSON.parse(e)}catch(t){return e}}class Binding{constructor(e,t){this.context=e;this.action=t}get index(){return this.action.index}get eventTarget(){return this.action.eventTarget}get eventOptions(){return this.action.eventOptions}get identifier(){return this.context.identifier}handleEvent(e){const t=this.prepareActionEvent(e);this.willBeInvokedByEvent(e)&&this.applyEventModifiers(t)&&this.invokeWithEvent(t)}get eventName(){return this.action.eventName}get method(){const e=this.controller[this.methodName];if("function"==typeof e)return e;throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`)}applyEventModifiers(e){const{element:t}=this.action;const{actionDescriptorFilters:r}=this.context.application;const{controller:s}=this.context;let n=true;for(const[i,o]of Object.entries(this.eventOptions))if(i in r){const c=r[i];n=n&&c({name:i,value:o,event:e,element:t,controller:s})}return n}prepareActionEvent(e){return Object.assign(e,{params:this.action.params})}invokeWithEvent(e){const{target:t,currentTarget:r}=e;try{this.method.call(this.controller,e);this.context.logDebugActivity(this.methodName,{event:e,target:t,currentTarget:r,action:this.methodName})}catch(t){const{identifier:r,controller:s,element:n,index:i}=this;const o={identifier:r,controller:s,element:n,index:i,event:e};this.context.handleError(t,`invoking action "${this.action}"`,o)}}willBeInvokedByEvent(e){const t=e.target;return!(e instanceof KeyboardEvent&&this.action.shouldIgnoreKeyboardEvent(e))&&(!(e instanceof MouseEvent&&this.action.shouldIgnoreMouseEvent(e))&&(this.element===t||(t instanceof Element&&this.element.contains(t)?this.scope.containsElement(t):this.scope.containsElement(this.action.element))))}get controller(){return this.context.controller}get methodName(){return this.action.methodName}get element(){return this.scope.element}get scope(){return this.context.scope}}class ElementObserver{constructor(e,t){this.mutationObserverInit={attributes:true,childList:true,subtree:true};this.element=e;this.started=false;this.delegate=t;this.elements=new Set;this.mutationObserver=new MutationObserver((e=>this.processMutations(e)))}start(){if(!this.started){this.started=true;this.mutationObserver.observe(this.element,this.mutationObserverInit);this.refresh()}}pause(e){if(this.started){this.mutationObserver.disconnect();this.started=false}e();if(!this.started){this.mutationObserver.observe(this.element,this.mutationObserverInit);this.started=true}}stop(){if(this.started){this.mutationObserver.takeRecords();this.mutationObserver.disconnect();this.started=false}}refresh(){if(this.started){const e=new Set(this.matchElementsInTree());for(const t of Array.from(this.elements))e.has(t)||this.removeElement(t);for(const t of Array.from(e))this.addElement(t)}}processMutations(e){if(this.started)for(const t of e)this.processMutation(t)}processMutation(e){if("attributes"==e.type)this.processAttributeChange(e.target,e.attributeName);else if("childList"==e.type){this.processRemovedNodes(e.removedNodes);this.processAddedNodes(e.addedNodes)}}processAttributeChange(e,t){this.elements.has(e)?this.delegate.elementAttributeChanged&&this.matchElement(e)?this.delegate.elementAttributeChanged(e,t):this.removeElement(e):this.matchElement(e)&&this.addElement(e)}processRemovedNodes(e){for(const t of Array.from(e)){const e=this.elementFromNode(t);e&&this.processTree(e,this.removeElement)}}processAddedNodes(e){for(const t of Array.from(e)){const e=this.elementFromNode(t);e&&this.elementIsActive(e)&&this.processTree(e,this.addElement)}}matchElement(e){return this.delegate.matchElement(e)}matchElementsInTree(e=this.element){return this.delegate.matchElementsInTree(e)}processTree(e,t){for(const r of this.matchElementsInTree(e))t.call(this,r)}elementFromNode(e){if(e.nodeType==Node.ELEMENT_NODE)return e}elementIsActive(e){return e.isConnected==this.element.isConnected&&this.element.contains(e)}addElement(e){if(!this.elements.has(e)&&this.elementIsActive(e)){this.elements.add(e);this.delegate.elementMatched&&this.delegate.elementMatched(e)}}removeElement(e){if(this.elements.has(e)){this.elements.delete(e);this.delegate.elementUnmatched&&this.delegate.elementUnmatched(e)}}}class AttributeObserver{constructor(e,t,r){this.attributeName=t;this.delegate=r;this.elementObserver=new ElementObserver(e,this)}get element(){return this.elementObserver.element}get selector(){return`[${this.attributeName}]`}start(){this.elementObserver.start()}pause(e){this.elementObserver.pause(e)}stop(){this.elementObserver.stop()}refresh(){this.elementObserver.refresh()}get started(){return this.elementObserver.started}matchElement(e){return e.hasAttribute(this.attributeName)}matchElementsInTree(e){const t=this.matchElement(e)?[e]:[];const r=Array.from(e.querySelectorAll(this.selector));return t.concat(r)}elementMatched(e){this.delegate.elementMatchedAttribute&&this.delegate.elementMatchedAttribute(e,this.attributeName)}elementUnmatched(e){this.delegate.elementUnmatchedAttribute&&this.delegate.elementUnmatchedAttribute(e,this.attributeName)}elementAttributeChanged(e,t){this.delegate.elementAttributeValueChanged&&this.attributeName==t&&this.delegate.elementAttributeValueChanged(e,t)}}function add(e,t,r){fetch(e,t).add(r)}function del(e,t,r){fetch(e,t).delete(r);prune(e,t)}function fetch(e,t){let r=e.get(t);if(!r){r=new Set;e.set(t,r)}return r}function prune(e,t){const r=e.get(t);null!=r&&0==r.size&&e.delete(t)}class Multimap{constructor(){this.valuesByKey=new Map}get keys(){return Array.from(this.valuesByKey.keys())}get values(){const e=Array.from(this.valuesByKey.values());return e.reduce(((e,t)=>e.concat(Array.from(t))),[])}get size(){const e=Array.from(this.valuesByKey.values());return e.reduce(((e,t)=>e+t.size),0)}add(e,t){add(this.valuesByKey,e,t)}delete(e,t){del(this.valuesByKey,e,t)}has(e,t){const r=this.valuesByKey.get(e);return null!=r&&r.has(t)}hasKey(e){return this.valuesByKey.has(e)}hasValue(e){const t=Array.from(this.valuesByKey.values());return t.some((t=>t.has(e)))}getValuesForKey(e){const t=this.valuesByKey.get(e);return t?Array.from(t):[]}getKeysForValue(e){return Array.from(this.valuesByKey).filter((([t,r])=>r.has(e))).map((([e,t])=>e))}}class IndexedMultimap extends Multimap{constructor(){super();this.keysByValue=new Map}get values(){return Array.from(this.keysByValue.keys())}add(e,t){super.add(e,t);add(this.keysByValue,t,e)}delete(e,t){super.delete(e,t);del(this.keysByValue,t,e)}hasValue(e){return this.keysByValue.has(e)}getKeysForValue(e){const t=this.keysByValue.get(e);return t?Array.from(t):[]}}class SelectorObserver{constructor(e,t,r,s){this._selector=t;this.details=s;this.elementObserver=new ElementObserver(e,this);this.delegate=r;this.matchesByElement=new Multimap}get started(){return this.elementObserver.started}get selector(){return this._selector}set selector(e){this._selector=e;this.refresh()}start(){this.elementObserver.start()}pause(e){this.elementObserver.pause(e)}stop(){this.elementObserver.stop()}refresh(){this.elementObserver.refresh()}get element(){return this.elementObserver.element}matchElement(e){const{selector:t}=this;if(t){const r=e.matches(t);return this.delegate.selectorMatchElement?r&&this.delegate.selectorMatchElement(e,this.details):r}return false}matchElementsInTree(e){const{selector:t}=this;if(t){const r=this.matchElement(e)?[e]:[];const s=Array.from(e.querySelectorAll(t)).filter((e=>this.matchElement(e)));return r.concat(s)}return[]}elementMatched(e){const{selector:t}=this;t&&this.selectorMatched(e,t)}elementUnmatched(e){const t=this.matchesByElement.getKeysForValue(e);for(const r of t)this.selectorUnmatched(e,r)}elementAttributeChanged(e,t){const{selector:r}=this;if(r){const t=this.matchElement(e);const s=this.matchesByElement.has(r,e);t&&!s?this.selectorMatched(e,r):!t&&s&&this.selectorUnmatched(e,r)}}selectorMatched(e,t){this.delegate.selectorMatched(e,t,this.details);this.matchesByElement.add(t,e)}selectorUnmatched(e,t){this.delegate.selectorUnmatched(e,t,this.details);this.matchesByElement.delete(t,e)}}class StringMapObserver{constructor(e,t){this.element=e;this.delegate=t;this.started=false;this.stringMap=new Map;this.mutationObserver=new MutationObserver((e=>this.processMutations(e)))}start(){if(!this.started){this.started=true;this.mutationObserver.observe(this.element,{attributes:true,attributeOldValue:true});this.refresh()}}stop(){if(this.started){this.mutationObserver.takeRecords();this.mutationObserver.disconnect();this.started=false}}refresh(){if(this.started)for(const e of this.knownAttributeNames)this.refreshAttribute(e,null)}processMutations(e){if(this.started)for(const t of e)this.processMutation(t)}processMutation(e){const t=e.attributeName;t&&this.refreshAttribute(t,e.oldValue)}refreshAttribute(e,t){const r=this.delegate.getStringMapKeyForAttribute(e);if(null!=r){this.stringMap.has(e)||this.stringMapKeyAdded(r,e);const s=this.element.getAttribute(e);this.stringMap.get(e)!=s&&this.stringMapValueChanged(s,r,t);if(null==s){const t=this.stringMap.get(e);this.stringMap.delete(e);t&&this.stringMapKeyRemoved(r,e,t)}else this.stringMap.set(e,s)}}stringMapKeyAdded(e,t){this.delegate.stringMapKeyAdded&&this.delegate.stringMapKeyAdded(e,t)}stringMapValueChanged(e,t,r){this.delegate.stringMapValueChanged&&this.delegate.stringMapValueChanged(e,t,r)}stringMapKeyRemoved(e,t,r){this.delegate.stringMapKeyRemoved&&this.delegate.stringMapKeyRemoved(e,t,r)}get knownAttributeNames(){return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames)))}get currentAttributeNames(){return Array.from(this.element.attributes).map((e=>e.name))}get recordedAttributeNames(){return Array.from(this.stringMap.keys())}}class TokenListObserver{constructor(e,t,r){this.attributeObserver=new AttributeObserver(e,t,this);this.delegate=r;this.tokensByElement=new Multimap}get started(){return this.attributeObserver.started}start(){this.attributeObserver.start()}pause(e){this.attributeObserver.pause(e)}stop(){this.attributeObserver.stop()}refresh(){this.attributeObserver.refresh()}get element(){return this.attributeObserver.element}get attributeName(){return this.attributeObserver.attributeName}elementMatchedAttribute(e){this.tokensMatched(this.readTokensForElement(e))}elementAttributeValueChanged(e){const[t,r]=this.refreshTokensForElement(e);this.tokensUnmatched(t);this.tokensMatched(r)}elementUnmatchedAttribute(e){this.tokensUnmatched(this.tokensByElement.getValuesForKey(e))}tokensMatched(e){e.forEach((e=>this.tokenMatched(e)))}tokensUnmatched(e){e.forEach((e=>this.tokenUnmatched(e)))}tokenMatched(e){this.delegate.tokenMatched(e);this.tokensByElement.add(e.element,e)}tokenUnmatched(e){this.delegate.tokenUnmatched(e);this.tokensByElement.delete(e.element,e)}refreshTokensForElement(e){const t=this.tokensByElement.getValuesForKey(e);const r=this.readTokensForElement(e);const s=zip(t,r).findIndex((([e,t])=>!tokensAreEqual(e,t)));return-1==s?[[],[]]:[t.slice(s),r.slice(s)]}readTokensForElement(e){const t=this.attributeName;const r=e.getAttribute(t)||"";return parseTokenString(r,e,t)}}function parseTokenString(e,t,r){return e.trim().split(/\s+/).filter((e=>e.length)).map(((e,s)=>({element:t,attributeName:r,content:e,index:s})))}function zip(e,t){const r=Math.max(e.length,t.length);return Array.from({length:r},((r,s)=>[e[s],t[s]]))}function tokensAreEqual(e,t){return e&&t&&e.index==t.index&&e.content==t.content}class ValueListObserver{constructor(e,t,r){this.tokenListObserver=new TokenListObserver(e,t,this);this.delegate=r;this.parseResultsByToken=new WeakMap;this.valuesByTokenByElement=new WeakMap}get started(){return this.tokenListObserver.started}start(){this.tokenListObserver.start()}stop(){this.tokenListObserver.stop()}refresh(){this.tokenListObserver.refresh()}get element(){return this.tokenListObserver.element}get attributeName(){return this.tokenListObserver.attributeName}tokenMatched(e){const{element:t}=e;const{value:r}=this.fetchParseResultForToken(e);if(r){this.fetchValuesByTokenForElement(t).set(e,r);this.delegate.elementMatchedValue(t,r)}}tokenUnmatched(e){const{element:t}=e;const{value:r}=this.fetchParseResultForToken(e);if(r){this.fetchValuesByTokenForElement(t).delete(e);this.delegate.elementUnmatchedValue(t,r)}}fetchParseResultForToken(e){let t=this.parseResultsByToken.get(e);if(!t){t=this.parseToken(e);this.parseResultsByToken.set(e,t)}return t}fetchValuesByTokenForElement(e){let t=this.valuesByTokenByElement.get(e);if(!t){t=new Map;this.valuesByTokenByElement.set(e,t)}return t}parseToken(e){try{const t=this.delegate.parseValueForToken(e);return{value:t}}catch(e){return{error:e}}}}class BindingObserver{constructor(e,t){this.context=e;this.delegate=t;this.bindingsByAction=new Map}start(){if(!this.valueListObserver){this.valueListObserver=new ValueListObserver(this.element,this.actionAttribute,this);this.valueListObserver.start()}}stop(){if(this.valueListObserver){this.valueListObserver.stop();delete this.valueListObserver;this.disconnectAllActions()}}get element(){return this.context.element}get identifier(){return this.context.identifier}get actionAttribute(){return this.schema.actionAttribute}get schema(){return this.context.schema}get bindings(){return Array.from(this.bindingsByAction.values())}connectAction(e){const t=new Binding(this.context,e);this.bindingsByAction.set(e,t);this.delegate.bindingConnected(t)}disconnectAction(e){const t=this.bindingsByAction.get(e);if(t){this.bindingsByAction.delete(e);this.delegate.bindingDisconnected(t)}}disconnectAllActions(){this.bindings.forEach((e=>this.delegate.bindingDisconnected(e,true)));this.bindingsByAction.clear()}parseValueForToken(e){const t=Action.forToken(e,this.schema);if(t.identifier==this.identifier)return t}elementMatchedValue(e,t){this.connectAction(t)}elementUnmatchedValue(e,t){this.disconnectAction(t)}}class ValueObserver{constructor(e,t){this.context=e;this.receiver=t;this.stringMapObserver=new StringMapObserver(this.element,this);this.valueDescriptorMap=this.controller.valueDescriptorMap}start(){this.stringMapObserver.start();this.invokeChangedCallbacksForDefaultValues()}stop(){this.stringMapObserver.stop()}get element(){return this.context.element}get controller(){return this.context.controller}getStringMapKeyForAttribute(e){if(e in this.valueDescriptorMap)return this.valueDescriptorMap[e].name}stringMapKeyAdded(e,t){const r=this.valueDescriptorMap[t];this.hasValue(e)||this.invokeChangedCallback(e,r.writer(this.receiver[e]),r.writer(r.defaultValue))}stringMapValueChanged(e,t,r){const s=this.valueDescriptorNameMap[t];if(null!==e){null===r&&(r=s.writer(s.defaultValue));this.invokeChangedCallback(t,e,r)}}stringMapKeyRemoved(e,t,r){const s=this.valueDescriptorNameMap[e];this.hasValue(e)?this.invokeChangedCallback(e,s.writer(this.receiver[e]),r):this.invokeChangedCallback(e,s.writer(s.defaultValue),r)}invokeChangedCallbacksForDefaultValues(){for(const{key:e,name:t,defaultValue:r,writer:s}of this.valueDescriptors)void 0==r||this.controller.data.has(e)||this.invokeChangedCallback(t,s(r),void 0)}invokeChangedCallback(e,t,r){const s=`${e}Changed`;const n=this.receiver[s];if("function"==typeof n){const s=this.valueDescriptorNameMap[e];try{const e=s.reader(t);let i=r;r&&(i=s.reader(r));n.call(this.receiver,e,i)}catch(e){e instanceof TypeError&&(e.message=`Stimulus Value "${this.context.identifier}.${s.name}" - ${e.message}`);throw e}}}get valueDescriptors(){const{valueDescriptorMap:e}=this;return Object.keys(e).map((t=>e[t]))}get valueDescriptorNameMap(){const e={};Object.keys(this.valueDescriptorMap).forEach((t=>{const r=this.valueDescriptorMap[t];e[r.name]=r}));return e}hasValue(e){const t=this.valueDescriptorNameMap[e];const r=`has${capitalize(t.name)}`;return this.receiver[r]}}class TargetObserver{constructor(e,t){this.context=e;this.delegate=t;this.targetsByName=new Multimap}start(){if(!this.tokenListObserver){this.tokenListObserver=new TokenListObserver(this.element,this.attributeName,this);this.tokenListObserver.start()}}stop(){if(this.tokenListObserver){this.disconnectAllTargets();this.tokenListObserver.stop();delete this.tokenListObserver}}tokenMatched({element:e,content:t}){this.scope.containsElement(e)&&this.connectTarget(e,t)}tokenUnmatched({element:e,content:t}){this.disconnectTarget(e,t)}connectTarget(e,t){var r;if(!this.targetsByName.has(t,e)){this.targetsByName.add(t,e);null===(r=this.tokenListObserver)||void 0===r?void 0:r.pause((()=>this.delegate.targetConnected(e,t)))}}disconnectTarget(e,t){var r;if(this.targetsByName.has(t,e)){this.targetsByName.delete(t,e);null===(r=this.tokenListObserver)||void 0===r?void 0:r.pause((()=>this.delegate.targetDisconnected(e,t)))}}disconnectAllTargets(){for(const e of this.targetsByName.keys)for(const t of this.targetsByName.getValuesForKey(e))this.disconnectTarget(t,e)}get attributeName(){return`data-${this.context.identifier}-target`}get element(){return this.context.element}get scope(){return this.context.scope}}function readInheritableStaticArrayValues(e,t){const r=getAncestorsForConstructor(e);return Array.from(r.reduce(((e,r)=>{getOwnStaticArrayValues(r,t).forEach((t=>e.add(t)));return e}),new Set))}function readInheritableStaticObjectPairs(e,t){const r=getAncestorsForConstructor(e);return r.reduce(((e,r)=>{e.push(...getOwnStaticObjectPairs(r,t));return e}),[])}function getAncestorsForConstructor(e){const t=[];while(e){t.push(e);e=Object.getPrototypeOf(e)}return t.reverse()}function getOwnStaticArrayValues(e,t){const r=e[t];return Array.isArray(r)?r:[]}function getOwnStaticObjectPairs(e,t){const r=e[t];return r?Object.keys(r).map((e=>[e,r[e]])):[]}class OutletObserver{constructor(e,t){this.started=false;this.context=e;this.delegate=t;this.outletsByName=new Multimap;this.outletElementsByName=new Multimap;this.selectorObserverMap=new Map;this.attributeObserverMap=new Map}start(){if(!this.started){this.outletDefinitions.forEach((e=>{this.setupSelectorObserverForOutlet(e);this.setupAttributeObserverForOutlet(e)}));this.started=true;this.dependentContexts.forEach((e=>e.refresh()))}}refresh(){this.selectorObserverMap.forEach((e=>e.refresh()));this.attributeObserverMap.forEach((e=>e.refresh()))}stop(){if(this.started){this.started=false;this.disconnectAllOutlets();this.stopSelectorObservers();this.stopAttributeObservers()}}stopSelectorObservers(){if(this.selectorObserverMap.size>0){this.selectorObserverMap.forEach((e=>e.stop()));this.selectorObserverMap.clear()}}stopAttributeObservers(){if(this.attributeObserverMap.size>0){this.attributeObserverMap.forEach((e=>e.stop()));this.attributeObserverMap.clear()}}selectorMatched(e,t,{outletName:r}){const s=this.getOutlet(e,r);s&&this.connectOutlet(s,e,r)}selectorUnmatched(e,t,{outletName:r}){const s=this.getOutletFromMap(e,r);s&&this.disconnectOutlet(s,e,r)}selectorMatchElement(e,{outletName:t}){const r=this.selector(t);const s=this.hasOutlet(e,t);const n=e.matches(`[${this.schema.controllerAttribute}~=${t}]`);return!!r&&(s&&n&&e.matches(r))}elementMatchedAttribute(e,t){const r=this.getOutletNameFromOutletAttributeName(t);r&&this.updateSelectorObserverForOutlet(r)}elementAttributeValueChanged(e,t){const r=this.getOutletNameFromOutletAttributeName(t);r&&this.updateSelectorObserverForOutlet(r)}elementUnmatchedAttribute(e,t){const r=this.getOutletNameFromOutletAttributeName(t);r&&this.updateSelectorObserverForOutlet(r)}connectOutlet(e,t,r){var s;if(!this.outletElementsByName.has(r,t)){this.outletsByName.add(r,e);this.outletElementsByName.add(r,t);null===(s=this.selectorObserverMap.get(r))||void 0===s?void 0:s.pause((()=>this.delegate.outletConnected(e,t,r)))}}disconnectOutlet(e,t,r){var s;if(this.outletElementsByName.has(r,t)){this.outletsByName.delete(r,e);this.outletElementsByName.delete(r,t);null===(s=this.selectorObserverMap.get(r))||void 0===s?void 0:s.pause((()=>this.delegate.outletDisconnected(e,t,r)))}}disconnectAllOutlets(){for(const e of this.outletElementsByName.keys)for(const t of this.outletElementsByName.getValuesForKey(e))for(const r of this.outletsByName.getValuesForKey(e))this.disconnectOutlet(r,t,e)}updateSelectorObserverForOutlet(e){const t=this.selectorObserverMap.get(e);t&&(t.selector=this.selector(e))}setupSelectorObserverForOutlet(e){const t=this.selector(e);const r=new SelectorObserver(document.body,t,this,{outletName:e});this.selectorObserverMap.set(e,r);r.start()}setupAttributeObserverForOutlet(e){const t=this.attributeNameForOutletName(e);const r=new AttributeObserver(this.scope.element,t,this);this.attributeObserverMap.set(e,r);r.start()}selector(e){return this.scope.outlets.getSelectorForOutletName(e)}attributeNameForOutletName(e){return this.scope.schema.outletAttributeForScope(this.identifier,e)}getOutletNameFromOutletAttributeName(e){return this.outletDefinitions.find((t=>this.attributeNameForOutletName(t)===e))}get outletDependencies(){const e=new Multimap;this.router.modules.forEach((t=>{const r=t.definition.controllerConstructor;const s=readInheritableStaticArrayValues(r,"outlets");s.forEach((r=>e.add(r,t.identifier)))}));return e}get outletDefinitions(){return this.outletDependencies.getKeysForValue(this.identifier)}get dependentControllerIdentifiers(){return this.outletDependencies.getValuesForKey(this.identifier)}get dependentContexts(){const e=this.dependentControllerIdentifiers;return this.router.contexts.filter((t=>e.includes(t.identifier)))}hasOutlet(e,t){return!!this.getOutlet(e,t)||!!this.getOutletFromMap(e,t)}getOutlet(e,t){return this.application.getControllerForElementAndIdentifier(e,t)}getOutletFromMap(e,t){return this.outletsByName.getValuesForKey(t).find((t=>t.element===e))}get scope(){return this.context.scope}get schema(){return this.context.schema}get identifier(){return this.context.identifier}get application(){return this.context.application}get router(){return this.application.router}}class Context{constructor(e,t){this.logDebugActivity=(e,t={})=>{const{identifier:r,controller:s,element:n}=this;t=Object.assign({identifier:r,controller:s,element:n},t);this.application.logDebugActivity(this.identifier,e,t)};this.module=e;this.scope=t;this.controller=new e.controllerConstructor(this);this.bindingObserver=new BindingObserver(this,this.dispatcher);this.valueObserver=new ValueObserver(this,this.controller);this.targetObserver=new TargetObserver(this,this);this.outletObserver=new OutletObserver(this,this);try{this.controller.initialize();this.logDebugActivity("initialize")}catch(e){this.handleError(e,"initializing controller")}}connect(){this.bindingObserver.start();this.valueObserver.start();this.targetObserver.start();this.outletObserver.start();try{this.controller.connect();this.logDebugActivity("connect")}catch(e){this.handleError(e,"connecting controller")}}refresh(){this.outletObserver.refresh()}disconnect(){try{this.controller.disconnect();this.logDebugActivity("disconnect")}catch(e){this.handleError(e,"disconnecting controller")}this.outletObserver.stop();this.targetObserver.stop();this.valueObserver.stop();this.bindingObserver.stop()}get application(){return this.module.application}get identifier(){return this.module.identifier}get schema(){return this.application.schema}get dispatcher(){return this.application.dispatcher}get element(){return this.scope.element}get parentElement(){return this.element.parentElement}handleError(e,t,r={}){const{identifier:s,controller:n,element:i}=this;r=Object.assign({identifier:s,controller:n,element:i},r);this.application.handleError(e,`Error ${t}`,r)}targetConnected(e,t){this.invokeControllerMethod(`${t}TargetConnected`,e)}targetDisconnected(e,t){this.invokeControllerMethod(`${t}TargetDisconnected`,e)}outletConnected(e,t,r){this.invokeControllerMethod(`${namespaceCamelize(r)}OutletConnected`,e,t)}outletDisconnected(e,t,r){this.invokeControllerMethod(`${namespaceCamelize(r)}OutletDisconnected`,e,t)}invokeControllerMethod(e,...t){const r=this.controller;"function"==typeof r[e]&&r[e](...t)}}function bless(e){return shadow(e,getBlessedProperties(e))}function shadow(e,t){const r=i(e);const s=getShadowProperties(e.prototype,t);Object.defineProperties(r.prototype,s);return r}function getBlessedProperties(e){const t=readInheritableStaticArrayValues(e,"blessings");return t.reduce(((t,r)=>{const s=r(e);for(const e in s){const r=t[e]||{};t[e]=Object.assign(r,s[e])}return t}),{})}function getShadowProperties(e,t){return n(t).reduce(((r,s)=>{const n=getShadowedDescriptor(e,t,s);n&&Object.assign(r,{[s]:n});return r}),{})}function getShadowedDescriptor(e,t,r){const s=Object.getOwnPropertyDescriptor(e,r);const n=s&&"value"in s;if(!n){const e=Object.getOwnPropertyDescriptor(t,r).value;if(s){e.get=s.get||e.get;e.set=s.set||e.set}return e}}const n=(()=>"function"==typeof Object.getOwnPropertySymbols?e=>[...Object.getOwnPropertyNames(e),...Object.getOwnPropertySymbols(e)]:Object.getOwnPropertyNames)();const i=(()=>{function extendWithReflect(e){function extended(){return Reflect.construct(e,arguments,new.target)}extended.prototype=Object.create(e.prototype,{constructor:{value:extended}});Reflect.setPrototypeOf(extended,e);return extended}function testReflectExtension(){const a=function(){this.a.call(this)};const e=extendWithReflect(a);e.prototype.a=function(){};return new e}try{testReflectExtension();return extendWithReflect}catch(e){return e=>class extended extends e{}}})();function blessDefinition(e){return{identifier:e.identifier,controllerConstructor:bless(e.controllerConstructor)}}class Module{constructor(e,t){this.application=e;this.definition=blessDefinition(t);this.contextsByScope=new WeakMap;this.connectedContexts=new Set}get identifier(){return this.definition.identifier}get controllerConstructor(){return this.definition.controllerConstructor}get contexts(){return Array.from(this.connectedContexts)}connectContextForScope(e){const t=this.fetchContextForScope(e);this.connectedContexts.add(t);t.connect()}disconnectContextForScope(e){const t=this.contextsByScope.get(e);if(t){this.connectedContexts.delete(t);t.disconnect()}}fetchContextForScope(e){let t=this.contextsByScope.get(e);if(!t){t=new Context(this,e);this.contextsByScope.set(e,t)}return t}}class ClassMap{constructor(e){this.scope=e}has(e){return this.data.has(this.getDataKey(e))}get(e){return this.getAll(e)[0]}getAll(e){const t=this.data.get(this.getDataKey(e))||"";return tokenize(t)}getAttributeName(e){return this.data.getAttributeNameForKey(this.getDataKey(e))}getDataKey(e){return`${e}-class`}get data(){return this.scope.data}}class DataMap{constructor(e){this.scope=e}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get(e){const t=this.getAttributeNameForKey(e);return this.element.getAttribute(t)}set(e,t){const r=this.getAttributeNameForKey(e);this.element.setAttribute(r,t);return this.get(e)}has(e){const t=this.getAttributeNameForKey(e);return this.element.hasAttribute(t)}delete(e){if(this.has(e)){const t=this.getAttributeNameForKey(e);this.element.removeAttribute(t);return true}return false}getAttributeNameForKey(e){return`data-${this.identifier}-${dasherize(e)}`}}class Guide{constructor(e){this.warnedKeysByObject=new WeakMap;this.logger=e}warn(e,t,r){let s=this.warnedKeysByObject.get(e);if(!s){s=new Set;this.warnedKeysByObject.set(e,s)}if(!s.has(t)){s.add(t);this.logger.warn(r,e)}}}function attributeValueContainsToken(e,t){return`[${e}~="${t}"]`}class TargetSet{constructor(e){this.scope=e}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get schema(){return this.scope.schema}has(e){return null!=this.find(e)}find(...e){return e.reduce(((e,t)=>e||this.findTarget(t)||this.findLegacyTarget(t)),void 0)}findAll(...e){return e.reduce(((e,t)=>[...e,...this.findAllTargets(t),...this.findAllLegacyTargets(t)]),[])}findTarget(e){const t=this.getSelectorForTargetName(e);return this.scope.findElement(t)}findAllTargets(e){const t=this.getSelectorForTargetName(e);return this.scope.findAllElements(t)}getSelectorForTargetName(e){const t=this.schema.targetAttributeForScope(this.identifier);return attributeValueContainsToken(t,e)}findLegacyTarget(e){const t=this.getLegacySelectorForTargetName(e);return this.deprecate(this.scope.findElement(t),e)}findAllLegacyTargets(e){const t=this.getLegacySelectorForTargetName(e);return this.scope.findAllElements(t).map((t=>this.deprecate(t,e)))}getLegacySelectorForTargetName(e){const t=`${this.identifier}.${e}`;return attributeValueContainsToken(this.schema.targetAttribute,t)}deprecate(e,t){if(e){const{identifier:r}=this;const s=this.schema.targetAttribute;const n=this.schema.targetAttributeForScope(r);this.guide.warn(e,`target:${t}`,`Please replace ${s}="${r}.${t}" with ${n}="${t}". The ${s} attribute is deprecated and will be removed in a future version of Stimulus.`)}return e}get guide(){return this.scope.guide}}class OutletSet{constructor(e,t){this.scope=e;this.controllerElement=t}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get schema(){return this.scope.schema}has(e){return null!=this.find(e)}find(...e){return e.reduce(((e,t)=>e||this.findOutlet(t)),void 0)}findAll(...e){return e.reduce(((e,t)=>[...e,...this.findAllOutlets(t)]),[])}getSelectorForOutletName(e){const t=this.schema.outletAttributeForScope(this.identifier,e);return this.controllerElement.getAttribute(t)}findOutlet(e){const t=this.getSelectorForOutletName(e);if(t)return this.findElement(t,e)}findAllOutlets(e){const t=this.getSelectorForOutletName(e);return t?this.findAllElements(t,e):[]}findElement(e,t){const r=this.scope.queryElements(e);return r.filter((r=>this.matchesElement(r,e,t)))[0]}findAllElements(e,t){const r=this.scope.queryElements(e);return r.filter((r=>this.matchesElement(r,e,t)))}matchesElement(e,t,r){const s=e.getAttribute(this.scope.schema.controllerAttribute)||"";return e.matches(t)&&s.split(" ").includes(r)}}class Scope{constructor(e,t,r,s){this.targets=new TargetSet(this);this.classes=new ClassMap(this);this.data=new DataMap(this);this.containsElement=e=>e.closest(this.controllerSelector)===this.element;this.schema=e;this.element=t;this.identifier=r;this.guide=new Guide(s);this.outlets=new OutletSet(this.documentScope,t)}findElement(e){return this.element.matches(e)?this.element:this.queryElements(e).find(this.containsElement)}findAllElements(e){return[...this.element.matches(e)?[this.element]:[],...this.queryElements(e).filter(this.containsElement)]}queryElements(e){return Array.from(this.element.querySelectorAll(e))}get controllerSelector(){return attributeValueContainsToken(this.schema.controllerAttribute,this.identifier)}get isDocumentScope(){return this.element===document.documentElement}get documentScope(){return this.isDocumentScope?this:new Scope(this.schema,document.documentElement,this.identifier,this.guide.logger)}}class ScopeObserver{constructor(e,t,r){this.element=e;this.schema=t;this.delegate=r;this.valueListObserver=new ValueListObserver(this.element,this.controllerAttribute,this);this.scopesByIdentifierByElement=new WeakMap;this.scopeReferenceCounts=new WeakMap}start(){this.valueListObserver.start()}stop(){this.valueListObserver.stop()}get controllerAttribute(){return this.schema.controllerAttribute}parseValueForToken(e){const{element:t,content:r}=e;return this.parseValueForElementAndIdentifier(t,r)}parseValueForElementAndIdentifier(e,t){const r=this.fetchScopesByIdentifierForElement(e);let s=r.get(t);if(!s){s=this.delegate.createScopeForElementAndIdentifier(e,t);r.set(t,s)}return s}elementMatchedValue(e,t){const r=(this.scopeReferenceCounts.get(t)||0)+1;this.scopeReferenceCounts.set(t,r);1==r&&this.delegate.scopeConnected(t)}elementUnmatchedValue(e,t){const r=this.scopeReferenceCounts.get(t);if(r){this.scopeReferenceCounts.set(t,r-1);1==r&&this.delegate.scopeDisconnected(t)}}fetchScopesByIdentifierForElement(e){let t=this.scopesByIdentifierByElement.get(e);if(!t){t=new Map;this.scopesByIdentifierByElement.set(e,t)}return t}}class Router{constructor(e){this.application=e;this.scopeObserver=new ScopeObserver(this.element,this.schema,this);this.scopesByIdentifier=new Multimap;this.modulesByIdentifier=new Map}get element(){return this.application.element}get schema(){return this.application.schema}get logger(){return this.application.logger}get controllerAttribute(){return this.schema.controllerAttribute}get modules(){return Array.from(this.modulesByIdentifier.values())}get contexts(){return this.modules.reduce(((e,t)=>e.concat(t.contexts)),[])}start(){this.scopeObserver.start()}stop(){this.scopeObserver.stop()}loadDefinition(e){this.unloadIdentifier(e.identifier);const t=new Module(this.application,e);this.connectModule(t);const r=e.controllerConstructor.afterLoad;r&&r.call(e.controllerConstructor,e.identifier,this.application)}unloadIdentifier(e){const t=this.modulesByIdentifier.get(e);t&&this.disconnectModule(t)}getContextForElementAndIdentifier(e,t){const r=this.modulesByIdentifier.get(t);if(r)return r.contexts.find((t=>t.element==e))}proposeToConnectScopeForElementAndIdentifier(e,t){const r=this.scopeObserver.parseValueForElementAndIdentifier(e,t);r?this.scopeObserver.elementMatchedValue(r.element,r):console.error(`Couldn't find or create scope for identifier: "${t}" and element:`,e)}handleError(e,t,r){this.application.handleError(e,t,r)}createScopeForElementAndIdentifier(e,t){return new Scope(this.schema,e,t,this.logger)}scopeConnected(e){this.scopesByIdentifier.add(e.identifier,e);const t=this.modulesByIdentifier.get(e.identifier);t&&t.connectContextForScope(e)}scopeDisconnected(e){this.scopesByIdentifier.delete(e.identifier,e);const t=this.modulesByIdentifier.get(e.identifier);t&&t.disconnectContextForScope(e)}connectModule(e){this.modulesByIdentifier.set(e.identifier,e);const t=this.scopesByIdentifier.getValuesForKey(e.identifier);t.forEach((t=>e.connectContextForScope(t)))}disconnectModule(e){this.modulesByIdentifier.delete(e.identifier);const t=this.scopesByIdentifier.getValuesForKey(e.identifier);t.forEach((t=>e.disconnectContextForScope(t)))}}const o={controllerAttribute:"data-controller",actionAttribute:"data-action",targetAttribute:"data-target",targetAttributeForScope:e=>`data-${e}-target`,outletAttributeForScope:(e,t)=>`data-${e}-${t}-outlet`,keyMappings:Object.assign(Object.assign({enter:"Enter",tab:"Tab",esc:"Escape",space:" ",up:"ArrowUp",down:"ArrowDown",left:"ArrowLeft",right:"ArrowRight",home:"Home",end:"End",page_up:"PageUp",page_down:"PageDown"},objectFromEntries("abcdefghijklmnopqrstuvwxyz".split("").map((e=>[e,e])))),objectFromEntries("0123456789".split("").map((e=>[e,e]))))};function objectFromEntries(e){return e.reduce(((e,[t,r])=>Object.assign(Object.assign({},e),{[t]:r})),{})}class Application{constructor(t=document.documentElement,r=o){this.logger=console;this.debug=false;this.logDebugActivity=(e,t,r={})=>{this.debug&&this.logFormattedMessage(e,t,r)};this.element=t;this.schema=r;this.dispatcher=new Dispatcher(this);this.router=new Router(this);this.actionDescriptorFilters=Object.assign({},e)}static start(e,t){const r=new this(e,t);r.start();return r}async start(){await domReady();this.logDebugActivity("application","starting");this.dispatcher.start();this.router.start();this.logDebugActivity("application","start")}stop(){this.logDebugActivity("application","stopping");this.dispatcher.stop();this.router.stop();this.logDebugActivity("application","stop")}register(e,t){this.load({identifier:e,controllerConstructor:t})}registerActionOption(e,t){this.actionDescriptorFilters[e]=t}load(e,...t){const r=Array.isArray(e)?e:[e,...t];r.forEach((e=>{e.controllerConstructor.shouldLoad&&this.router.loadDefinition(e)}))}unload(e,...t){const r=Array.isArray(e)?e:[e,...t];r.forEach((e=>this.router.unloadIdentifier(e)))}get controllers(){return this.router.contexts.map((e=>e.controller))}getControllerForElementAndIdentifier(e,t){const r=this.router.getContextForElementAndIdentifier(e,t);return r?r.controller:null}handleError(e,t,r){var s;this.logger.error("%s\n\n%o\n\n%o",t,e,r);null===(s=window.onerror)||void 0===s?void 0:s.call(window,t,"",0,0,e)}logFormattedMessage(e,t,r={}){r=Object.assign({application:this},r);this.logger.groupCollapsed(`${e} #${t}`);this.logger.log("details:",Object.assign({},r));this.logger.groupEnd()}}function domReady(){return new Promise((e=>{"loading"==document.readyState?document.addEventListener("DOMContentLoaded",(()=>e())):e()}))}function ClassPropertiesBlessing(e){const t=readInheritableStaticArrayValues(e,"classes");return t.reduce(((e,t)=>Object.assign(e,propertiesForClassDefinition(t))),{})}function propertiesForClassDefinition(e){return{[`${e}Class`]:{get(){const{classes:t}=this;if(t.has(e))return t.get(e);{const r=t.getAttributeName(e);throw new Error(`Missing attribute "${r}"`)}}},[`${e}Classes`]:{get(){return this.classes.getAll(e)}},[`has${capitalize(e)}Class`]:{get(){return this.classes.has(e)}}}}function OutletPropertiesBlessing(e){const t=readInheritableStaticArrayValues(e,"outlets");return t.reduce(((e,t)=>Object.assign(e,propertiesForOutletDefinition(t))),{})}function getOutletController(e,t,r){return e.application.getControllerForElementAndIdentifier(t,r)}function getControllerAndEnsureConnectedScope(e,t,r){let s=getOutletController(e,t,r);if(s)return s;e.application.router.proposeToConnectScopeForElementAndIdentifier(t,r);s=getOutletController(e,t,r);return s||void 0}function propertiesForOutletDefinition(e){const t=namespaceCamelize(e);return{[`${t}Outlet`]:{get(){const t=this.outlets.find(e);const r=this.outlets.getSelectorForOutletName(e);if(t){const r=getControllerAndEnsureConnectedScope(this,t,e);if(r)return r;throw new Error(`The provided outlet element is missing an outlet controller "${e}" instance for host controller "${this.identifier}"`)}throw new Error(`Missing outlet element "${e}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${r}".`)}},[`${t}Outlets`]:{get(){const t=this.outlets.findAll(e);return t.length>0?t.map((t=>{const r=getControllerAndEnsureConnectedScope(this,t,e);if(r)return r;console.warn(`The provided outlet element is missing an outlet controller "${e}" instance for host controller "${this.identifier}"`,t)})).filter((e=>e)):[]}},[`${t}OutletElement`]:{get(){const t=this.outlets.find(e);const r=this.outlets.getSelectorForOutletName(e);if(t)return t;throw new Error(`Missing outlet element "${e}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${r}".`)}},[`${t}OutletElements`]:{get(){return this.outlets.findAll(e)}},[`has${capitalize(t)}Outlet`]:{get(){return this.outlets.has(e)}}}}function TargetPropertiesBlessing(e){const t=readInheritableStaticArrayValues(e,"targets");return t.reduce(((e,t)=>Object.assign(e,propertiesForTargetDefinition(t))),{})}function propertiesForTargetDefinition(e){return{[`${e}Target`]:{get(){const t=this.targets.find(e);if(t)return t;throw new Error(`Missing target element "${e}" for "${this.identifier}" controller`)}},[`${e}Targets`]:{get(){return this.targets.findAll(e)}},[`has${capitalize(e)}Target`]:{get(){return this.targets.has(e)}}}}function ValuePropertiesBlessing(e){const t=readInheritableStaticObjectPairs(e,"values");const r={valueDescriptorMap:{get(){return t.reduce(((e,t)=>{const r=parseValueDefinitionPair(t,this.identifier);const s=this.data.getAttributeNameForKey(r.key);return Object.assign(e,{[s]:r})}),{})}}};return t.reduce(((e,t)=>Object.assign(e,propertiesForValueDefinitionPair(t))),r)}function propertiesForValueDefinitionPair(e,t){const r=parseValueDefinitionPair(e,t);const{key:s,name:n,reader:i,writer:o}=r;return{[n]:{get(){const e=this.data.get(s);return null!==e?i(e):r.defaultValue},set(e){void 0===e?this.data.delete(s):this.data.set(s,o(e))}},[`has${capitalize(n)}`]:{get(){return this.data.has(s)||r.hasCustomDefaultValue}}}}function parseValueDefinitionPair([e,t],r){return valueDescriptorForTokenAndTypeDefinition({controller:r,token:e,typeDefinition:t})}function parseValueTypeConstant(e){switch(e){case Array:return"array";case Boolean:return"boolean";case Number:return"number";case Object:return"object";case String:return"string"}}function parseValueTypeDefault(e){switch(typeof e){case"boolean":return"boolean";case"number":return"number";case"string":return"string"}return Array.isArray(e)?"array":"[object Object]"===Object.prototype.toString.call(e)?"object":void 0}function parseValueTypeObject(e){const{controller:t,token:r,typeObject:s}=e;const n=isSomething(s.type);const i=isSomething(s.default);const o=n&&i;const c=n&&!i;const l=!n&&i;const h=parseValueTypeConstant(s.type);const u=parseValueTypeDefault(e.typeObject.default);if(c)return h;if(l)return u;if(h!==u){const e=t?`${t}.${r}`:r;throw new Error(`The specified default value for the Stimulus Value "${e}" must match the defined type "${h}". The provided default value of "${s.default}" is of type "${u}".`)}return o?h:void 0}function parseValueTypeDefinition(e){const{controller:t,token:r,typeDefinition:s}=e;const n={controller:t,token:r,typeObject:s};const i=parseValueTypeObject(n);const o=parseValueTypeDefault(s);const c=parseValueTypeConstant(s);const l=i||o||c;if(l)return l;const h=t?`${t}.${s}`:r;throw new Error(`Unknown value type "${h}" for "${r}" value`)}function defaultValueForDefinition(e){const t=parseValueTypeConstant(e);if(t)return c[t];const r=hasProperty(e,"default");const s=hasProperty(e,"type");const n=e;if(r)return n.default;if(s){const{type:e}=n;const t=parseValueTypeConstant(e);if(t)return c[t]}return e}function valueDescriptorForTokenAndTypeDefinition(e){const{token:t,typeDefinition:r}=e;const s=`${dasherize(t)}-value`;const n=parseValueTypeDefinition(e);return{type:n,key:s,name:camelize(s),get defaultValue(){return defaultValueForDefinition(r)},get hasCustomDefaultValue(){return void 0!==parseValueTypeDefault(r)},reader:l[n],writer:h[n]||h.default}}const c={get array(){return[]},boolean:false,number:0,get object(){return{}},string:""};const l={array(e){const t=JSON.parse(e);if(!Array.isArray(t))throw new TypeError(`expected value of type "array" but instead got value "${e}" of type "${parseValueTypeDefault(t)}"`);return t},boolean(e){return!("0"==e||"false"==String(e).toLowerCase())},number(e){return Number(e.replace(/_/g,""))},object(e){const t=JSON.parse(e);if(null===t||"object"!=typeof t||Array.isArray(t))throw new TypeError(`expected value of type "object" but instead got value "${e}" of type "${parseValueTypeDefault(t)}"`);return t},string(e){return e}};const h={default:writeString,array:writeJSON,object:writeJSON};function writeJSON(e){return JSON.stringify(e)}function writeString(e){return`${e}`}class Controller{constructor(e){this.context=e}static get shouldLoad(){return true}static afterLoad(e,t){}get application(){return this.context.application}get scope(){return this.context.scope}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get targets(){return this.scope.targets}get outlets(){return this.scope.outlets}get classes(){return this.scope.classes}get data(){return this.scope.data}initialize(){}connect(){}disconnect(){}dispatch(e,{target:t=this.element,detail:r={},prefix:s=this.identifier,bubbles:n=true,cancelable:i=true}={}){const o=s?`${s}:${e}`:e;const c=new CustomEvent(o,{detail:r,bubbles:n,cancelable:i});t.dispatchEvent(c);return c}}Controller.blessings=[ClassPropertiesBlessing,TargetPropertiesBlessing,ValuePropertiesBlessing,OutletPropertiesBlessing];Controller.targets=[];Controller.outlets=[];Controller.values={};export{Application,AttributeObserver,Context,Controller,ElementObserver,IndexedMultimap,Multimap,SelectorObserver,StringMapObserver,TokenListObserver,ValueListObserver,add,o as defaultSchema,del,fetch,prune}; +