diff --git a/Gemfile b/Gemfile index dde12fc..d79854d 100644 --- a/Gemfile +++ b/Gemfile @@ -7,3 +7,4 @@ gemspec gem "rake" gem "rspec" +gem "debug" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 26659a0..f740f16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,12 +2,47 @@ PATH remote: . specs: SOF-cycle (0.1.0) + activesupport (>= 6.0) + forwardable GEM remote: https://rubygems.org/ specs: + activesupport (7.1.3.4) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + base64 (0.2.0) + bigdecimal (3.1.8) + concurrent-ruby (1.3.3) + connection_pool (2.4.1) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) diff-lcs (1.5.1) + drb (2.2.1) + forwardable (1.3.3) + i18n (1.14.5) + concurrent-ruby (~> 1.0) + io-console (0.7.2) + irb (1.14.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + minitest (5.24.1) + mutex_m (0.2.0) + psych (5.1.2) + stringio rake (13.2.1) + rdoc (6.7.0) + psych (>= 4.0.0) + reline (0.5.9) + io-console (~> 0.5) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -21,6 +56,9 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.1) + stringio (3.1.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) PLATFORMS arm64-darwin-23 @@ -28,6 +66,7 @@ PLATFORMS DEPENDENCIES SOF-cycle! + debug rake rspec diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 1e5f910..97f4a8c 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -1,10 +1,353 @@ # frozen_string_literal: true require_relative "cycle/version" +require "forwardable" +require_relative "cycle/parser" +require_relative "cycle/time_span" +require "active_support/core_ext/date/conversions" +require "active_support/core_ext/string/filters" module SOF - module Cycle - class Error < StandardError; end - # Your code goes here... + class Cycle + extend Forwardable + class InvalidInput < StandardError; end + + class InvalidPeriod < InvalidInput; end + + class InvalidKind < InvalidInput; end + + def initialize(notation, parser: Parser.new(notation)) + @notation = notation + @parser = parser + validate_period + + return if @parser.valid? + + raise InvalidInput, "'#{notation}' is not a valid input" + end + + attr_reader :parser + + delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period, + :humanized_period, :period_key, :"active?"] => :@parser + delegate [:kind, :"volume_only?", :valid_periods] => "self.class" + delegate [:period_count, :duration] => :time_span + + # Turn a cycle or notation string into a hash + def self.dump(cycle_or_string) + if cycle_or_string.is_a? Cycle + cycle_or_string + else + Cycle.for(cycle_or_string) + end.to_h + end + + # Return a Cycle object from a hash + def self.load(hash) + symbolized_hash = hash.symbolize_keys + cycle_class = class_for_kind(symbolized_hash[:kind]) + + unless cycle_class.valid_periods.empty? + cycle_class.validate_period( + TimeSpan.notation_id_from_name(symbolized_hash[:period]) + ) + end + + Cycle.for notation(symbolized_hash) + rescue TimeSpan::InvalidPeriod => exc + raise InvalidPeriod, exc.message + end + + # Retun a notation string from a hash + # + # @param hash [Hash] hash of data for a valid Cycle + # @return [String] string representation of a Cycle + def self.notation(hash) + volume_notation = "V#{hash.fetch(:volume) { 1 }}" + return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only + + cycle_class = class_for_kind(hash[:kind].to_sym) + [ + volume_notation, + cycle_class.notation_id, + TimeSpan.notation(hash.slice(:period, :period_count)), + hash.fetch(:from, nil) + ].compact.join + end + + # Return a Cycle object from a notation string + # + # @param notation [String] a string notation representing a Cycle + # @example + # Cycle.for('V2C1Y) + # @return [Cycle] a Cycle object representing the provide string notation + def self.for(notation) + return notation if notation.is_a? Cycle + return notation if notation.is_a? Cycle::Dormant + parser = Parser.new(notation) + unless parser.valid? + raise InvalidInput, "'#{notation}' is not a valid input" + end + + cycle = cycle_handlers.find do |klass| + parser.parses?(klass.notation_id) + end.new(notation, parser:) + return cycle if parser.active? + + Cycle::Dormant.new(cycle, parser:) + end + + # Return the appropriate class for the give notation id + # + # @param notation [String] notation id matching the kind of Cycle class + # @example + # class_for_notation_id('L') + # + def self.class_for_notation_id(notation_id) + cycle_handlers.find do |klass| + klass.notation_id == notation_id + end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}") + end + + # Return the class handling the kind + # + # @param sym [Symbol] symbol matching the kind of Cycle class + # @example + # class_for_kind(:lookback) + def self.class_for_kind(sym) + Cycle.cycle_handlers.find do |klass| + klass.handles?(sym) + end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle") + end + + def self.cycle_handlers = @cycle_handlers ||= Set.new + + def self.inherited(klass) = cycle_handlers << klass + + def self.handles?(sym) + sym && kind == sym.to_sym + end + + @volume_only = false + @notation_id = nil + @kind = nil + @valid_periods = [] + + def self.volume_only? = @volume_only + + class << self + attr_reader :notation_id, :kind, :valid_periods + end + + # Raises an error if the given period isn't in the list of valid periods. + # + # @param period [String] period matching the class valid periods + # @raise [InvalidPeriod] + def self.validate_period(period) + raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period) + Invalid period value of '#{period}' provided. Valid periods are: + #{valid_periods.join(", ")} + ERR + end + + def validate_period + return if valid_periods.empty? + + self.class.validate_period(period_key) + end + + # Return the cycle representation as a notation string + def notation = self.class.notation(to_h) + + # Cycles are considered equal if their hash representations are equal + def ==(other) = to_h == other.to_h + + # From the supplied anchor date, are there enough in-window completions to + # satisfy the cycle? + # + # @return [Boolean] true if the cycle is satisfied, false otherwise + def satisfied_by?(completion_dates, anchor: Date.current) + covered_dates(completion_dates, anchor:).size >= volume + end + + def covered_dates(dates, anchor: Date.current) + dates.select do |date| + cover?(date, anchor:) + end + end + + def cover?(date, anchor: Date.current) + range(anchor).cover?(date) + end + + def range(anchor) = start_date(anchor)..final_date(anchor) + + def humanized_span = [period_count, humanized_period].join(" ") + + # Return the final date of the cycle + def final_date(_anchor) = nil + + def expiration_of(_completion_dates, anchor: Date.current) = nil + + def volume_to_delay_expiration(_completion_dates, anchor:) = 0 + + def to_h + { + kind:, + volume:, + period:, + period_count:, + **from_data + } + end + + def from_data + return {} unless from + + {from: from} + end + + def as_json(...) = notation + + class Dormant + def initialize(cycle, parser:) + @cycle = cycle + @parser = parser + end + + attr_reader :cycle, :parser + + def to_s + cycle.to_s + " (dormant)" + end + + def covered_dates(...) = [] + + def expiration_of(...) = nil + + def satisfied_by?(...) = false + + def cover?(...) = false + + def method_missing(method, ...) = cycle.send(method, ...) + + def respond_to_missing?(method, include_private = false) + cycle.respond_to?(method, include_private) + end + end + + class Within < self + @volume_only = false + @notation_id = "W" + @kind = :within + @valid_periods = %w[D W M Y] + + def to_s = "#{volume}x within #{date_range}" + + def date_range + return humanized_span unless active? + + [start_date, final_date].map { _1.to_fs(:american) }.join(" - ") + end + + def final_date(_ = nil) = time_span.end_date(start_date) + + def start_date(_ = nil) = from_date.to_date + end + + class VolumeOnly < self + @volume_only = true + @notation_id = nil + @kind = :volume_only + @valid_periods = [] + + class << self + def handles?(sym) = sym.nil? || super + + def validate_period(period) + raise InvalidPeriod, <<~ERR.squish unless period.nil? + Invalid period value of '#{period}' provided. Valid periods are: + #{valid_periods.join(", ")} + ERR + end + end + + def to_s = "#{volume}x total" + + def covered_dates(dates, ...) = dates + + def cover?(...) = true + end + + class Lookback < self + @volume_only = false + @notation_id = "L" + @kind = :lookback + @valid_periods = %w[D W M Y] + + def to_s = "#{volume}x in the prior #{period_count} #{humanized_period}" + + def volume_to_delay_expiration(completion_dates, anchor:) + oldest_relevant_completion = completion_dates.min + [completion_dates.count(oldest_relevant_completion), volume].min + end + + # "Absent further completions, you go red on this date" + # @return [Date, nil] the date on which the cycle will expire given the + # provided completion dates. Returns nil if the cycle is already unsatisfied. + def expiration_of(completion_dates) + anchor = completion_dates.max_by(volume) { _1 }.min + return unless satisfied_by?(completion_dates, anchor:) + + window_end anchor + end + + def final_date(anchor) + return if anchor.nil? + + time_span.end_date(anchor.to_date) + end + alias_method :window_end, :final_date + + def start_date(anchor) + time_span.begin_date(anchor.to_date) + end + alias_method :window_start, :start_date + end + + class Calendar < self + @volume_only = false + @notation_id = "C" + @kind = :calendar + @valid_periods = %w[M Q Y] + + class << self + def frame_of_reference = "total" + end + + def to_s + "#{volume}x every #{period_count} calendar #{humanized_period}" + end + + # "Absent further completions, you go red on this date" + # @return [Date, nil] the date on which the cycle will expire given the + # provided completion dates. Returns nil if the cycle is already unsatisfied. + def expiration_of(completion_dates) + anchor = completion_dates.max_by(volume) { _1 }.min + return unless satisfied_by?(completion_dates, anchor:) + + window_end(anchor) + duration + end + + def final_date(anchor) + return if anchor.nil? + time_span.end_date_of_period(anchor.to_date) + end + alias_method :window_end, :final_date + + def start_date(anchor) + time_span.begin_date_of_period(anchor.to_date) + end + end end end diff --git a/lib/sof/cycle/parser.rb b/lib/sof/cycle/parser.rb new file mode 100644 index 0000000..72f68d4 --- /dev/null +++ b/lib/sof/cycle/parser.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "../cycle" +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/object/inclusion" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/isolated_execution_state" + +module SOF + # This class is not intended to be referenced directly. + # This is an internal implementation of Cycle behavior. + class Cycle::Parser + extend Forwardable + PARTS_REGEX = / + ^(?V(?\d*))? # optional volume + (?(?L|C|W) # kind + (?\d+) # period count + (?D|W|M|Q|Y)?)? # period_key + (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from + /ix + + def self.dormant_capable_kinds = %w[W] + + def self.for(str_or_notation) + return str_or_notation if str_or_notation.is_a? self + + new(str_or_notation) + end + + def self.load(hash) + hash.symbolize_keys! + hash.reverse_merge!(volume: 1) + keys = %i[volume kind period_count period_key] + str = "V#{hash.values_at(*keys).join}" + return new(str) unless hash[:from_date] + + new([str, "F#{hash[:from_date]}"].join) + end + + def initialize(notation) + @notation = notation&.upcase + @match = @notation&.match(PARTS_REGEX) + end + + attr_reader :match, :notation + + delegate [:dormant_capable_kinds] => "self.class" + delegate [:period, :humanized_period] => :time_span + + # Return a TimeSpan object for the period and period_count + def time_span + @time_span ||= Cycle::TimeSpan.for(period_count, period_key) + end + + def valid? = match.present? + + def inspect = notation + alias_method :to_s, :inspect + + def activated_notation(date) + return notation unless dormant_capable? + + self.class.load(to_h.merge(from_date: date.to_date)).notation + end + + def ==(other) = other.to_h == to_h + + def to_h + { + volume:, + kind:, + period_count:, + period_key:, + from_date: + } + end + + def parses?(notation_id) = kind == notation_id + + def active? = !dormant? + + def dormant? = dormant_capable? && from_date.nil? + + def dormant_capable? = kind.in?(dormant_capable_kinds) + + def period_count = match[:period_count] + + def period_key = match[:period_key] + + def vol = match[:vol] || "V1" + + def volume = (match[:volume] || 1).to_i + + def from_data + return {} unless from + + {from: from} + end + + def from_date = match[:from_date] + + def from = match[:from] + + def kind = match[:kind] + end +end \ No newline at end of file diff --git a/lib/sof/cycle/time_span.rb b/lib/sof/cycle/time_span.rb new file mode 100644 index 0000000..b48975d --- /dev/null +++ b/lib/sof/cycle/time_span.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require_relative "../cycle" +require "active_support/deprecator" +require "active_support/deprecation" +require "active_support/core_ext/numeric/time" +require "active_support/core_ext/integer/time" +require "active_support/core_ext/string/conversions" +module SOF + # This class is not intended to be referenced directly. + # This is an internal implementation of Cycle behavior. + class Cycle::TimeSpan + extend Forwardable + # TimeSpan objects map Cycle notations to behaviors for their periods + # + # For example: + # 'M' => TimeSpan::DatePeriod::Month + # 'Y' => TimeSpan::DatePeriod::Year + # Read each DatePeriod subclass for more information. + # + class InvalidPeriod < StandardError; end + + class << self + # Return a time_span for the given count and period + def for(count, period) + case count.to_i + when 0 + TimeSpanNothing + when 1 + TimeSpanOne + else + self + end.new(count, period) + end + + # Return a notation string from a hash + def notation(hash) + return unless hash.key?(:period) + + [ + hash.fetch(:period_count) { 1 }, + notation_id_from_name(hash[:period]) + ].compact.join + end + + # Return the notation character for the given period name + def notation_id_from_name(name) + type = DatePeriod.types.find do |klass| + klass.period.to_s == name.to_s + end + + raise InvalidPeriod, "'#{name}' is not a valid period" unless type + + type.code + end + end + + # Class used to calculate the windows of time so that + # a TimeSpan object will know the correct end of year, + # quarter, etc. + class DatePeriod + extend Forwardable + class << self + def for(count, period_notation) + @cached_periods ||= {} + @cached_periods[period_notation] ||= {} + @cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count) + @cached_periods[period_notation][count] + end + + def for_notation(notation) + types.find do |klass| + klass.code == notation.to_s.upcase + end + end + + def types = @types ||= Set.new + + def inherited(klass) + DatePeriod.types << klass + end + + @period = nil + @code = nil + @interval = nil + attr_reader :period, :code, :interval + end + + delegate [:period, :code, :interval] => "self.class" + + def initialize(count) + @count = count + end + attr_reader :count + + def end_date(date) + @end_date ||= {} + @end_date[date] ||= date + duration + end + + def begin_date(date) + @begin_date ||= {} + @begin_date[date] ||= date - duration + end + + def duration = count.send(period) + + def end_of_period(_) = nil + + def humanized_period + return period if count == 1 + + "#{period}s" + end + + class Year < self + @period = :year + @code = "Y" + @interval = "years" + + def end_of_period(date) + date.end_of_year + end + + def beginning_of_period(date) + date.beginning_of_year + end + end + + class Quarter < self + @period = :quarter + @code = "Q" + @interval = "quarters" + + def duration + (count * 3).months + end + + def end_of_period(date) + date.end_of_quarter + end + + def beginning_of_period(date) + date.beginning_of_quarter + end + end + + class Month < self + @period = :month + @code = "M" + @interval = "months" + + def end_of_period(date) + date.end_of_month + end + + def beginning_of_period(date) + date.beginning_of_month + end + end + + class Week < self + @period = :week + @code = "W" + @interval = "weeks" + + def end_of_period(date) + date.end_of_week + end + + def beginning_of_period(date) + date.beginning_of_week + end + end + + class Day < self + @period = :day + @code = "D" + @interval = "days" + + def end_of_period(date) + date + end + + def beginning_of_period(date) + date + end + end + end + private_constant :DatePeriod + + def initialize(count, period_id) + @count = Integer(count, exception: false) + @window = DatePeriod.for(period_count, period_id) + end + attr_reader :window + + delegate [:end_date, :begin_date] => :window + + def end_date_of_period(date) + window.end_of_period(date) + end + + def begin_date_of_period(date) + window.beginning_of_period(date) + end + + # Integer value for the period count or nil + def period_count + @count + end + + delegate [:period, :duration, :interval, :humanized_period] => :window + + # Return a date according to the rules of the time_span + def final_date(date) + return unless period + + window.end_date(date.to_date) + end + + def to_h + { + period:, + period_count: + } + end + + class TimeSpanNothing < self + end + + class TimeSpanOne < self + def interval = humanized_period + end + end +end \ No newline at end of file diff --git a/lib/sof/cycle/version.rb b/lib/sof/cycle/version.rb index abc7c46..322a245 100644 --- a/lib/sof/cycle/version.rb +++ b/lib/sof/cycle/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module SOF - module Cycle + class Cycle VERSION = "0.1.0" end end diff --git a/sof-cycle.gemspec b/sof-cycle.gemspec index 99c9630..ed260fd 100644 --- a/sof-cycle.gemspec +++ b/sof-cycle.gemspec @@ -28,9 +28,6 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html + spec.add_dependency "forwardable" + spec.add_dependency "activesupport", ">= 6.0" end diff --git a/spec/calendar_spec.rb b/spec/calendar_spec.rb new file mode 100644 index 0000000..301bfeb --- /dev/null +++ b/spec/calendar_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::Calendar, type: :value do + let(:cycle) { Cycle.for(notation) } + let(:notation) { "V2C1Y" } + let(:completed_dates) do + [ + recent_date, + middle_date, + early_date, + early_date, + out_of_window_date + ] + end + let(:recent_date) { anchor - 1.days } + let(:middle_date) { anchor - 70.days } + let(:early_date) { anchor - 150.days } + let(:out_of_window_date) { anchor - 11.months } + let(:anchor) { "2020-08-01".to_date } + + describe "#covered_dates" do + it "given an anchor date, returns dates that fall within it's window" do + expect(cycle.covered_dates(completed_dates, anchor:)).to eq([ + recent_date, + middle_date, + early_date, + early_date + ]) + end + end + + describe "#satisfied_by?(completed_dates, anchor:)" do + context "when the completions--judged from the anchor--satisfy the cycle" do + it "returns true" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq true + end + end + + context "when the completions are irrelevant to the given anchor" do + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor: Date.current)).to eq false + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + + context "when there are no completions" do + let(:completed_dates) { [] } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + end + + describe "#expiration_of(completion_dates)" do + context "when the completions currently satisfy the cycle" do + it "returns the end of the _next_ calendar period" do + expect(cycle.expiration_of(completed_dates)).to eq( + (recent_date + 1.year).end_of_year + ) + end + end + + context "when the period is months" do + let(:notation) { "V1C1M" } + let(:completed_dates) { ["2020-01-15".to_date] } + let(:anchor) { "2020-01-16".to_date } + + it "returns the end of the _next_ calendar period" do + expect(cycle.expiration_of(completed_dates)).to eq( + "2020-02-29".to_date + ) + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns nil" do + expect(cycle.expiration_of(completed_dates)).to be_nil + end + end + end + end +end \ No newline at end of file diff --git a/spec/cycle_spec.rb b/spec/cycle_spec.rb new file mode 100644 index 0000000..6da10f1 --- /dev/null +++ b/spec/cycle_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle, type: :value do + describe ".notation" do + it "returns a string notation for the given hash" do + expect(described_class.notation(volume: 3, kind: :lookback, period: :day, period_count: 3)).to eq("V3L3D") + end + + it "assumes a volume of 1 when it is not provided" do + expect(described_class.notation(kind: :calendar, period: :month, period_count: 2)).to eq("V1C2M") + end + + it "returns volume notation only, if there is no kind" do + expect(described_class.notation(volume: 7, period: :month, period_count: 2)).to eq("V7") + end + end + + describe ".load" do + it "returns a Cycle object from a given hash" do + data = {kind: :calendar, period: :month, period_count: 2} + expect(described_class.load(data)).to be_a(Cycle) + end + + it "allows mixed key types" do + data = {"kind" => :calendar, :period => :month, "period_count" => 2} + expect(described_class.load(data)).to be_a(Cycle) + end + + it "raises an error when given an invalid period for the kind" do + data = {"kind" => :calendar, :period => :xyz, "period_count" => 2} + expect { described_class.load(data) }.to raise_error(Cycle::InvalidPeriod) + end + + it "raises an error when given an invalid kind" do + data = {"kind" => :wtf} + expect { described_class.load(data) }.to raise_error(Cycle::InvalidKind) + end + end + + describe ".dump" do + it "generates a hash from a Cycle object" do + cycle = Cycle.for("V5C2M") + expect(described_class.dump(cycle)).to eq({volume: 5, kind: :calendar, period_count: 2, period: :month}) + end + + it "generates a hash from a notation" do + expect(described_class.dump("V5C2M")).to eq({volume: 5, kind: :calendar, period_count: 2, period: :month}) + end + end + + describe ".for" do + it "returns a Cycle object matching the notation" do + aggregate_failures do + expect(Cycle.for("V1")).to eq(Cycle.load({volume: 1})) + expect(Cycle.for("V1C1Y")).to eq(Cycle.load({volume: 1, kind: :calendar, period_count: 1, period: :year})) + end + end + + it "raises an error with invalid kind and period combinations" do + aggregate_failures do + expect { Cycle.for("L1Q") }.to raise_error(Cycle::InvalidPeriod, /Invalid period value of 'Q' provided. Valid periods are: D, W, M, Y/) + expect { Cycle.for("C1W") }.to raise_error(Cycle::InvalidPeriod, /Invalid period value of 'W' provided. Valid periods are: M, Q, Y/) + end + end + + it "returns the argument if it is already a Cycle" do + cycle = Cycle.for("V1") + expect(Cycle.for(cycle)).to eq(cycle) + end + end + + describe "#to_s" do + it "returns a human readable summary" do + aggregate_failures do + { + v1: "1x total", + v3c5y: "3x every 5 calendar years", + v1c1y: "1x every 1 calendar year", + v3l1y: "3x in the prior 1 year", + v2w6m: "2x within 6 months (dormant)", + v2l6m: "2x in the prior 6 months" + }.each do |notation, summary| + result = Cycle.for(notation.to_s).to_s + expect(result).to eq(summary), + "expected '#{result}' to eq '#{summary}'" + end + end + end + end + + describe "#final_date" do + it "returns a date at the end of a calendar window" do + cycle = described_class.for("C1Y") + expect(cycle.final_date("1971-01-01")).to eq(Date.parse("1971-12-31")) + end + + it "returns a date at the end of a lookback window" do + cycle = described_class.for("L1M") + expect(cycle.final_date("2003-03-08")).to eq(Date.parse("2003-04-08")) + end + end + + describe "#as_json" do + it "represents itself with notation" do + expect(described_class.for("v12L34d").as_json).to eq("V12L34D") + end + end + end +end \ No newline at end of file diff --git a/spec/dormant_spec.rb b/spec/dormant_spec.rb new file mode 100644 index 0000000..da475d9 --- /dev/null +++ b/spec/dormant_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::Dormant, type: :value do + let(:cycle) { Cycle.for(notation) } + let(:notation) { "V2W180D" } + let(:anchor) { "2020-08-01".to_date } + let(:completed_dates) do + [ + recent_date, + middle_date, + early_date, + early_date, + out_of_window_date + ] + end + let(:recent_date) { anchor - 1.days } + let(:middle_date) { anchor - 70.days } + let(:early_date) { anchor - 150.days } + let(:out_of_window_date) { anchor - 182.days } + + describe "#activated_notation" do + it "appends the from data to the notation" do + expect(cycle.activated_notation("2024-06-09")).to eq("V2W180DF2024-06-09") + end + + it "appends a Date even when supplied a Time" do + time = "2024-06-09".to_time + expect(cycle.activated_notation(time)).to eq("V2W180DF2024-06-09") + end + end + + describe "#covered_dates" do + it "returns an empty array" do + expect(cycle.covered_dates(completed_dates, anchor:)).to be_empty + end + end + + describe "#satisfied_by?(completed_dates, anchor:)" do + it "always returns false" do + aggregate_failures do + expect(cycle.satisfied_by?(completed_dates, anchor: 5.years.ago)).to eq false + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + expect(cycle.satisfied_by?([], anchor:)).to eq false + expect(cycle.satisfied_by?(completed_dates, anchor: 5.years.from_now)).to eq false + end + end + end + + describe "#expiration_of(completion_dates)" do + it "always returns nil" do + aggregate_failures do + expect(cycle.expiration_of(completed_dates)).to be_nil + expect(cycle.expiration_of([])).to be_nil + end + end + end + + describe "#volume" do + it "returns the volume specified by the notation" do + expect(cycle.volume).to eq(2) + end + end + + describe "#notation" do + it "returns the string representation of itself" do + expect(cycle.notation).to eq(notation) + end + end + end +end \ No newline at end of file diff --git a/spec/lookback_spec.rb b/spec/lookback_spec.rb new file mode 100644 index 0000000..5bd5493 --- /dev/null +++ b/spec/lookback_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::Lookback, type: :value do + let(:cycle) { Cycle.for(notation) } + let(:notation) { "V2L180D" } + let(:anchor) { "2020-08-01".to_date } + let(:completed_dates) do + [ + recent_date, + middle_date, + early_date, + early_date, + out_of_window_date + ] + end + let(:recent_date) { anchor - 1.days } + let(:middle_date) { anchor - 70.days } + let(:early_date) { anchor - 150.days } + let(:out_of_window_date) { anchor - 182.days } + + describe "#covered_dates" do + it "given an anchor date, returns dates that fall within it's window" do + expect(cycle.covered_dates(completed_dates, anchor:)).to eq([ + recent_date, + middle_date, + early_date, + early_date + ]) + end + end + + describe "#satisfied_by?(completed_dates, anchor:)" do + context "when the completions--judged from the anchor--satisfy the cycle" do + it "returns true" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq true + end + end + + context "when the completions are irrelevant to the given anchor" do + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor: Date.current)).to eq false + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + + context "when there are no completions" do + let(:completed_dates) { [] } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + end + + describe "#expiration_of(completion_dates)" do + context "when the completions currently satisfy the cycle" do + it "returns the date on which the completions will no longer satisfy the cycle" do + expect(cycle.expiration_of(completed_dates)).to eq(middle_date + 180.days) + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns nil" do + expect(cycle.expiration_of(completed_dates)).to be_nil + end + end + end + + describe "#volume" do + it "returns the volume specified by the notation" do + expect(cycle.volume).to eq(2) + end + end + + describe "#notation" do + it "returns the string representation of itself" do + expect(cycle.notation).to eq(notation) + end + end + end +end \ No newline at end of file diff --git a/spec/parser_spec.rb b/spec/parser_spec.rb new file mode 100644 index 0000000..fd8e879 --- /dev/null +++ b/spec/parser_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::Parser, type: :value do + describe ".load(hash)" do + it "returns a Notation instance" do + hash = { + volume: 1, + kind: "L", + period_count: "180", + period_key: "D" + } + expect(described_class.load(hash)).to eq described_class.new("V1L180D") + end + end + + describe ".for(str_or_notation)" do + context "when given a string" do + it "returns a Notation instance" do + expect(described_class.for("V1L180D")).to be_a(described_class) + end + end + + context "when given a Notation object" do + it "returns the object" do + notation = described_class.new("V1L180D") + expect(described_class.for(notation)).to eq notation + end + end + end + + describe "#initialize" do + it "handles any case" do + aggregate_failures do + expect(described_class.new("V1l180D").inspect).to eq "V1L180D" + expect(described_class.new("V1L180d").to_s).to eq "V1L180D" + end + end + end + + describe "#inspect & #to_s" do + it "returns the string representation" do + aggregate_failures do + expect(described_class.new("V1L180D").inspect).to eq "V1L180D" + expect(described_class.new("V1L180D").to_s).to eq "V1L180D" + end + end + end + + describe "#valid?" do + it "returns true if the notation is recognized" do + aggregate_failures do + expect(described_class.new("V1L180D")).to be_valid + expect(described_class.new("XXX")).not_to be_valid + end + end + end + + describe "#to_h" do + it "returns a hash representation of the notation" do + aggregate_failures do + expect(described_class.new("V1L180D").to_h).to eq({ + volume: 1, + kind: "L", + period_count: "180", + period_key: "D", + from_date: nil + }) + expect(described_class.new("V1W180D").to_h).to eq({ + volume: 1, + kind: "W", + period_count: "180", + period_key: "D", + from_date: nil + }) + expect(described_class.new("V1W180DF2024-05-06").to_h).to eq({ + volume: 1, + kind: "W", + period_count: "180", + period_key: "D", + from_date: "2024-05-06" + }) + end + end + end + + describe "#activated_notation(date)" do + it "returns the activated notation" do + date = "2024-05-06" + aggregate_failures do + expect(described_class.new("V1L180D").activated_notation(date)).to eq( + "V1L180D" + ) + expect(described_class.new("V1W180D").activated_notation(date)).to eq( + "V1W180DF2024-05-06" + ) + expect(described_class.new("V1W180DF2024-09-09").activated_notation(date)).to eq( + "V1W180DF2024-05-06" + ) + end + end + end + + describe "#dormant|_capable? & #active?" do + it "returns true if the notation has a dormant variant" do + aggregate_failures do + expect(described_class.new("V1L180D")).not_to be_dormant_capable + expect(described_class.new("V1L180D")).not_to be_dormant + expect(described_class.new("V1L180D")).to be_active + + expect(described_class.new("V1W180D")).to be_dormant_capable + expect(described_class.new("V1W180D")).to be_dormant + expect(described_class.new("V1W180D")).not_to be_active + + expect(described_class.new("V1W180DF2024-09-09")).to be_dormant_capable + expect(described_class.new("V1W180DF2024-09-09")).not_to be_dormant + expect(described_class.new("V1W180DF2024-09-09")).to be_active + end + end + end + + describe "#from|_date" do + it "returns match[:from]|match[:from_date]" do + aggregate_failures do + expect(described_class.new("V1L180D").from).to be_nil + expect(described_class.new("V1L180D").from_date).to be_nil + expect(described_class.new("V1W10DF2024-04-09").from).to eq "F2024-04-09" + expect(described_class.new("V1W10DF2024-04-09").from_date).to eq "2024-04-09" + end + end + end + + describe "#period_count" do + it "returns match[:period_count]" do + aggregate_failures do + expect(described_class.new("V1L180D").period_count).to eq "180" + expect(described_class.new("V3C1Y").period_count).to eq "1" + expect(described_class.new("V4").period_count).to be_nil + end + end + end + + describe "#period_key" do + it "returns match[:period_key]" do + aggregate_failures do + expect(described_class.new("V1L180D").period_key).to eq "D" + expect(described_class.new("V3C1Y").period_key).to eq "Y" + expect(described_class.new("V4").period_key).to be_nil + end + end + end + + describe "#volume" do + it "returns match[:volume]" do + aggregate_failures do + expect(described_class.new("L180D").volume).to eq 1 + expect(described_class.new("V1L180D").volume).to eq 1 + expect(described_class.new("V3C1Y").volume).to eq 3 + expect(described_class.new("V4").volume).to eq 4 + end + end + end + + describe "#kind" do + it "returns match[:kind]" do + aggregate_failures do + expect(described_class.new("V1L180D").kind).to eq "L" + expect(described_class.new("V1C1Y").kind).to eq "C" + expect(described_class.new("V1").kind).to be_nil + end + end + end + + describe "#parses?" do + it "returns true if the object parses the notation_id" do + aggregate_failures do + expect(described_class.new("V1L180D").parses?("L")).to be true + expect(described_class.new("V1C1Y").parses?("C")).to be true + expect(described_class.new("V1").parses?(nil)).to be true + end + end + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c80d44b..a0ffe80 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,6 @@ +require "sof/cycle" +require "debug" + # This file was generated by the `rspec --init` 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 diff --git a/spec/time_span_spec.rb b/spec/time_span_spec.rb new file mode 100644 index 0000000..e747bef --- /dev/null +++ b/spec/time_span_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe SOF::Cycle::TimeSpan, type: :value do + describe ".notation" do + it "accepts a hash and returns a string notation" do + aggregate_failures do + expect(described_class.notation(period: :day, period_count: 3)).to eq("3D") + expect(described_class.notation(period: :month, period_count: 2)).to eq("2M") + expect(described_class.notation(period: :quarter, period_count: 5)).to eq("5Q") + expect(described_class.notation(period: :year, period_count: 2)).to eq("2Y") + end + end + end + + describe ".for" do + it "returns a TimeSpan object" do + expect(described_class.for(1, "M")).to be_a(described_class) + end + + it "accepts string counts" do + expect(described_class.for("5", "M").period_count).to eq(5) + end + + it "accepts lowercase periods" do + expect(described_class.for(3, "m").period).to eq(:month) + end + end + + describe "#end_date_of_period" do + it "is nil if there is no period" do + expect(described_class.for("", "").end_date_of_period(Time.current)).to be_nil + end + + it "for a year period returns a date at the end of the next calendar year" do + span = described_class.for("1", "Y") + expect(span.end_date_of_period("2022-01-15".to_date)).to eq Date.parse("2022-12-31") + end + + it "for a month period returns a date 30 days later" do + span = described_class.for("1", "m") + expect(span.end_date_of_period("2022-01-15".to_date)).to eq Date.parse("2022-01-31") + end + + it "for a week period returns a date a at the end of the week" do + span = described_class.for("1", "w") + expect(span.end_date_of_period("2022-01-15".to_date)).to eq Date.parse("2022-01-16") + end + + it "for a day period returns a the same date" do + span = described_class.for("1", "D") + expect(span.end_date_of_period("2022-01-15".to_date)).to eq Date.parse("2022-01-15") + end + + it "for a quarter period returns a date at the end of the next quarter" do + span = described_class.for("1", "Q") + expect(span.end_date_of_period("2022-01-15".to_date)).to eq Date.parse("2022-03-31") + end + end + end +end \ No newline at end of file diff --git a/spec/volume_only_spec.rb b/spec/volume_only_spec.rb new file mode 100644 index 0000000..fdb629b --- /dev/null +++ b/spec/volume_only_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::VolumeOnly, type: :value do + let(:cycle) { Cycle.for(notation) } + let(:notation) { "V2" } + let(:completed_dates) do + [ + recent_date, + middle_date, + early_date, + early_date + ] + end + let(:recent_date) { anchor - 1.days } + let(:middle_date) { anchor - 70.days } + let(:early_date) { anchor - 99.years } + let(:anchor) { "2020-08-01".to_date } + + describe "#covered_dates" do + it "given an anchor date, returns dates that fall within it's window" do + expect(cycle.covered_dates(completed_dates, anchor:)).to eq([ + recent_date, + middle_date, + early_date, + early_date + ]) + end + end + + describe "#satisfied_by?(completed_dates, anchor:)" do + context "when the completions--judged from the anchor--satisfy the cycle" do + it "returns true" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq true + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + + context "when there are no completions" do + let(:completed_dates) { [] } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + end + + describe "#expiration_of(completion_dates)" do + context "when the completions currently satisfy the cycle" do + it "returns nil" do + expect(cycle.expiration_of(completed_dates)).to be nil + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns nil" do + expect(cycle.expiration_of(completed_dates)).to be_nil + end + end + end + end +end \ No newline at end of file diff --git a/spec/within_spec.rb b/spec/within_spec.rb new file mode 100644 index 0000000..056a70a --- /dev/null +++ b/spec/within_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::Within, type: :value do + let(:cycle) { Cycle.for(notation) } + let(:notation) { "V2W180DF#{from_date}" } + let(:completed_dates) do + [ + too_early, + too_late, + first_valid, + last_valid + ] + end + let(:too_late) { from_date + 181.days } + let(:too_early) { from_date - 1.day } + let(:first_valid) { from_date } + let(:last_valid) { from_date + 180.days } + let(:from_date) { "2020-08-01".to_date } + + let(:anchor) { "2999-08-01".to_date } # anchor never matters for this cycle + + describe "#start_date" do + it "returns the " do + expect(cycle.start_date).to eq(from_date) + end + end + + describe "#to_s" do + it "returns a string representation of the cycle" do + range = [from_date, from_date + 180.days].map { |d| d.to_fs(:american) }.join(" - ") + expect(cycle.to_s).to eq "2x within #{range}" + end + end + + describe "#covered_dates" do + it "given an anchor date, returns dates that fall within it's window" do + expect(cycle.covered_dates(completed_dates, anchor:)).to eq([ + first_valid, + last_valid + ]) + end + end + + describe "#satisfied_by?(completed_dates, anchor:)" do + context "when the completions--judged from the --satisfy the cycle" do + it "returns true" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq true + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V3W180D" } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + + context "when there are no completions" do + let(:completed_dates) { [] } + + it "returns false" do + expect(cycle.satisfied_by?(completed_dates, anchor:)).to eq false + end + end + end + + describe "#expiration_of(completion_dates)" do + context "when the completions currently satisfy the cycle" do + it "returns nil" do + expect(cycle.expiration_of(completed_dates)).to be nil + end + end + + context "when the completions currently do not satisfy the cycle" do + let(:notation) { "V5L180D" } + + it "returns nil" do + expect(cycle.expiration_of(completed_dates)).to be_nil + end + end + end + + describe "#volume" do + it "returns the volume specified by the notation" do + expect(cycle.volume).to eq(2) + end + end + + describe "#notation" do + it "returns the string representation of itself" do + expect(cycle.notation).to eq(notation) + end + end + end +end \ No newline at end of file