From e26a02584bdd777d6ad2b2dbc315d0f05d7a9b75 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 12 Sep 2026 08:52:40 -0700 Subject: [PATCH] fix: handle invalid dates in feed ordering Use the first valid publication or update timestamp across latest, items_since, and merge. Preserve malformed values and source order for equal or missing dates, including dates before 1970. Add regression coverage for fallback dates, stable ordering, precision, merge identity rules, and non-mutating results. Document the date selection contract. Closes #58 --- README.md | 14 ++++- lib/simple-rss.rb | 20 +++---- test/base/date_ordering_test.rb | 95 +++++++++++++++++++++++++++++++++ test/base/enumerable_test.rb | 16 ++++++ test/data/mixed_dates.xml | 55 +++++++++++++++++++ 5 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 test/base/date_ordering_test.rb create mode 100644 test/data/mixed_dates.xml diff --git a/README.md b/README.md index 6f10891..079e1bb 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,22 @@ total = feed.count feed[0].title # first item feed[-1].title # last item -# Get the n most recent items (sorted by pubDate or updated) +# Get the n most recent items feed.latest(10) ``` +`latest`, `items_since`, and merge ordering use the first successfully parsed date +from `pubDate`, `updated`, and `published`, in that order. Invalid date strings +remain available in the original fields. `latest` places entries without a usable +date after dated entries, including dates before 1970. Equal dates and undated +entries retain their source order, and `latest` does not modify the feed. + +`items_since(time)` returns entries strictly newer than the given time in source +order, excluding entries without a usable date. Merging sorts identified entries +by the same date rules and keeps the newest entry for each identity. Equal dates +keep the first occurrence. Entries without an identity remain at the end in input +order, regardless of their dates. + ### JSON Serialization ```ruby diff --git a/lib/simple-rss.rb b/lib/simple-rss.rb index f451525..b364c56 100644 --- a/lib/simple-rss.rb +++ b/lib/simple-rss.rb @@ -95,7 +95,7 @@ def [](index) # # @rbs (?Integer) -> Array[Hash[Symbol, untyped]] def latest(count = 10) - items.sort_by { |item| item[:pubDate] || item[:updated] || Time.at(0) }.reverse.first(count) + sorted_items_by_date(items).first(count) end # @rbs () -> Symbol @@ -123,8 +123,8 @@ def valid? # @rbs (Time) -> Array[Hash[Symbol, untyped]] def items_since(time) items.select do |item| - item_date = item[:pubDate] || item[:updated] || item[:published] - item_date.is_a?(Time) && item_date > time + date = item_date(item) + date && date > time end end @@ -152,7 +152,8 @@ def search(query) # @rbs (*SimpleRSS) -> Array[Hash[Symbol, untyped]] def merge(*feeds) all_items = [items, *feeds.map(&:items)].flatten - dedupe_items(sorted_items_by_date(all_items)) + keyed_items, unkeyed_items = all_items.partition { |item| !item_key(item).nil? } + dedupe_items(sorted_items_by_date(keyed_items) + unkeyed_items) end # @rbs (SimpleRSS) -> Hash[Symbol, Array[Hash[Symbol, untyped]]] @@ -579,8 +580,10 @@ def select_new_keyed_items(item_list, known_keys) # @rbs (Array[Hash[Symbol, untyped]]) -> Array[Hash[Symbol, untyped]] def sorted_items_by_date(item_list) - keyed_items, unkeyed_items = item_list.partition { |item| !item_key(item).nil? } - keyed_items.sort_by { |item| item_date(item) || Time.at(0) }.reverse + unkeyed_items + item_list.sort_by.with_index do |item, index| + date = item_date(item) + [date ? -date.to_r : Float::INFINITY, index] + end end # @rbs (Array[Hash[Symbol, untyped]]) -> Array[Hash[Symbol, untyped]] @@ -612,10 +615,7 @@ def item_key(item) # @rbs (Hash[Symbol, untyped]) -> Time? def item_date(item) - date = item[:pubDate] || item[:updated] || item[:published] - return date if date.is_a?(Time) - - nil + [item[:pubDate], item[:updated], item[:published]].find { |date| date.is_a?(Time) } end # @rbs (Hash[Symbol, untyped]) -> Array[String] diff --git a/test/base/date_ordering_test.rb b/test/base/date_ordering_test.rb new file mode 100644 index 0000000..efe65c0 --- /dev/null +++ b/test/base/date_ordering_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class DateOrderingTest < Test::Unit::TestCase + def setup + @feed = SimpleRSS.parse(File.read(File.join(__dir__, "../data/mixed_dates.xml"))) + end + + def test_latest_uses_valid_fallbacks_and_preserves_equal_date_order + assert_equal [ + "Updated first", "Updated second", "Fractional", "Published", "Publication", + "Historical", "Missing first", "Blank", "Invalid" + ], @feed.latest.map(&:title) + end + + def test_latest_limits_results_without_changing_original_entries + original = @feed.to_hash + + assert_equal ["Updated first", "Updated second", "Fractional"], @feed.latest(3).map(&:title) + assert_equal [], @feed.latest(0) + assert_same @feed.items[3], @feed.latest.first + assert_equal original, @feed.to_hash + end + + def test_latest_sorts_atom_entries_with_only_published_dates + feed = SimpleRSS.parse <<~XML + + Publication dates + Newer2026-09-03T00:00:00Z + Older2026-09-01T00:00:00Z + + XML + + assert_equal %w[Newer Older], feed.latest.map(&:title) + end + + def test_items_since_uses_valid_fallbacks_and_excludes_undated_entries + assert_equal ["Fractional", "Updated first", "Published", "Updated second"], + @feed.items_since(Time.iso8601("2026-09-01T00:00:00Z")).map(&:title) + assert_equal ["Fractional", "Updated first", "Updated second"], + @feed.items_since(Time.iso8601("2026-09-02T00:00:00Z")).map(&:title) + assert_equal [], @feed.items_since(Time.iso8601("2026-09-04T00:00:00Z")) + end + + def test_merge_sorts_dated_entries_before_undated_identified_entries + assert_equal [ + "Updated first", "Updated second", "Fractional", "Published", "Publication", + "Historical", "Blank", "Invalid", "Missing first" + ], @feed.merge.map(&:title) + end + + def test_merge_uses_fallback_dates_to_dedupe_and_keeps_unidentified_entries_last + other_feed = SimpleRSS.parse <<~XML + + + Other feed + + updated-first + Replacement + not-a-date + 2026-09-04T00:00:00Z + + Unidentified newest2030-01-01T00:00:00Z + + + XML + original = @feed.to_hash + other_original = other_feed.to_hash + + assert_equal [ + "Replacement", "Updated second", "Fractional", "Published", "Publication", + "Historical", "Blank", "Invalid", "Missing first", "Unidentified newest" + ], SimpleRSS.merge(@feed, other_feed).map(&:title) + assert_equal original, @feed.to_hash + assert_equal other_original, other_feed.to_hash + end + + def test_merge_keeps_the_first_duplicate_when_dates_are_equal + other_feed = SimpleRSS.parse <<~XML + + + Other feed + + updated-second + Later duplicate + 2026-09-03T00:00:00.000000002Z + + + + XML + + duplicates = @feed.merge(other_feed).select { |item| item[:guid] == "updated-second" } + + assert_equal ["Updated second"], duplicates.map(&:title) + end +end diff --git a/test/base/enumerable_test.rb b/test/base/enumerable_test.rb index c3fed6c..dd36d07 100644 --- a/test/base/enumerable_test.rb +++ b/test/base/enumerable_test.rb @@ -98,4 +98,20 @@ def test_latest_handles_missing_dates assert_equal "Has Date", latest.first[:title] assert_equal "No Date", latest.last[:title] end + + def test_latest_preserves_invalid_dates_without_failing + feed = SimpleRSS.parse <<~XML + + + Mixed dates + Invalidnot-a-date + Valid2026-09-01T00:00:00Z + + + XML + + assert_equal %w[Valid Invalid], feed.latest.map(&:title) + assert_equal "not-a-date", feed.first.pubDate + assert_equal %w[Invalid Valid], feed.map(&:title) + end end diff --git a/test/data/mixed_dates.xml b/test/data/mixed_dates.xml new file mode 100644 index 0000000..b8696db --- /dev/null +++ b/test/data/mixed_dates.xml @@ -0,0 +1,55 @@ + + + Mixed dates + + Missing first + + + publication + Publication + 2026-09-01T00:00:00Z + 2026-09-05T00:00:00Z + 2026-09-06T00:00:00Z + + + fractional + Fractional + 2026-09-03T00:00:00.000000001Z + + + updated-first + Updated first + not-a-date + 2026-09-03T00:00:00.000000002Z + 2026-09-07T00:00:00Z + + + published + Published + + not-a-date + 2026-09-02T00:00:00Z + + + historical + Historical + 1965-01-01T00:00:00Z + + + updated-second + Updated second + 2026-09-03T01:00:00.000000002+01:00 + + + blank + Blank + + + + invalid + Invalid + not-a-date + not-a-date + + +