diff --git a/lib/calendarium-romanum.rb b/lib/calendarium-romanum.rb index 33019f74..1f0b1a93 100644 --- a/lib/calendarium-romanum.rb +++ b/lib/calendarium-romanum.rb @@ -15,6 +15,7 @@ module CalendariumRomanum enum enums errors + event_dispatcher data day calendar @@ -31,6 +32,7 @@ module CalendariumRomanum sanctorale_loader sanctorale_writer sanctorale_factory + special_cases_handler transfers util ordinalizer diff --git a/lib/calendarium-romanum/calendar.rb b/lib/calendarium-romanum/calendar.rb index e573de8e..1a57c328 100644 --- a/lib/calendarium-romanum/calendar.rb +++ b/lib/calendarium-romanum/calendar.rb @@ -41,7 +41,7 @@ class Calendar # @raise [RangeError] # if +year+ is specified for which the implemented calendar # system wasn't in force - def initialize(year, sanctorale = nil, temporale = nil, vespers: false, transfers: nil) + def initialize(year, sanctorale = nil, temporale = nil, vespers: false, transfers: nil, event_dispatcher: nil) unless year.is_a? Integer temporale = year year = temporale.year @@ -61,6 +61,8 @@ def initialize(year, sanctorale = nil, temporale = nil, vespers: false, transfer @populate_vespers = vespers @transferred = (transfers || Transfers).call(@temporale, @sanctorale).freeze + + @event_dispatcher = event_dispatcher || EventDispatcher.new end class << self @@ -254,11 +256,11 @@ def freeze private def celebrations_for(date) - tr = @transferred[date] + tr = transferred_on_event(date, @transferred[date]) if @transferred[date] return [tr] if tr - t = @temporale[date] - st = @sanctorale[date] + t = temporale_retrieval_event date, @temporale[date] + st = sanctorale_retrieval_event date, @sanctorale[date] if date.saturday? && @temporale.season(date) == Seasons::ORDINARY && @@ -267,29 +269,36 @@ def celebrations_for(date) st += [Temporale::CelebrationFactory.saturday_memorial_bvm] end - unless st.empty? + result = + if st.empty? + [t] + else if st.first.rank > t.rank if st.first.rank == Ranks::MEMORIAL_OPTIONAL - return [t] + st + [t] + st else - return st + st end elsif t.rank == Ranks::FERIAL_PRIVILEGED && st.first.rank.memorial? commemorations = st.collect do |c| c.change(rank: Ranks::COMMEMORATION, colour: t.colour) end - return [t] + commemorations + + [t] + commemorations elsif t.symbol == :immaculate_heart && [Ranks::MEMORIAL_GENERAL, Ranks::MEMORIAL_PROPER].include?(st.first.rank) optional_memorials = ([t] + st).collect do |celebration| celebration.change rank: Ranks::MEMORIAL_OPTIONAL end ferial = temporale.send :ferial, date # ugly and evil - return [ferial] + optional_memorials + + [ferial] + optional_memorials + else + [t] end end - [t] + resolution_event date, result, t, st end def first_vespers_on(date, celebrations) @@ -297,19 +306,90 @@ def first_vespers_on(date, celebrations) tomorrow_celebrations = celebrations_for(tomorrow) c = tomorrow_celebrations.first + + result = if c.rank >= Ranks::SOLEMNITY_PROPER || c.rank == Ranks::SUNDAY_UNPRIVILEGED || (c.rank == Ranks::FEAST_LORD_GENERAL && tomorrow.sunday?) if c.symbol == :ash_wednesday || c.symbol == :good_friday - return nil - end - - if c.rank > celebrations.first.rank || c.symbol == :easter_sunday - return c + nil + elsif c.rank > celebrations.first.rank || c.symbol == :easter_sunday + c + else + nil end end - nil + vespers_event date, result, celebrations, tomorrow_celebrations + end + + # There is a solemnity transferred to the given date. + # Listeners can prevent the transfer from taking effect + # (and thus lose the solemnity for the given year) by setting + # #celebration to nil, or even replace it with a completely + # different celebration. + class TransferredOnEvent < Struct.new(:date, :celebration, :calendar) + EVENT_ID = :calendar__transferred_on + end + + # Dispatched whenever {Calendar} retrieves {Celebration} for + # a given date from {Temporale}. + # Listeners can override the {Celebration}. + class TemporaleRetrievalEvent < Struct.new(:date, :result, :calendar) + EVENT_ID = :calendar__temporale_retrieval + end + + # Dispatched whenever {Calendar} retrieves {Celebration}s for + # a given date from {Sanctorale}. + # Listeners can override the {Celebration}s. + class SanctoraleRetrievalEvent < Struct.new(:date, :result, :calendar) + EVENT_ID = :calendar__sanctorale_retrieval + end + + # Dispatched whenever {Calendar} decides which {Celebration}(s) + # will take place on the given date. + # Listeners can replace the result. + class TemporaleSanctoraleResolutionEvent < Struct.new(:date, :result, :temporale, :sanctorale, :calendar) + EVENT_ID = :calendar__temporale_sanctorale_resolution + end + + # Dispatched whenever {Calendar} decides which (if any) + # {Celebration}'s Vespers should be celebrated on the given date. + # Only valid options for +result+ are +nil+ (the day's {Celebration} + # keeps the Vespers) or one of the {Celebration}s from +tomorrow+, + # if it's rank makes it eligible for first Vespers. + class VespersResolutionEvent < Struct.new(:date, :result, :today, :tomorrow, :calendar) + EVENT_ID = :calendar__vespers_resolution + end + + def transferred_on_event(*args) + @event_dispatcher + .dispatch(TransferredOnEvent.new(*args, self)) + .celebration + end + + def temporale_retrieval_event(*args) + @event_dispatcher + .dispatch(TemporaleRetrievalEvent.new(*args, self)) + .result + end + + def sanctorale_retrieval_event(*args) + @event_dispatcher + .dispatch(SanctoraleRetrievalEvent.new(*args, self)) + .result + end + + def resolution_event(*args) + @event_dispatcher + .dispatch(TemporaleSanctoraleResolutionEvent.new(*args, self)) + .result + end + + def vespers_event(*args) + @event_dispatcher + .dispatch(VespersResolutionEvent.new(*args, self)) + .result end def system_not_effective diff --git a/lib/calendarium-romanum/event_dispatcher.rb b/lib/calendarium-romanum/event_dispatcher.rb new file mode 100644 index 00000000..67167a0b --- /dev/null +++ b/lib/calendarium-romanum/event_dispatcher.rb @@ -0,0 +1,26 @@ +module CalendariumRomanum + class EventDispatcher + def initialize + @listeners = {} + end + + def add_listener(event_id, listener=nil, &blk) + listener ||= blk + unless listener + raise ArgumentError.new('Either pass a callable as argument or provide a block') + end + + @listeners[event_id] ||= [] + @listeners[event_id] << listener + end + + def dispatch(event, event_id = nil) + event_id ||= event.class::EVENT_ID + + listeners = @listeners[event_id] + listeners.each {|l| l.call event, event_id } if listeners + + event + end + end +end diff --git a/lib/calendarium-romanum/special_cases_handler.rb b/lib/calendarium-romanum/special_cases_handler.rb new file mode 100644 index 00000000..dada04a6 --- /dev/null +++ b/lib/calendarium-romanum/special_cases_handler.rb @@ -0,0 +1,104 @@ +module CalendariumRomanum + # Provides {Calendar} event listeners implementing behaviour not fitting + # in the standard calendar rules, but prescribed for given liturgical year + # by the Holy See. + class SpecialCasesHandler + # Returns listeners relevant for the specified liturgical year. + # + # @return [Hash#call>] + def self.listeners(year) + @listeners ||= + begin + { + # see liturgical_law/2020_dubia_de_calendario_2022.md + 2021 => { + # Birth of St. John Baptist celebrated one day earlier + CR::Calendar::SanctoraleRetrievalEvent::EVENT_ID => + SanctoraleDateChangeListener.new(:baptist_birth, Date.new(2022, 6, 23)), + # Sacred Heart receives first Vespers + CR::Calendar::VespersResolutionEvent::EVENT_ID => + GrantFirstVespersListener.new(:sacred_heart) + }, + }.freeze + end + + @listeners[year] || {} + end + + # Returns {EventDispatcher} pre-configured with listeners relevant for the + # specified liturgical year. + # + # @return [EventDispatcher] + def self.event_dispatcher(year) + EventDispatcher.new.tap do |el| + listeners(year).each_pair {|event, listener| el.add_listener event, listener } + end + end + + # For the given year changes any sanctorale celebration's date. + class SanctoraleDateChangeListener + def initialize(celebration_symbol, date) + @symbol = celebration_symbol + @date = date + end + + # TODO: supply test coverage, quite probably it doesn't do everywhere exactly what it should + def call(event, event_id) + unless @celebration + @orig_date, @celebration = event.calendar.sanctorale.by_symbol(@symbol) + end + + return unless @celebration + + if event.date == @orig_date + event.result = event.result.reject {|c| c.symbol == @symbol } + end + + if event.date == @date + event.result = + if @celebration.rank == CR::Ranks::MEMORIAL_OPTIONAL && + (event.result.empty? || event.result[0].rank == CR::Ranks::MEMORIAL_OPTIONAL) + event.result + [@celebration] + else + [@celebration] + end + end + end + end + + # For the given year changes any temporale celebration's date, + # given that the celebration has it's own proper symbol. + class TemporaleDateChangeListener + def initialize(celebration_symbol, date) + @symbol = celebration_symbol + @date = date + end + + def call(event, event_id) + @orig_date ||= event.calendar.temporale.public_send @symbol + return if @date == @orig_date + + if event.date == @orig_date + # TODO must be handled! + end + + if event.date == @date + event.result = event.calendar.temporale[@orig_date] + end + end + end + + # Grants first Vespers to a celebration even if it wouldn't get them + # according to the standard logic of celebration precedence. + class GrantFirstVespersListener + def initialize(celebration_symbol) + @symbol = celebration_symbol + end + + def call(event, event_id) + found = event.tomorrow.find {|c| c.symbol == @symbol } + event.result = found if found + end + end + end +end diff --git a/lib/calendarium-romanum/transfers.rb b/lib/calendarium-romanum/transfers.rb index 39967d91..aee5f9fe 100644 --- a/lib/calendarium-romanum/transfers.rb +++ b/lib/calendarium-romanum/transfers.rb @@ -62,7 +62,7 @@ def call end @transferred[transfer_to] = loser # primary celebrations have noone to be beaten by, no need to harden their dates - @transferred[date] = winner unless winner.rank == Ranks::PRIMARY + @transferred[date] = winner unless winner.rank >= Ranks::PRIMARY end @transferred diff --git a/liturgical_law/2020_dubia_de_calendario_2022.md b/liturgical_law/2020_dubia_de_calendario_2022.md index eb0b9501..33a2a7ad 100644 --- a/liturgical_law/2020_dubia_de_calendario_2022.md +++ b/liturgical_law/2020_dubia_de_calendario_2022.md @@ -57,7 +57,8 @@ c) Sollemnitas Nativitatis S. Ioanni Baptistae et sollemnitas Sacratissimi Cordi II Vesperae omittantur. I Vesperae sollemnitatis Sacratissimi Cordis Iesu celebrentur. ```ruby -calendar = CR::Calendar.new 2021, CR::Data::GENERAL_ROMAN_LATIN.load, vespers: true +year = 2021 +calendar = CR::Calendar.new year, CR::Data::GENERAL_ROMAN_LATIN.load, vespers: true, event_dispatcher: CR::SpecialCasesHandler.event_dispatcher(year) i23 = Date.new(2022, 6, 23) i24 = i23 + 1 @@ -77,9 +78,40 @@ feria VI, celebretur; sollemnitas autem Sacratissimi Cordis Iesu ad diem 23 iuni feriam V transferatur, usque ad horam Nonam inclusive. ```ruby -skip 'there is currently no pretty way how to model this scenario using calendarium-romanum - - a custom Temporale is required, either with a changed date of Sacred Heart or with - customized solemnity transfer logic' +year = 2021 + +i23 = Date.new(2022, 6, 23) +i24 = i23 + 1 + +dispatcher = CR::EventDispatcher.new + +# TODO these three listeners should be replaced by a single listener customizing +# the logic of solemnity transfer (once that is possible) +dispatcher.add_listener( + CR::Calendar::TemporaleRetrievalEvent::EVENT_ID, + CR::SpecialCasesHandler::TemporaleDateChangeListener.new(:sacred_heart, i23) +) +dispatcher.add_listener(CR::Calendar::TransferredOnEvent::EVENT_ID) do |event| + if event.celebration && [:baptist_birth, :sacred_heart].include?(event.celebration.symbol) + event.celebration = nil + end +end +dispatcher.add_listener(CR::Calendar::TemporaleSanctoraleResolutionEvent::EVENT_ID) do |event| + event.result = event.sanctorale if event.date == i24 +end + +dispatcher.add_listener( + CR::Calendar::VespersResolutionEvent::EVENT_ID, + CR::SpecialCasesHandler::GrantFirstVespersListener.new(:baptist_birth) +) + +calendar = CR::Calendar.new year, CR::Data::GENERAL_ROMAN_LATIN.load, vespers: true, event_dispatcher: dispatcher + +expect(calendar[i24].celebrations[0].symbol).to be :baptist_birth + +day = calendar[i23] +expect(day.celebrations[0].symbol).to be :sacred_heart +expect(day.vespers.symbol).to be :baptist_birth ``` d) Dominica XX Temporis "per annum", die 14 augusti. diff --git a/spec/calendar_spec.rb b/spec/calendar_spec.rb index 1e9e2225..bf3574a3 100644 --- a/spec/calendar_spec.rb +++ b/spec/calendar_spec.rb @@ -807,6 +807,94 @@ end end + describe 'customizing behaviour through event listeners' do + let(:dispatcher) { CR::EventDispatcher.new } + let(:year) { 2014 } + let(:sanctorale) { CR::Sanctorale.new } + + let(:st_none) { CR::Celebration.new('St. None, abbot, founder of the Order of Programmers (OProg)', CR::Ranks::SOLEMNITY_PROPER, symbol: :none) } + + describe 'solemnity transfer' do + it 'can prevent it' do + # the solemnity falls on the year's Good Friday ... + d = CR::Temporale::Dates.good_friday(year) + sanctorale.add d.month, d.day, st_none + + calendar = described_class.new year, sanctorale, event_dispatcher: dispatcher + + # ... and is transferred + new_date = CR::Temporale::Dates.palm_sunday(year) - 1 + expect(calendar.transferred).to eq({new_date => st_none}) + + expect(calendar[new_date].celebrations[0]).to be st_none + + # now we prevent the transfer from taking effect through an event listener + dispatcher.add_listener(CR::Calendar::TransferredOnEvent::EVENT_ID) do |event| + event.celebration = nil + end + + expect(calendar[new_date].celebrations[0]).to be_ferial + end + end + + describe 'sanctorale vs. temporale resolution' do + it 'can override it' do + sanctorale.add 7, 10, st_none + + calendar = described_class.new year, sanctorale, event_dispatcher: dispatcher + + expect(calendar.day(7, 10).celebrations[0]).to be st_none + + # now we modify the calendar's behaviour to ignore St. None + dispatcher.add_listener(CR::Calendar::TemporaleSanctoraleResolutionEvent::EVENT_ID) do |event| + event.result = [event.temporale] if event.result[0].symbol == :none + end + + expect(calendar.day(7, 10).celebrations[0]).to be_ferial + end + end + + describe 'first Vespers resolution' do + it 'can override it' do + date = Date.new(year + 1, 7, 18) + expect(date).to be_saturday # make sure + + sanctorale.add date.month, date.day, st_none + + calendar = described_class.new year, sanctorale, event_dispatcher: dispatcher, vespers: true + + # normally the solemnity gets second Vespers + expect(calendar[date].vespers).to be nil + + # now we modify the calendar's behaviour to unlawfully grant the green Sunday first Vespers + dispatcher.add_listener(CR::Calendar::VespersResolutionEvent::EVENT_ID) do |event| + if event.today[0].symbol == :none && event.tomorrow[0].sunday? + event.result = event.tomorrow[0] + end + end + + expect(calendar[date].vespers.rank).to be CR::Ranks::SUNDAY_UNPRIVILEGED + end + end + + describe 'Sanctorale celebration' do + it 'can be overridden' do + expect(sanctorale).to be_empty # make sure + + calendar = described_class.new year, sanctorale, event_dispatcher: dispatcher + + # solemnities of St. None all the year long + dispatcher.add_listener(CR::Calendar::SanctoraleRetrievalEvent::EVENT_ID) do |event| + event.result = [st_none] + end + + expect(calendar[Date.new(year, 12, 15)].celebrations[0]).to be st_none + expect(calendar[Date.new(year + 1, 7, 1)].celebrations[0]).to be st_none + expect(calendar[Date.new(year + 1, 9, 1)].celebrations[0]).to be st_none + end + end + end + # only a small subset of the Sanctorale public interface # is used by Calendar. These specs show how small it is. describe 'required sanctorale interface' do diff --git a/spec/event_dispatcher_spec.rb b/spec/event_dispatcher_spec.rb new file mode 100644 index 00000000..54e3ba22 --- /dev/null +++ b/spec/event_dispatcher_spec.rb @@ -0,0 +1,49 @@ +require_relative 'spec_helper' + +describe CR::EventDispatcher do + let(:subject) { described_class.new } + + let(:event) { Object.new } + let(:event_id) { :some_id } + + describe '#dispatch' do + it 'returns the event' do + expect(subject.dispatch(event, 'anything')).to be event + end + + describe 'an event listenned to' do + it 'passes it to the listener' do + listener = double(Proc) + subject.add_listener event_id, listener + + expect(listener).to receive(:call).with(event, event_id) + + subject.dispatch event, event_id + end + end + + describe 'an event not listenned to' do + it 'does not pass it to the listener' do + listener = double(Proc) + subject.add_listener event_id, listener + + expect(listener).not_to receive(:call) + + subject.dispatch event, :event_id_not_listenned_to + end + end + + describe 'order of listeners' do + it 'is the order in which they were added' do + order_tracker = [] + + subject.add_listener(event_id) { order_tracker << 1 } + subject.add_listener(event_id) { order_tracker << 2 } + + subject.dispatch event, event_id + + expect(order_tracker).to eq [1, 2] + end + end + end +end