Files
bitvid/tests/search-filters.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 a6a6d4b0f7 [search] Wire sort/textScope/relay:, fix dual-type duration, drop dead flag, first tests
Audit found the search pipeline largely real (token parser, local + NIP-50 relay
search, enforced author/tag/date/has/nsfw/kind filters, wired modal) with dead
spots, now fixed:

- sort: offered in the modal but never applied. New pure sortSearchResults —
  recent (default), views (shared view-count cache), trending (views weighted by
  recency), longest — applied in searchView.
- duration filter only read nip71.duration, so bitvid's OWN kind-30078 videos
  (top-level duration) never matched. Both video types now satisfy duration
  filters; same shapes cover the planned live (30311) / shorts kinds.
- textScope now enforced in the local title/description match (all|title|description).
- relay: now scopes the video relay-search to that relay (was parsed-but-unused).
- FEATURE_SEARCH_FILTERS removed: dev-only flag consumed by NOTHING while the
  filter UI ships and works everywhere (plumbing + setter deleted, no consumers).

Tests (tests/search-filters.test.mjs, 4 — surface previously had zero): parser
tokens; matcher enforces tags/date/has:magnet/duration for BOTH duration shapes;
views/longest/recent sorts (no input mutation); trending ranks fresh-riser above
stale-hit. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:07:22 -04:00

119 lines
4.4 KiB
JavaScript

// Search pipeline coverage (was zero): the token parser, the video filter
// matcher, and the result sorter — the pure core of search.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-search-core
// given: "query strings / videos with varying metadata"
// when: "parseFilterQuery / buildVideoSearchFilterMatcher / sortSearchResults run"
// then: "tokens parse into filters; matcher enforces them; sort honors the selected mode"
// observable_outcomes:
// - "tag:/has:/duration:< tokens land in filters + free text preserved"
// - "matcher enforces tags, date range, has:magnet, duration bounds"
// - "sort: views ranks by injected view counts; longest by duration; recent by created_at"
// determinism_controls:
// - "pure functions; injected getViews + fixed now"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { parseFilterQuery } from "../js/search/searchFilters.js";
import {
buildVideoSearchFilterMatcher,
sortSearchResults,
} from "../js/search/searchFilterMatchers.js";
test("parser: tokens land in filters, free text preserved", () => {
const { text, filters, errors } = parseFilterQuery(
"cat videos tag:nostr has:magnet duration:<120",
);
assert.equal(errors.length, 0);
assert.equal(text, "cat videos");
assert.deepEqual(filters.tags, ["nostr"]);
assert.equal(filters.hasMagnet, true);
assert.equal(filters.duration.maxSeconds, 120);
});
test("matcher enforces tags, date range, has:magnet, duration", () => {
const video = {
pubkey: "a".repeat(64),
created_at: 1_700_000_000,
tags: [["t", "nostr"]],
magnet: "magnet:?xt=urn:btih:abc",
nip71: { duration: 90 }, // ingested NIP-71 shape (bitvid kind uses top-level)
};
const pass = buildVideoSearchFilterMatcher({
tags: ["nostr"],
hasMagnet: true,
duration: { minSeconds: 60, maxSeconds: 120 },
dateRange: { after: 1_699_999_999, before: 1_700_000_001 },
});
assert.equal(pass(video), true);
assert.equal(pass({ ...video, tags: [] }), false, "missing tag rejected");
assert.equal(pass({ ...video, magnet: "" }), false, "has:magnet rejected");
assert.equal(
pass({ ...video, nip71: { duration: 30 } }),
false,
"too short rejected",
);
// bitvid's own kind-30078 videos carry duration TOP-LEVEL — both video types
// must satisfy duration filters (spec: two video kinds, more coming for
// live/shorts per the dev plans).
assert.equal(
pass({ ...video, nip71: undefined, duration: 90 }),
true,
"top-level duration (bitvid kind) matches too",
);
assert.equal(
pass({ ...video, created_at: 1_690_000_000 }),
false,
"outside date range rejected",
);
});
test("sortSearchResults honors views / longest / recent (and defaults to recent)", () => {
const videos = [
{ id: "old-popular", created_at: 100, duration: 10 },
{ id: "new-quiet", created_at: 300, duration: 50 },
{ id: "mid-long", created_at: 200, duration: 500 },
];
const viewsById = { "old-popular": 900, "new-quiet": 5, "mid-long": 40 };
const getViews = (v) => viewsById[v.id];
assert.deepEqual(
sortSearchResults(videos, "views", { getViews }).map((v) => v.id),
["old-popular", "mid-long", "new-quiet"],
);
assert.deepEqual(
sortSearchResults(videos, "longest", { getViews }).map((v) => v.id),
["mid-long", "new-quiet", "old-popular"],
);
assert.deepEqual(
sortSearchResults(videos, "recent", { getViews }).map((v) => v.id),
["new-quiet", "mid-long", "old-popular"],
);
// Unknown/default -> recent; input array is not mutated.
const copy = [...videos];
sortSearchResults(videos, undefined, { getViews });
assert.deepEqual(videos, copy);
});
test("sort: trending weights views by recency", () => {
const now = 1_700_000_000_000; // ms
const daySec = 86400;
const videos = [
{ id: "old-hit", created_at: now / 1000 - 30 * daySec }, // 900 views, 30d old
{ id: "fresh-riser", created_at: now / 1000 - 1 * daySec }, // 60 views, 1d old
];
const getViews = (v) => (v.id === "old-hit" ? 900 : 60);
const ranked = sortSearchResults(videos, "trending", { getViews, now }).map(
(v) => v.id,
);
assert.deepEqual(ranked, ["fresh-riser", "old-hit"]);
});