From 971c6728411e7fe7b772447a00075e16d5d62f6d Mon Sep 17 00:00:00 2001 From: John Dowd Date: Sun, 1 Sep 2024 09:29:31 -0400 Subject: [PATCH 1/4] Bump version to 0.1.4 --- CHANGELOG.md | 8 ++------ Gemfile.lock | 2 +- lib/sof/cycle/version.rb | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 335dfb3..d596d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.4] - Unreleased + ## [0.1.3] - 2024-09-01 ### Fixed - `Cycles::EndOf` to have the correct behavior - -## [0.1.2] - 2024-08-09 - -### Added - -- `Cycle#recurring?` to reveal if a given Cycle is one-and-done or must be repeated. diff --git a/Gemfile.lock b/Gemfile.lock index 149fecb..8f86581 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - sof-cycle (0.1.3) + sof-cycle (0.1.4) activesupport (>= 6.0) forwardable diff --git a/lib/sof/cycle/version.rb b/lib/sof/cycle/version.rb index 249d95b..e7e397d 100644 --- a/lib/sof/cycle/version.rb +++ b/lib/sof/cycle/version.rb @@ -2,6 +2,6 @@ module SOF class Cycle - VERSION = "0.1.3" + VERSION = "0.1.4" end end From 6ab0fd536dbbb696a909ce164c8000ec8f903a38 Mon Sep 17 00:00:00 2001 From: John Dowd Date: Sun, 1 Sep 2024 21:56:50 -0400 Subject: [PATCH 2/4] Consolidate class methods * Group all of them under `class << self` * Put them above the #initialize definition --- lib/sof/cycle.rb | 226 +++++++++++++++++++++++------------------------ 1 file changed, 113 insertions(+), 113 deletions(-) diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 1e7557e..22b832f 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -11,142 +11,142 @@ class InvalidPeriod < InvalidInput; end class InvalidKind < InvalidInput; end - def initialize(notation, parser: Parser.new(notation)) - @notation = notation - @parser = parser - validate_period + class << self + # Turn a cycle or notation string into a hash + def 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 if @parser.valid? + # Return a Cycle object from a hash + def load(hash) + symbolized_hash = hash.symbolize_keys + cycle_class = class_for_kind(symbolized_hash[:kind]) - raise InvalidInput, "'#{notation}' is not a valid input" - end + unless cycle_class.valid_periods.empty? + cycle_class.validate_period( + TimeSpan.notation_id_from_name(symbolized_hash[:period]) + ) + end - attr_reader :parser + Cycle.for notation(symbolized_hash) + rescue TimeSpan::InvalidPeriod => exc + raise InvalidPeriod, exc.message + end - delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period, - :humanized_period, :period_key, :active?] => :@parser - delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class" - delegate [:period_count, :duration] => :time_span - delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?, - :within?] => :kind_inquiry + # 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 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 - # 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 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 for(notation) + return notation if notation.is_a? Cycle + return notation if notation.is_a? Cycles::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? + + Cycles::Dormant.new(cycle, parser:) + 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]) + # 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 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 - unless cycle_class.valid_periods.empty? - cycle_class.validate_period( - TimeSpan.notation_id_from_name(symbolized_hash[:period]) - ) + # Return the class handling the kind + # + # @param sym [Symbol] symbol matching the kind of Cycle class + # @example + # class_for_kind(:lookback) + def 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 - Cycle.for notation(symbolized_hash) - rescue TimeSpan::InvalidPeriod => exc - raise InvalidPeriod, exc.message - end + def cycle_handlers = @cycle_handlers ||= Set.new - # 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 + def inherited(klass) = cycle_handlers << klass - # 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? Cycles::Dormant - parser = Parser.new(notation) - unless parser.valid? - raise InvalidInput, "'#{notation}' is not a valid input" + def handles?(sym) + sym && kind == sym.to_sym end - cycle = cycle_handlers.find do |klass| - parser.parses?(klass.notation_id) - end.new(notation, parser:) - return cycle if parser.active? + @volume_only = false + @notation_id = nil + @kind = nil + @valid_periods = [] - Cycles::Dormant.new(cycle, parser:) - end + attr_reader :notation_id, :kind, :valid_periods + def volume_only? = @volume_only - # 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 + def recurring? = raise "#{name} must implement #{__method__}" - # 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") + # 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 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 end - def self.cycle_handlers = @cycle_handlers ||= Set.new + def initialize(notation, parser: Parser.new(notation)) + @notation = notation + @parser = parser + validate_period - def self.inherited(klass) = cycle_handlers << klass + return if @parser.valid? - def self.handles?(sym) - sym && kind == sym.to_sym + raise InvalidInput, "'#{notation}' is not a valid input" end - @volume_only = false - @notation_id = nil - @kind = nil - @valid_periods = [] - - class << self - attr_reader :notation_id, :kind, :valid_periods - def volume_only? = @volume_only - - def recurring? = raise "#{name} must implement #{__method__}" - end + attr_reader :parser - # 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 + delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period, + :humanized_period, :period_key, :active?] => :@parser + delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class" + delegate [:period_count, :duration] => :time_span + delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?, + :within?] => :kind_inquiry def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s) From 6d8e5219f159e526b49b9999d5512a54b194be36 Mon Sep 17 00:00:00 2001 From: John Dowd Date: Sun, 1 Sep 2024 23:11:09 -0400 Subject: [PATCH 3/4] Add Cycle#last_completed(dates) --- lib/sof/cycle.rb | 3 +++ spec/sof/cycles/calendar_spec.rb | 1 + spec/sof/cycles/lookback_spec.rb | 1 + spec/sof/cycles/shared_examples.rb | 8 ++++++++ spec/sof/cycles/volume_only_spec.rb | 1 + spec/sof/cycles/within_spec.rb | 1 + 6 files changed, 15 insertions(+) diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 22b832f..fe389d7 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -162,6 +162,9 @@ def notation = self.class.notation(to_h) # Cycles are considered equal if their hash representations are equal def ==(other) = to_h == other.to_h + # Return the most recent completion date from the supplied array of dates + def last_completed(dates) = dates.compact.map(&:to_date).max + # From the supplied anchor date, are there enough in-window completions to # satisfy the cycle? # diff --git a/spec/sof/cycles/calendar_spec.rb b/spec/sof/cycles/calendar_spec.rb index 1fab836..f00437d 100644 --- a/spec/sof/cycles/calendar_spec.rb +++ b/spec/sof/cycles/calendar_spec.rb @@ -31,6 +31,7 @@ module SOF it_behaves_like "#as_json returns the notation" it_behaves_like "it computes #final_date(given)", given: "1971-01-01", returns: "1971-12-31".to_date + it_behaves_like "last_completed is", :recent_date describe "#recurring?" do it "repeats" do diff --git a/spec/sof/cycles/lookback_spec.rb b/spec/sof/cycles/lookback_spec.rb index de2fd26..d1c35d3 100644 --- a/spec/sof/cycles/lookback_spec.rb +++ b/spec/sof/cycles/lookback_spec.rb @@ -31,6 +31,7 @@ module SOF it_behaves_like "#as_json returns the notation" it_behaves_like "it computes #final_date(given)", given: "2003-03-08", returns: ("2003-03-08".to_date + 180.days) + it_behaves_like "last_completed is", :recent_date describe "#recurring?" do it "repeats" do diff --git a/spec/sof/cycles/shared_examples.rb b/spec/sof/cycles/shared_examples.rb index 172a2b7..5c553f1 100644 --- a/spec/sof/cycles/shared_examples.rb +++ b/spec/sof/cycles/shared_examples.rb @@ -43,3 +43,11 @@ expect(subject.final_date(given)).to eq(returns) end end + +shared_examples_for "last_completed is" do |symbol| + it "returns the correct date" do + expected = send(symbol) + dates = completed_dates + [nil, "1999-01-01"] + expect(subject.last_completed(dates)).to eq(expected) + end +end diff --git a/spec/sof/cycles/volume_only_spec.rb b/spec/sof/cycles/volume_only_spec.rb index 181bc40..9b8dd4a 100644 --- a/spec/sof/cycles/volume_only_spec.rb +++ b/spec/sof/cycles/volume_only_spec.rb @@ -29,6 +29,7 @@ module SOF it_behaves_like "#as_json returns the notation" it_behaves_like "it computes #final_date(given)", given: "2003-03-08", returns: nil + it_behaves_like "last_completed is", :recent_date describe "#recurring?" do it "does not repeat" do diff --git a/spec/sof/cycles/within_spec.rb b/spec/sof/cycles/within_spec.rb index 0f3edf5..d2865da 100644 --- a/spec/sof/cycles/within_spec.rb +++ b/spec/sof/cycles/within_spec.rb @@ -32,6 +32,7 @@ module SOF it_behaves_like "#as_json returns the notation" it_behaves_like "it computes #final_date(given)", given: "_", returns: ("2020-08-01".to_date + 180.days) + it_behaves_like "last_completed is", :too_late describe "#recurring?" do it "does not repeat" do From fa6072931c380d0ea6c3e39a3b5df4bebefddcdf Mon Sep 17 00:00:00 2001 From: John Dowd Date: Sun, 1 Sep 2024 23:16:10 -0400 Subject: [PATCH 4/4] Add Cycles::EndOf#last_completed --- lib/sof/cycles/end_of.rb | 3 +++ spec/sof/cycles/end_of_spec.rb | 13 +++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index 8a7b263..ac9498d 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -24,6 +24,9 @@ def to_s "#{volume}x by #{final_date.to_fs(:american)}" end + # Always returns the from_date + def last_completed(_) = from_date.to_date + # Returns the expiration date for the cycle # # @param [nil] _ Unused parameter, maintained for compatibility diff --git a/spec/sof/cycles/end_of_spec.rb b/spec/sof/cycles/end_of_spec.rb index 24bcbda..61f8098 100644 --- a/spec/sof/cycles/end_of_spec.rb +++ b/spec/sof/cycles/end_of_spec.rb @@ -10,8 +10,8 @@ module SOF let(:notation) { "V2E18MF#{from_date}" } let(:anchor) { nil } - let(:end_date) { (from_date.to_date + 17.months).end_of_month } - let(:from_date) { "2020-01-01" } + let(:end_date) { (from_date + 17.months).end_of_month } + let(:from_date) { "2020-01-01".to_date } let(:completed_dates) { [] } @@ -39,6 +39,7 @@ module SOF it_behaves_like "#as_json returns the notation" it_behaves_like "it computes #final_date(given)", given: nil, returns: ("2020-01-01".to_date + 17.months).end_of_month + it_behaves_like "last_completed is", :from_date describe "#covered_dates" do let(:completed_dates) do @@ -51,10 +52,10 @@ module SOF too_late_date ] end - let(:recent_date) { from_date.to_date + 17.months } - let(:middle_date) { from_date.to_date + 2.months } - let(:early_date) { from_date.to_date + 1.month } - let(:too_early_date) { from_date.to_date - 1.day } + let(:recent_date) { from_date + 17.months } + let(:middle_date) { from_date + 2.months } + let(:early_date) { from_date + 1.month } + let(:too_early_date) { from_date - 1.day } let(:too_late_date) { end_date + 1.day } let(:anchor) { "2021-06-29".to_date }