Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/sof/cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def initialize(notation, parser: Parser.new(notation))
delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
delegate [:period_count, :duration] => :time_span
delegate [:calendar?, :dormant?, :end_of?, :interval?, :lookback?,
:volume_only?, :within?] => :kind_inquiry
:lookback_end_of?, :volume_only?, :within?] => :kind_inquiry

def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s)

Expand Down
43 changes: 43 additions & 0 deletions lib/sof/cycles/lookback_end_of.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module SOF
module Cycles
class LookbackEndOf < Cycle
@volume_only = false
@notation_id = "LE"
@kind = :lookback_end_of
@valid_periods = %w[D W M Q Y]

def self.recurring? = true

def self.description
"Lookback End of Period - occurrences within a prior time period, expiring at the end of the calendar period"
end

def self.examples
["V1LE24M - once in the prior 24 months (expires end of month)", "V2LE3W - twice in the prior 3 weeks (expires end of week)"]
end

def to_s = "#{volume}x in the prior #{period_count} #{humanized_period} (end of period)"

def expiration_of(completion_dates, anchor: Date.current)
oldest = completion_dates.max_by(volume) { it }.min
return unless satisfied_by?(completion_dates, anchor:)

final_date(oldest)
end

def final_date(anchor)
return if anchor.nil?

time_span.end_date_of_period(time_span.end_date(anchor.to_date))
end
alias_method :window_end, :final_date

def start_date(anchor)
time_span.begin_date_of_period(time_span.begin_date(anchor.to_date))
end
alias_method :window_start, :start_date
end
end
end
2 changes: 1 addition & 1 deletion lib/sof/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Parser

PARTS_REGEX = /
^(?<vol>V(?<volume>\d*))? # optional volume
(?<set>(?<kind>L|C|W|E|I) # kind
(?<set>(?<kind>LE|L|C|W|E|I) # kind
(?<period_count>\d+) # period count
(?<period_key>D|W|M|Q|Y)?)? # period_key
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
Expand Down
127 changes: 127 additions & 0 deletions spec/sof/cycles/lookback_end_of_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

require "spec_helper"
require_relative "shared_examples"

module SOF
RSpec.describe Cycles::LookbackEndOf, type: :value do
subject(:cycle) { Cycle.for(notation) }

let(:notation) { "V1LE24M" }

# April 10, 2026 — used as "today" in anchor-based tests
let(:anchor) { "2026-04-10".to_date }

# April 9, 2024 — one day before the standard 24-month lookback window
# but inside the beginning_of_month-rounded window
let(:edge_completion) { "2024-04-09".to_date }

# May 15, 2024 — clearly inside the 24-month window (no rounding needed)
let(:inside_completion) { "2024-05-15".to_date }

# March 31, 2024 — one day before the rounded window start (April 1, 2024),
# proving the boundary is beginning_of_month(anchor - period), not further back
let(:outside_completion) { "2024-03-31".to_date }

it_behaves_like "#kind returns", :lookback_end_of
it_behaves_like "#valid_periods are", %w[D W M Q Y]
it_behaves_like "#to_s returns", "1x in the prior 24 months (end of period)"
it_behaves_like "#volume returns the volume"
it_behaves_like "#notation returns the notation"
it_behaves_like "#as_json returns the notation"
it_behaves_like "it cannot be extended"

describe "#recurring?" do
it "repeats" do
expect(cycle).to be_recurring
end
end

describe "#satisfied_by?(completion_dates, anchor:)" do
context "when a completion is one day before the exact lookback boundary but within the start of the period" do
# Standard L24M window from April 10, 2026 starts April 10, 2024.
# LE24M rounds to April 1, 2024 — so April 9, 2024 qualifies.
it "returns true" do
expect(cycle).to be_satisfied_by([edge_completion], anchor:)
end
end

context "when a completion is clearly inside the rounded window" do
it "returns true" do
expect(cycle).to be_satisfied_by([inside_completion], anchor:)
end
end

context "when a completion is outside the window even after rounding" do
it "returns false" do
expect(cycle).not_to be_satisfied_by([outside_completion], anchor:)
end
end

context "with volume > 1" do
let(:notation) { "V2LE24M" }

it "requires the minimum number of completions" do
expect(cycle).not_to be_satisfied_by([edge_completion], anchor:)
expect(cycle).to be_satisfied_by([edge_completion, inside_completion], anchor:)
end
end

context "when there are no completions" do
it "returns false" do
expect(cycle).not_to be_satisfied_by([], anchor:)
end
end
end

describe "#expiration_of(completion_dates)" do
context "when satisfied" do
# anchor = edge_completion = April 9, 2024
# April 9, 2024 + 24 months = April 9, 2026 → end_of_month = April 30, 2026
it "returns the end of the period in which the window boundary falls" do
expect(cycle.expiration_of([edge_completion])).to eq("2026-04-30".to_date)
end
end

context "with a completion in the middle of a period" do
# May 15, 2024 + 24 months = May 15, 2026 → end_of_month = May 31, 2026
it "rounds to end of that period" do
expect(cycle.expiration_of([inside_completion])).to eq("2026-05-31".to_date)
end
end

context "when not satisfied" do
it "returns nil" do
expect(cycle.expiration_of([outside_completion])).to be_nil
end
end

context "with volume > 1" do
let(:notation) { "V2LE24M" }

# Uses the oldest of the most recent 2 completions as the anchor.
# edge_completion (April 9, 2024) is the older of the two → April 9, 2024 + 24 months = April 30, 2026
it "uses the oldest of the most recent volume completions" do
expect(cycle.expiration_of([inside_completion, edge_completion])).to eq("2026-04-30".to_date)
end
end
end

describe "#final_date(anchor)" do
# April 10, 2026 + 24 months = April 10, 2028 → end_of_month = April 30, 2028
it "returns anchor + period rounded to end of period" do
expect(cycle.final_date("2026-04-10".to_date)).to eq("2028-04-30".to_date)
end
end

describe "Cycle.for" do
it "returns a LookbackEndOf instance for LE notation" do
expect(Cycle.for("V1LE24M")).to be_a(Cycles::LookbackEndOf)
end

it "does not affect plain Lookback" do
expect(Cycle.for("V1L24M")).to be_a(Cycles::Lookback)
end
end
end
end
15 changes: 15 additions & 0 deletions spec/sof/parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -225,5 +225,20 @@ module SOF
end
end
end

describe "LE kind" do
it "parses V1LE24M as a valid LE notation" do
parser = described_class.new("V1LE24M")
expect(parser).to be_valid
expect(parser.kind).to eq("LE")
expect(parser.period_count).to eq("24")
expect(parser.period_key).to eq("M")
end

it "does not confuse V1L24M (Lookback) with LE" do
parser = described_class.new("V1L24M")
expect(parser.kind).to eq("L")
end
end
end
end
Loading