Skip to content

Commit

Permalink
Merge pull request #16 from SOFware/extend/cycle-last-completed
Browse files Browse the repository at this point in the history
Extend/cycle last completed
  • Loading branch information
jdowd authored Sep 2, 2024
2 parents c40f677 + fa60729 commit ce5ac8b
Show file tree
Hide file tree
Showing 11 changed files with 142 additions and 127 deletions.
8 changes: 2 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
sof-cycle (0.1.3)
sof-cycle (0.1.4)
activesupport (>= 6.0)
forwardable

Expand Down
229 changes: 116 additions & 113 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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?
#
Expand Down
2 changes: 1 addition & 1 deletion lib/sof/cycle/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module SOF
class Cycle
VERSION = "0.1.3"
VERSION = "0.1.4"
end
end
3 changes: 3 additions & 0 deletions lib/sof/cycles/end_of.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/sof/cycles/calendar_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions spec/sof/cycles/end_of_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) { [] }

Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions spec/sof/cycles/lookback_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions spec/sof/cycles/shared_examples.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions spec/sof/cycles/volume_only_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/sof/cycles/within_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit ce5ac8b

Please sign in to comment.