import at_record/gen/defs.{CatalogRef} import at_record/gen/feed/get_feed_skeleton.{ type EntryRef, type FeedItem, EntryRef, FeedItem, FeedItemReasonReasonActorBatch, FeedItemReasonReasonImport, FeedItemReasonReasonSingle, FeedItemReasonReasonSubjectConverge, ReasonActorBatch, ReasonImport, ReasonSingle, ReasonSubjectConverge, } import at_record_web/model.{ type FeedEntry, type Model, Feed, FeedEntry, FeedFailed, FeedLoaded, FeedLoading, Model, } import at_record_web/msg.{ FeedEntryData, FeedShowMore, FeedSkeletonData, GotFeedEntry, GotFeedMore, GotFeedSkeleton, OnRouteChange, } import at_record_web/pages/feed import at_record_web/update.{update} import gleam/dict import gleam/list import gleam/option.{None, Some} import gleam/string import lustre/element import rsvp import support.{an_entry, base, empty_effect, logged_in} // --- builders ------------------------------------------------------------ fn ref(actor: String, entry_id: String) -> EntryRef { EntryRef(actor:, entry_id:) } fn single(actor: String, entry_id: String, action: String) -> FeedItem { FeedItem( entries: [ref(actor, entry_id)], reason: FeedItemReasonReasonSingle(ReasonSingle(action:)), ) } fn batch(refs: List(EntryRef), action: String) -> FeedItem { FeedItem( entries: refs, reason: FeedItemReasonReasonActorBatch(ReasonActorBatch( action:, window_start: "2026-07-18T00:00:00Z", window_end: "2026-07-18T23:59:59Z", )), ) } fn converge(refs: List(EntryRef), subject_uri: String) -> FeedItem { FeedItem( entries: refs, reason: FeedItemReasonReasonSubjectConverge(ReasonSubjectConverge( action: "acquired", subject: CatalogRef(cid: "bafycid", external_ids: None, uri: subject_uri), )), ) } fn import_item( refs: List(EntryRef), source: option.Option(String), ) -> FeedItem { FeedItem( entries: refs, reason: FeedItemReasonReasonImport(ReasonImport(source:)), ) } fn fe(handle: String, updated_at: String) -> FeedEntry { FeedEntry( did: "did:" <> handle, handle:, entry: an_entry(), release_info: None, updated_at:, ) } fn with_feed( items: List(FeedItem), fallback: Bool, entries: List(#(#(String, String), FeedEntry)), ) -> Model { Model( ..logged_in(), feed: FeedLoaded(items, None, fallback), feed_entries: dict.from_list(entries), ) } fn render(model: Model) -> String { feed.view(model) |> element.to_string } fn occurrences(haystack: String, needle: String) -> Int { list.length(string.split(haystack, needle)) - 1 } // --- states -------------------------------------------------------------- pub fn feed_view_renders_the_designed_empty_state_test() { let html = render(with_feed([], False, [])) assert string.contains(html, "class=\"empty-state\"") assert string.contains(html, "THE FEED IS QUIET") assert string.contains( html, "follow collectors from their public crates to see what they add, want, and fix", ) assert string.contains(html, "BROWSE THE NETWORK") assert string.contains(html, "href=\"/browse\"") } pub fn feed_loading_state_shows_a_fetching_placeholder_test() { let html = render(Model(..logged_in(), feed: FeedLoading)) assert string.contains(html, "FETCHING") } pub fn feed_failed_state_shows_the_couldnt_load_copy_test() { let html = render(Model(..logged_in(), feed: FeedFailed)) assert string.contains(html, "error-state__sticker") assert string.contains(html, "load the feed right now") } pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { let #(model, _) = update(logged_in(), GotFeedSkeleton(Error(rsvp.NetworkError))) assert model.feed == FeedFailed assert case model.notice { Some(model.Notice(model.Failure, _)) -> True _ -> False } assert string.contains(render(model), "load the feed right now") } // --- reason rows: hydrated ---------------------------------------------- pub fn single_reason_hydrated_renders_header_and_cover_row_test() { let html = render( with_feed([single("did:a", "e0", "added")], False, [ #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), ]), ) assert string.contains(html, "@alice.test added") assert string.contains(html, "Spiderland") assert string.contains(html, "class=\"row\"") assert string.contains(html, "href=\"/u/alice.test/record/e0\"") } pub fn actor_batch_hydrated_renders_count_and_strip_test() { let refs = [ref("did:a", "e0"), ref("did:a", "e1")] let html = render( with_feed([batch(refs, "added")], False, [ #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), #(#("did:a", "e1"), fe("alice.test", "2026-07-18T11:00:00Z")), ]), ) assert string.contains(html, "@alice.test added 2 records") assert string.contains(html, "class=\"feed-strip\"") } pub fn subject_converge_hydrated_links_to_the_pressing_test() { let refs = [ref("did:a", "e0"), ref("did:b", "e9")] let subject = "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1" let html = render( with_feed([converge(refs, subject)], False, [ #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), ]), ) assert string.contains(html, "2 collectors picked up the same pressing") assert string.contains(html, "class=\"feed-avatar-cluster\"") assert string.contains(html, "href=\"/pressing/did:x/rk1\"") } pub fn import_reason_via_line_reflects_the_source_test() { let refs = [ref("did:a", "e0")] let hydrated = [#(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z"))] let with_source = render(with_feed([import_item(refs, Some("discogs"))], False, hydrated)) assert string.contains(with_source, "@alice.test imported 1 record") assert string.contains(with_source, "via Discogs") assert string.contains(with_source, "class=\"feed-strip\"") let without_source = render(with_feed([import_item(refs, None)], False, hydrated)) assert string.contains(without_source, "@alice.test imported 1 record") assert !string.contains(without_source, " via ") } // --- reason rows: unhydrated (skeleton, no crash) ----------------------- pub fn every_reason_renders_a_skeleton_when_unhydrated_test() { let refs = [ref("did:a", "e0"), ref("did:b", "e1")] [ single("did:a", "e0", "added"), batch(refs, "added"), converge(refs, "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1"), import_item(refs, Some("discogs")), ] |> list.each(fn(item) { let html = render(with_feed([item], False, [])) assert string.contains(html, "feed-row--skeleton") }) } // --- fallback label ------------------------------------------------------ pub fn fallback_label_shows_only_when_true_test() { let hydrated = [#(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z"))] [True, False] |> list.each(fn(fallback) { let html = render(with_feed([single("did:a", "e0", "added")], fallback, hydrated)) assert string.contains(html, "FROM THE WIDER NETWORK") == fallback }) } // --- day separators ------------------------------------------------------ pub fn day_separator_count_reflects_distinct_and_same_hydrated_days_test() { let items = [single("did:a", "e0", "added"), single("did:a", "e1", "added")] let e0 = #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")) let different_days = render( with_feed(items, False, [ e0, #(#("did:a", "e1"), fe("alice.test", "2026-07-17T10:00:00Z")), ]), ) assert occurrences(different_days, "class=\"feed-day\"") == 2 assert string.contains(different_days, "2026-07-18") assert string.contains(different_days, "2026-07-17") let same_day = render( with_feed(items, False, [ e0, #(#("did:a", "e1"), fe("alice.test", "2026-07-18T09:00:00Z")), ]), ) assert occurrences(same_day, "class=\"feed-day\"") == 1 } // --- update: routing + pagination --------------------------------------- pub fn on_route_change_feed_sets_loading_and_fires_a_load_test() { let #(model, effect) = update(logged_in(), OnRouteChange(Feed)) assert model.feed == FeedLoading assert effect != empty_effect() } pub fn got_feed_skeleton_stores_items_and_hydrates_test() { let data = FeedSkeletonData([single("did:a", "e0", "added")], False, None) let #(model, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) let assert FeedLoaded(items, _, _) = model.feed assert list.length(items) == 1 // Uncached refs mean a real hydration effect fires. assert effect != empty_effect() } pub fn got_feed_skeleton_with_no_items_fires_no_hydration_test() { let data = FeedSkeletonData([], False, None) let #(_, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) assert effect == empty_effect() } pub fn got_feed_entry_ok_caches_the_entry_test() { let data = FeedEntryData( did: "did:a", handle: "alice.test", entry: an_entry(), release: None, updated_at: "2026-07-18T10:00:00Z", ) let #(model, _) = update(logged_in(), GotFeedEntry("did:a", "e0", Ok(data))) assert dict.has_key(model.feed_entries, #("did:a", "e0")) } pub fn got_feed_entry_error_leaves_the_cache_untouched_test() { let #(model, _) = update(logged_in(), GotFeedEntry("did:a", "e0", Error(rsvp.NetworkError))) assert dict.size(model.feed_entries) == 0 assert model.notice == None } pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { let seeded = Model( ..logged_in(), feed: FeedLoaded( [single("did:a", "e0", "added"), single("did:a", "e1", "added")], Some("cursor1"), False, ), feed_loading_more: True, ) // The next page repeats e1 (fold shifted between pages) and adds e2. let next = FeedSkeletonData( [single("did:a", "e1", "added"), single("did:a", "e2", "added")], False, None, ) let #(updated, _) = update(seeded, GotFeedMore(Ok(next))) let assert FeedLoaded(items, _, _) = updated.feed assert item_keys(items) == [#("did:a", "e0"), #("did:a", "e1"), #("did:a", "e2")] assert updated.feed_loading_more == False } fn item_keys(items: List(FeedItem)) -> List(#(String, String)) { list.filter_map(items, fn(item) { model.feed_item_key(item) |> option.to_result(Nil) }) } pub fn feed_show_more_fetches_only_with_a_cursor_and_not_already_loading_test() { let item = single("did:a", "e0", "added") [ #(FeedLoaded([item], Some("c"), False), False, True), #(FeedLoaded([item], None, False), False, False), #(FeedLoaded([item], Some("c"), False), True, False), ] |> list.each(fn(row) { let #(feed_state, loading, expect_fetch) = row let seeded = Model(..logged_in(), feed: feed_state, feed_loading_more: loading) let #(_, effect) = update(seeded, FeedShowMore) // A fetch fires only when a cursor exists and none is already in flight. assert { effect != empty_effect() } == expect_fetch }) } // --- pure helpers -------------------------------------------------------- pub fn feed_item_day_takes_the_newest_hydrated_day_test() { let item = batch([ref("did:a", "e0"), ref("did:a", "e1")], "added") let cache = dict.from_list([ #(#("did:a", "e0"), fe("alice.test", "2026-07-17T10:00:00Z")), #(#("did:a", "e1"), fe("alice.test", "2026-07-18T10:00:00Z")), ]) assert model.feed_item_day(item, cache) == Some("2026-07-18") } pub fn feed_item_day_is_none_when_unhydrated_test() { let item = single("did:a", "e0", "added") assert model.feed_item_day(item, dict.new()) == None } pub fn feed_item_key_is_the_first_ref_pair_test() { let item = batch([ref("did:a", "e0"), ref("did:b", "e1")], "added") assert model.feed_item_key(item) == Some(#("did:a", "e0")) } pub fn feed_item_key_is_none_for_an_empty_item_test() { let item = FeedItem( entries: [], reason: FeedItemReasonReasonSingle(ReasonSingle("added")), ) assert model.feed_item_key(item) == None } // unused import guard: base() keeps the shared builder referenced here. pub fn base_model_has_a_feed_field_test() { assert base().feed == FeedLoading }