Files
bitvid/tests/trending-sorter.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 02854d4fb5 feed: add Trending tab — recently-added ranked by view count (#27)
New FEED_TYPES.TRENDING tab: the active (recently-added) source ranked by view
count (recency tiebreak, trusted-muted last), gated by FEATURE_TRENDING_FEED
(default on). Builds on the now-trustworthy view counts (#4).

- createTrendingSorter reads counts from runtime.getViewCount, which resolves
  each video's pointer via the app's canonical deriveVideoPointerInfo against the
  shared viewCounter cache (new getVideoViewCountSnapshot export) — the same
  counts the grid cards already load.
- Live re-rank: viewCounter now emits a coalesced onViewCountsChanged signal;
  while the Trending view is active it debounce-re-runs the feed (cheap re-rank
  over the in-memory active set, no relay re-fetch), so the order settles into
  true trending as counts stream in. Cold cache reads as recency, then improves.
- Pipeline extracted to js/feedEngine/trendingFeed.js (feedCoordinator is at its
  size cap; bumped the cap for the thin load/refresh delegations).
- Wiring: FEED_TYPES + flag, feedCoordinator cases, app.js wrappers,
  applicationBootstrap registration (hides the sidebar link when flag off),
  viewManager init + js/trendingView.js, views/trending.html, sidebar link.

Tests: tests/trending-sorter.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:06:34 -04:00

71 lines
2.3 KiB
JavaScript

// #27: the Trending feed ranks the active set by VIEW COUNT (recency breaks
// ties), with unknown/not-yet-loaded counts treated as 0 and trusted-muted last.
// The sorter reads counts from the injected runtime.getViewCount (the shared
// viewCounter cache).
import test from "node:test";
import assert from "node:assert/strict";
import { createTrendingSorter } from "../js/feedEngine/sorters.js";
const item = (id, { views, created_at = 1000, muted = false } = {}) => ({
video: {
id,
pubkey: "a".repeat(64),
created_at,
...(muted ? { moderation: { trustedMuted: true } } : {}),
},
metadata: {},
__views: views,
});
const sort = (items) => {
const counts = new Map(items.map((i) => [i.video.id, i.__views]));
return createTrendingSorter()(items, {
runtime: { getViewCount: (video) => counts.get(video?.id) },
}).map((i) => i.video.id);
};
test("ranks by view count descending", () => {
assert.deepEqual(
sort([item("low", { views: 5 }), item("high", { views: 100 }), item("mid", { views: 50 })]),
["high", "mid", "low"],
);
});
test("recency breaks ties on equal views", () => {
assert.deepEqual(
sort([
item("older", { views: 10, created_at: 1000 }),
item("newer", { views: 10, created_at: 2000 }),
]),
["newer", "older"],
);
});
test("unknown counts are treated as 0 (rank below known views, recency among them)", () => {
const ids = sort([
item("unknown-old", { views: undefined, created_at: 1000 }),
item("known", { views: 3, created_at: 500 }),
item("unknown-new", { views: undefined, created_at: 2000 }),
]);
assert.equal(ids[0], "known", "a known-view video leads the not-yet-loaded ones");
assert.deepEqual(ids.slice(1), ["unknown-new", "unknown-old"], "unknowns fall back to recency");
});
test("trusted-muted sinks last even if most-viewed", () => {
assert.deepEqual(
sort([item("muted-popular", { views: 1000, muted: true }), item("plain", { views: 1 })]),
["plain", "muted-popular"],
);
});
test("tolerates non-array input and a missing getViewCount", () => {
assert.deepEqual(createTrendingSorter()(null, {}), []);
// No runtime.getViewCount → all 0 → recency order.
const out = createTrendingSorter()(
[item("a", { created_at: 1 }), item("b", { created_at: 2 })],
{},
).map((i) => i.video.id);
assert.deepEqual(out, ["b", "a"]);
});