diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 140effb..dbc0664 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "forwardable" +require_relative "cycle/registry" require_relative "cycle/parser" module SOF @@ -70,9 +71,7 @@ def for(notation) raise InvalidInput, "'#{notation}' is not a valid input" end - cycle = Cycle.cycle_handlers.find do |klass| - parser.parses?(klass.notation_id) - end.new(notation, parser:) + cycle = registry.for_notation_id(parser.kind).new(notation, parser:) return cycle if parser.active? Cycles::Dormant.new(cycle, parser:) @@ -84,22 +83,14 @@ def for(notation) # @example # class_for_notation_id('L') # - def class_for_notation_id(notation_id) - Cycle.cycle_handlers.find do |klass| - klass.notation_id == notation_id - end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}") - end + def class_for_notation_id(notation_id) = registry.for_notation_id(notation_id) # 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 + def class_for_kind(sym) = registry.handling(sym) # Return a legend explaining all notation components # @@ -123,6 +114,8 @@ def legend } end + # Defaults for the base class itself, which handles no kind and so is + # never registered. @volume_only = false @notation_id = nil @kind = nil @@ -150,23 +143,77 @@ def handles?(sym) kind.to_s == sym.to_s end - def cycle_handlers - @cycle_handlers ||= Set.new + # Declare what kind of cycle this class handles, and register it. + # + # Registration is a consequence of declaring, so the two can never + # disagree, and subclassing Cycle for any other reason — an abstract + # intermediate, a test double — registers nothing. + # + # An application can declare its own kind this way and the parser will + # recognise its notation, because the pattern is built from what is + # registered. + # + # @param kind [Symbol] the kind this class handles, e.g. :lookback + # @param notation [String, nil] the notation id opening the cycle, e.g. + # "L". Omit for a cycle with no notation of its own, such as + # volume-only. + # @param periods [Array] period keys this kind accepts. Empty + # means the kind takes no period. + # @param volume_only [Boolean] whether this kind carries volume alone. + # + # @example + # class Fortnightly < SOF::Cycle + # handles :fortnightly, notation: "X", periods: %w[D W] + # def self.recurring? = true + # end + def handles(kind, notation: nil, periods: [], volume_only: false) + @kind = kind + @notation_id = notation + @valid_periods = periods + @volume_only = volume_only + register(self) end - def inherited(klass) - Cycle.cycle_handlers << klass - end + # Register a handler that does not inherit from Cycle. Inheriting and + # declaring with `handles` is the easier path, because it supplies the + # behaviour Cycle.for goes on to use; this is for a class that would + # rather implement that itself. + # + # The class must answer, at the class level: + # + # kind the kind it handles, e.g. :lookback + # notation_id the notation opening it, e.g. "L", or nil + # valid_periods period keys it accepts, e.g. %w[D W M Y] + # volume_only? whether it carries volume alone + # handles?(kind) whether it answers to a kind + # recurring? whether the window repeats + # dormant_capable? whether it can be written without a from date + # validate_period(key) + # description, examples (for the legend) + # + # and instances are built as `new(notation, parser:)`. + # + # Registering displaces any handler already answering to the same kind + # or notation id, so this replaces a built-in as readily as it adds one. + # + # @param cycle_class [Class] the handler to register + # @return [Class] the class registered + def register(cycle_class) = registry.register(cycle_class) + + # Drop a handler, undoing `register` or a `handles` declaration. + def unregister(cycle_class) = registry.unregister(cycle_class) + + def registry = Registry.instance private def build_kind_legend legend = {} - Cycle.cycle_handlers.each do |handler| + registry.cycle_classes.each do |handler| # Skip volume_only since it doesn't have a notation_id - next if handler.instance_variable_get(:@volume_only) + next if handler.volume_only? - notation_id = handler.instance_variable_get(:@notation_id) + notation_id = handler.notation_id next unless notation_id legend[notation_id] = { @@ -179,14 +226,10 @@ def build_kind_legend def build_period_legend legend = {} - # Use known period codes since DatePeriod is private - period_mappings = { - "D" => "day", - "W" => "week", - "M" => "month", - "Q" => "quarter", - "Y" => "year" - } + # Built from the registered periods, so one an application adds shows + # up here too. + period_mappings = TimeSpan.period_registry.period_classes + .to_h { |klass| [klass.code, klass.period.to_s] } period_mappings.each do |code, period_name| legend[code] = { diff --git a/lib/sof/cycle/parser.rb b/lib/sof/cycle/parser.rb index 957e4d5..c815e8f 100644 --- a/lib/sof/cycle/parser.rb +++ b/lib/sof/cycle/parser.rb @@ -14,20 +14,30 @@ class Cycle class Parser extend Forwardable - PARTS_REGEX = / - ^(?V(?\d*))? # optional volume - (?(?LE|L|C|W|E|I) # kind - (?\d+) # period count - (?D|W|M|Q|Y)?)? # period_key - (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from - /ix - - # Deliberately a method, not a constant: handlers register themselves - # through Cycle.inherited as each file in sof/cycles loads, which happens - # after this file. A constant would capture the empty set and leave every - # dormant-capable kind unrecognized. + # Built from the registry rather than written out, so an application + # that declares its own kind has its notation recognised too. Rebuilt + # whenever a class registers; cached in between, since parsing is hot. + def self.parts_regex + pattern = [Cycle.registry.notation_pattern, Cycle::TimeSpan.period_registry.code_pattern] + return @parts_regex if @parts_regex && @parts_regex_pattern == pattern + + @parts_regex_pattern = pattern + kinds, periods = pattern + @parts_regex = / + ^(?V(?\d*))? # optional volume + (?(?#{kinds}) # kind + (?\d+) # period count + (?#{periods})?)? # period_key + (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from + /ix + end + + # Deliberately a method, not a constant: handlers register as each file + # in sof/cycles loads, which happens after this file. A constant would + # capture the empty set and leave every dormant-capable kind + # unrecognized. def self.dormant_capable_kinds - Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact.freeze + Cycle.registry.cycle_classes.select(&:dormant_capable?).map(&:notation_id).compact.freeze end def self.for(notation_or_parser) @@ -48,7 +58,7 @@ def self.load(hash) def initialize(notation) @notation = notation&.upcase - @match = @notation&.match(PARTS_REGEX) + @match = @notation&.match(self.class.parts_regex) @time_span = nil end diff --git a/lib/sof/cycle/registry.rb b/lib/sof/cycle/registry.rb new file mode 100644 index 0000000..21817a0 --- /dev/null +++ b/lib/sof/cycle/registry.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module SOF + class Cycle + # The set of classes that handle cycle kinds. + # + # Registration is explicit — a class joins by declaring what it handles + # (see Cycle.handles), not by subclassing. Inheriting from Cycle for any + # other reason, such as a test double or an abstract intermediate, then + # carries no hidden side effect. + # + # The registry is also what the parser reads to recognise notations, so an + # application can add a kind of its own and have its notation parse without + # reopening this gem. + class Registry + def self.instance = @instance ||= new + + def initialize + @cycle_classes = [] + @notation_pattern = nil + end + + # Add a handler, displacing any already handling the same kind or + # notation id. That is what lets an application override a built-in + # kind — declare a class handling :lookback and it takes over "L" — + # as well as add one of its own. Re-registering the same class is a + # no-op, so a reloaded file accumulates no duplicates. + # + # Returns the class, so a declaration can use the result. + def register(cycle_class) + displaced = @cycle_classes.reject { |klass| klass.equal?(cycle_class) } + .select { |klass| conflicts?(klass, cycle_class) } + @cycle_classes -= displaced + @cycle_classes << cycle_class unless @cycle_classes.include?(cycle_class) + @notation_pattern = nil + cycle_class + end + + # Drop a handler. Mainly for an application undoing an override, and for + # tests that register a throwaway kind. + def unregister(cycle_class) + @cycle_classes.delete(cycle_class) + @notation_pattern = nil + cycle_class + end + + def cycle_classes = @cycle_classes.dup + + # The class declaring `kind`. + def handling(kind) + @cycle_classes.find { |klass| klass.handles?(kind) } || + raise(InvalidKind, "':#{kind}' is not a valid kind of Cycle") + end + + # The class declaring `notation_id` — the letter(s) that open a cycle's + # notation, such as "L" or "LE". + def for_notation_id(notation_id) + @cycle_classes.find { |klass| klass.notation_id == notation_id } || + raise(InvalidKind, "'#{notation_id}' is not a valid kind of Cycle") + end + + # Registered notation ids, longest first. A volume-only cycle has none, + # so it contributes nothing to parse. + def notation_ids + @cycle_classes.filter_map(&:notation_id).uniq.sort_by { |id| [-id.length, id] } + end + + # The alternation the parser splices into its pattern. Rebuilt whenever a + # class registers, so a kind added at runtime is recognised from then on. + def notation_pattern + @notation_pattern ||= notation_ids.map { Regexp.escape(it) }.join("|") + end + + private + + # Two handlers conflict when they answer to the same kind, or claim the + # same notation id. A nil notation id claims nothing. + def conflicts?(existing, incoming) + return true if existing.kind == incoming.kind + return false if incoming.notation_id.nil? + + existing.notation_id == incoming.notation_id + end + end + end +end diff --git a/lib/sof/cycle/time_span.rb b/lib/sof/cycle/time_span.rb index bcb2798..cd56e12 100644 --- a/lib/sof/cycle/time_span.rb +++ b/lib/sof/cycle/time_span.rb @@ -16,6 +16,78 @@ class TimeSpan # class InvalidPeriod < StandardError; end + # The set of classes measuring a period of time. Registration is + # explicit — a class joins by declaring what it measures, not by + # subclassing — which is what removes the inherited hook and lets the + # parser build its period alternation from what is registered. + # + # Internal, like DatePeriod itself: periods are not an extension point. + # Reach it through TimeSpan.period_registry. + class PeriodRegistry + def self.instance = @instance ||= new + + def initialize + @period_classes = [] + @code_pattern = nil + end + + # Add a period class, displacing any already measuring the same period + # or claiming the same code, so an application can replace a built-in as + # well as add to it. Returns the class. + def register(period_class) + displaced = @period_classes.reject { |klass| klass.equal?(period_class) } + .select { |klass| conflicts?(klass, period_class) } + @period_classes -= displaced + @period_classes << period_class unless @period_classes.include?(period_class) + @code_pattern = nil + period_class + end + + def unregister(period_class) + @period_classes.delete(period_class) + @code_pattern = nil + period_class + end + + def period_classes = @period_classes.dup + + # The class whose notation code this is, e.g. "M". Case-insensitive, + # since a notation may be written either way. Nil when unrecognised — + # DatePeriod.for falls back to its own default. + def for_code(code) + return if code.nil? + + normalized = code.to_s.upcase + @period_classes.find { |klass| klass.code == normalized } + end + + # The class measuring this period, e.g. :month. Nil when unrecognised. + def for_period(period) + @period_classes.find { |klass| klass.period.to_s == period.to_s } + end + + # Registered codes, longest first so a longer code is never shadowed by + # its own prefix. + def codes + @period_classes.filter_map(&:code).uniq.sort_by { |code| [-code.length, code] } + end + + # The alternation the parser splices in for the period key. Rebuilt + # whenever a class registers. + def code_pattern + @code_pattern ||= codes.map { Regexp.escape(it) }.join("|") + end + + private + + def conflicts?(existing, incoming) + return true if existing.period == incoming.period + return false if incoming.code.nil? + + existing.code == incoming.code + end + end + class << self # Return a time_span for the given count and period def for(count, period) @@ -39,15 +111,15 @@ def notation(hash) ].compact.join end + # The registry of period classes, owned by DatePeriod. Exposed here + # because DatePeriod is a private constant, so callers outside cannot + # name it — the parser needs the code alternation to build its pattern. + def period_registry = PeriodRegistry.instance + # 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 + period_registry.for_period(name)&.code || + raise(InvalidPeriod, "'#{name}' is not a valid period") end end @@ -69,16 +141,33 @@ def for(count, period_notation) @cached_periods[period_notation][count] end - def for_notation(notation) - DatePeriod.types.find do |klass| - klass.code == notation.to_s.upcase - end - end - - def types = @types ||= Set.new - - def inherited(klass) - DatePeriod.types << klass + def for_notation(notation) = registry.for_code(notation) + + def types = registry.period_classes + + def registry = TimeSpan.period_registry + + # Declare the period this class measures, and register it. + # + # As with Cycle.handles, registration follows from declaring, so + # subclassing DatePeriod on its own registers nothing, and an + # application can add a period of its own — its code is recognised + # because the parser builds that alternation from the registry. + # + # @param period [Symbol] the period measured, e.g. :month + # @param code [String] the notation character, e.g. "M" + # @param interval [String] the plural name used in descriptions + # + # @example + # class Fortnight < DatePeriod + # measures :fortnight, code: "N", interval: "fortnights" + # def duration = (count * 2).weeks + # end + def measures(period, code:, interval:) + @period = period + @code = code + @interval = interval + registry.register(self) end @period = nil @@ -115,9 +204,7 @@ def humanized_period end class Year < self - @period = :year - @code = "Y" - @interval = "years" + measures :year, code: "Y", interval: "years" def end_of_period(date) date.end_of_year @@ -129,9 +216,7 @@ def beginning_of_period(date) end class Quarter < self - @period = :quarter - @code = "Q" - @interval = "quarters" + measures :quarter, code: "Q", interval: "quarters" def duration (count * 3).months @@ -147,9 +232,7 @@ def beginning_of_period(date) end class Month < self - @period = :month - @code = "M" - @interval = "months" + measures :month, code: "M", interval: "months" def end_of_period(date) date.end_of_month @@ -161,9 +244,7 @@ def beginning_of_period(date) end class Week < self - @period = :week - @code = "W" - @interval = "weeks" + measures :week, code: "W", interval: "weeks" def end_of_period(date) date.end_of_week @@ -175,9 +256,7 @@ def beginning_of_period(date) end class Day < self - @period = :day - @code = "D" - @interval = "days" + measures :day, code: "D", interval: "days" def end_of_period(date) date @@ -188,7 +267,7 @@ def beginning_of_period(date) end end end - private_constant :DatePeriod + private_constant :DatePeriod, :PeriodRegistry def initialize(count, period_id) @count = Integer(count, exception: false) diff --git a/lib/sof/cycles/calendar.rb b/lib/sof/cycles/calendar.rb index 0c8d325..f5c5ffb 100644 --- a/lib/sof/cycles/calendar.rb +++ b/lib/sof/cycles/calendar.rb @@ -3,10 +3,7 @@ module SOF module Cycles class Calendar < Cycle - @volume_only = false - @notation_id = "C" - @kind = :calendar - @valid_periods = %w[M Q Y] + handles :calendar, notation: "C", periods: %w[M Q Y] class << self def frame_of_reference = "total" diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index 8cd64fb..6283818 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -11,10 +11,7 @@ module SOF module Cycles class EndOf < Cycle - @volume_only = false - @notation_id = "E" - @kind = :end_of - @valid_periods = %w[W M Q Y] + handles :end_of, notation: "E", periods: %w[W M Q Y] def self.recurring? = true diff --git a/lib/sof/cycles/interval.rb b/lib/sof/cycles/interval.rb index 9d56cf1..26a693a 100644 --- a/lib/sof/cycles/interval.rb +++ b/lib/sof/cycles/interval.rb @@ -11,10 +11,7 @@ module SOF module Cycles class Interval < Cycle - @volume_only = false - @notation_id = "I" - @kind = :interval - @valid_periods = %w[D W M Y] + handles :interval, notation: "I", periods: %w[D W M Y] def self.recurring? = true diff --git a/lib/sof/cycles/lookback.rb b/lib/sof/cycles/lookback.rb index e02cf6b..35293ab 100644 --- a/lib/sof/cycles/lookback.rb +++ b/lib/sof/cycles/lookback.rb @@ -3,10 +3,7 @@ module SOF module Cycles class Lookback < Cycle - @volume_only = false - @notation_id = "L" - @kind = :lookback - @valid_periods = %w[D W M Y] + handles :lookback, notation: "L", periods: %w[D W M Y] def self.recurring? = true diff --git a/lib/sof/cycles/lookback_end_of.rb b/lib/sof/cycles/lookback_end_of.rb index 3727536..b7c3b60 100644 --- a/lib/sof/cycles/lookback_end_of.rb +++ b/lib/sof/cycles/lookback_end_of.rb @@ -3,10 +3,7 @@ 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] + handles :lookback_end_of, notation: "LE", periods: %w[D W M Q Y] def self.recurring? = true diff --git a/lib/sof/cycles/volume_only.rb b/lib/sof/cycles/volume_only.rb index 4cb5b3a..6a657ec 100644 --- a/lib/sof/cycles/volume_only.rb +++ b/lib/sof/cycles/volume_only.rb @@ -3,10 +3,7 @@ module SOF module Cycles class VolumeOnly < Cycle - @volume_only = true - @notation_id = nil - @kind = :volume_only - @valid_periods = [] + handles :volume_only, volume_only: true class << self def handles?(sym) = sym.nil? || sym.to_s == "volume_only" diff --git a/lib/sof/cycles/within.rb b/lib/sof/cycles/within.rb index 325c996..50b49d2 100644 --- a/lib/sof/cycles/within.rb +++ b/lib/sof/cycles/within.rb @@ -3,10 +3,7 @@ module SOF module Cycles class Within < Cycle - @volume_only = false - @notation_id = "W" - @kind = :within - @valid_periods = %w[D W M Y] + handles :within, notation: "W", periods: %w[D W M Y] def self.recurring? = false diff --git a/spec/sof/custom_cycle_kind_spec.rb b/spec/sof/custom_cycle_kind_spec.rb new file mode 100644 index 0000000..29b14c8 --- /dev/null +++ b/spec/sof/custom_cycle_kind_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + # What an application gets: declare a class, and its notation parses. No + # reopening of this gem, no patching of the parser's pattern. + RSpec.describe "declaring a cycle kind from an application", type: :value do + # Registration is global, so every example puts the registry back. + around do |example| + before = Cycle.registry.cycle_classes + example.run + (Cycle.registry.cycle_classes - before).each { Cycle.registry.unregister(it) } + before.each { Cycle.registry.register(it) } + end + + describe "adding a kind of its own" do + let!(:fortnightly) do + Class.new(Cycle) do + handles :fortnightly, notation: "X", periods: %w[D W M] + + def self.name = "Fortnightly" + def self.recurring? = true + def self.description = "Fortnightly" + def self.examples = ["V1X2W"] + end + end + + it "parses its notation" do + expect(Cycle.for("V1X2W")).to be_a(fortnightly) + end + + it "reads the volume and period from the notation" do + cycle = Cycle.for("V3X2W") + + expect(cycle.volume).to eq(3) + expect(cycle.kind).to eq(:fortnightly) + expect(cycle.period_count).to eq(2) + end + + it "is reachable by kind and by notation id" do + expect(Cycle.class_for_kind(:fortnightly)).to eq(fortnightly) + expect(Cycle.class_for_notation_id("X")).to eq(fortnightly) + end + + it "validates against the periods it declared" do + expect { Cycle.for("V1X2Q") }.to raise_error(Cycle::InvalidPeriod) + end + + it "appears in the legend" do + expect(Cycle.legend["kind"]).to include("X") + end + + it "leaves the built-in kinds working" do + expect(Cycle.for("V1L30D")).to be_a(Cycles::Lookback) + expect(Cycle.for("V1LE6M")).to be_a(Cycles::LookbackEndOf) + end + end + + describe "overriding a built-in kind" do + let!(:custom_lookback) do + Class.new(Cycles::Lookback) do + handles :lookback, notation: "L", periods: %w[D W M Y] + + def self.name = "CustomLookback" + def self.description = "Custom" + def self.examples = ["V1L30D"] + def to_s = "custom lookback" + end + end + + it "takes over the notation from the built-in class" do + expect(Cycle.for("V1L30D")).to be_a(custom_lookback) + expect(Cycle.for("V1L30D").to_s).to eq("custom lookback") + end + + it "displaces the built-in rather than sitting alongside it" do + expect(Cycle.registry.cycle_classes).not_to include(Cycles::Lookback) + end + + it "does not disturb the kind whose notation merely shares a prefix" do + expect(Cycle.for("V1LE6M")).to be_a(Cycles::LookbackEndOf) + end + end + + # The parser compiles its pattern once and reuses it, so a kind declared + # after parsing has already begun — an initializer running after first + # use — only works if that cache is keyed to what is registered. + describe "declaring a kind after parsing has already happened" do + it "recognises the new notation anyway" do + Cycle.for("V1L30D") # compile the pattern with only the built-ins + + Class.new(Cycle) do + handles :late, notation: "Z", periods: %w[D] + def self.name = "Late" + def self.recurring? = true + end + + expect(Cycle.for("V1Z5D").kind).to eq(:late) + end + + it "stops recognising a notation once its class is unregistered" do + late = Class.new(Cycle) do + handles :late, notation: "Z", periods: %w[D] + def self.name = "Late" + def self.recurring? = true + end + expect(Cycle.for("V1Z5D").kind).to eq(:late) + + Cycle.registry.unregister(late) + + expect { Cycle.for("V1Z5D") }.to raise_error(Cycle::InvalidInput) + end + end + + # Inheriting is the easy path, not a requirement: Cycle.for asks the + # registry for a class and builds it, so anything answering the same + # questions can stand in. + describe "registering a class that does not inherit from Cycle" do + let!(:standalone) do + Class.new do + def self.name = "Standalone" + def self.kind = :standalone + def self.notation_id = "S" + def self.valid_periods = %w[D W M] + def self.volume_only? = false + def self.handles?(sym) = sym.to_s == "standalone" + def self.dormant_capable? = false + def self.recurring? = true + def self.description = "Standalone" + def self.examples = ["V1S3D"] + def self.validate_period(_) = nil + + def initialize(notation, parser:) + @notation = notation + @parser = parser + end + + def to_s = "standalone cycle" + end.tap { Cycle.register(it) } + end + + it "parses its notation and builds the registered class" do + expect(Cycle.for("V1S3D")).to be_a(standalone) + end + + it "is reachable by kind" do + expect(Cycle.class_for_kind(:standalone)).to eq(standalone) + end + + it "is dropped again by unregister" do + Cycle.unregister(standalone) + + expect { Cycle.for("V1S3D") }.to raise_error(Cycle::InvalidInput) + end + end + + describe "subclassing without declaring" do + it "registers nothing, so an abstract subclass is not a handler" do + abstract = Class.new(Cycle) { def self.name = "Abstract" } + + expect(Cycle.registry.cycle_classes).not_to include(abstract) + end + end + end +end diff --git a/spec/sof/cycle/registry_spec.rb b/spec/sof/cycle/registry_spec.rb new file mode 100644 index 0000000..9c6f281 --- /dev/null +++ b/spec/sof/cycle/registry_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "spec_helper" + +module SOF + RSpec.describe Cycle::Registry do + subject(:registry) { described_class.new } + + # A stand-in handler. Registration takes anything answering the three + # questions the registry asks, so specs need no real Cycle subclass. + def handler(kind:, notation_id: nil) + Class.new do + define_singleton_method(:kind) { kind } + define_singleton_method(:notation_id) { notation_id } + define_singleton_method(:handles?) { |sym| kind.to_s == sym.to_s } + define_singleton_method(:inspect) { "handler(#{kind})" } + end + end + + describe "#register" do + it "adds a class and reports it as registered" do + klass = handler(kind: :lookback, notation_id: "L") + + registry.register(klass) + + expect(registry.cycle_classes).to include(klass) + end + + it "is idempotent — registering twice keeps one entry" do + klass = handler(kind: :lookback, notation_id: "L") + + 2.times { registry.register(klass) } + + expect(registry.cycle_classes.count(klass)).to eq(1) + end + + it "returns the registered class so a declaration can chain" do + klass = handler(kind: :lookback, notation_id: "L") + + expect(registry.register(klass)).to eq(klass) + end + end + + describe "#handling" do + it "finds the class declaring that kind" do + lookback = registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :calendar, notation_id: "C")) + + expect(registry.handling(:lookback)).to eq(lookback) + end + + it "matches a string kind as well as a symbol" do + lookback = registry.register(handler(kind: :lookback, notation_id: "L")) + + expect(registry.handling("lookback")).to eq(lookback) + end + + it "raises InvalidKind for an unknown kind" do + expect { registry.handling(:nonsense) } + .to raise_error(Cycle::InvalidKind, /nonsense/) + end + end + + describe "#for_notation_id" do + it "finds the class declaring that notation id" do + lookback_end_of = registry.register(handler(kind: :lookback_end_of, notation_id: "LE")) + registry.register(handler(kind: :lookback, notation_id: "L")) + + expect(registry.for_notation_id("LE")).to eq(lookback_end_of) + end + + it "raises InvalidKind for an unknown notation id" do + expect { registry.for_notation_id("Z") } + .to raise_error(Cycle::InvalidKind, /Z/) + end + end + + describe "#notation_ids" do + it "omits classes with no notation id, such as volume-only" do + registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :volume_only, notation_id: nil)) + + expect(registry.notation_ids).to eq(["L"]) + end + + it "orders longest first so a longer id is never shadowed by its prefix" do + registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :lookback_end_of, notation_id: "LE")) + + expect(registry.notation_ids).to eq(%w[LE L]) + end + end + + describe "#notation_pattern" do + it "builds an alternation of the registered notation ids" do + registry.register(handler(kind: :lookback, notation_id: "L")) + registry.register(handler(kind: :lookback_end_of, notation_id: "LE")) + + expect(registry.notation_pattern).to eq("LE|L") + end + + it "escapes ids so a regex-significant character cannot alter the pattern" do + registry.register(handler(kind: :odd, notation_id: "A.B")) + + expect(registry.notation_pattern).to eq("A\\.B") + end + + it "picks up a class registered after the pattern was first built" do + registry.register(handler(kind: :lookback, notation_id: "L")) + expect(registry.notation_pattern).to eq("L") + + registry.register(handler(kind: :custom, notation_id: "X")) + + expect(registry.notation_pattern).to eq("L|X") + end + end + end +end