mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The Author field (and the author: token) passed values through as-typed, but the matcher compares against video.pubkey — HEX. So entering an npub (the only format users actually have) silently returned zero results; only raw hex worked. New normalizeAuthorValues (searchFilters.js, injectable decoder for tests): npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and undecodable values are dropped — surfaced as parse errors on the author: token path. Applied in BOTH the parser and the filter modal's applyFilters, so the Author panel field and the token syntax now genuinely filter by creator. Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an injected decoder; parser hex passthrough + garbage error). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
161 lines
6.2 KiB
JavaScript
161 lines
6.2 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"]);
|
|
});
|
|
|
|
// Author values arrive as npubs but videos carry hex pubkeys — without decoding,
|
|
// npub authors silently matched nothing (the "wired but wrong" filter).
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-search-author-npub
|
|
// given: "author values as npub / hex / garbage"
|
|
// when: "normalizeAuthorValues runs (with an injected decoder)"
|
|
// then: "npubs decode to hex, hex passes through lowercased, garbage is dropped with errors"
|
|
// observable_outcomes:
|
|
// - "npub -> decoded hex; HEX passthrough lowercased; undecodable -> dropped"
|
|
// - "parser author: token emits decode errors for garbage"
|
|
// determinism_controls:
|
|
// - "injected decodeNpub stub (no window.NostrTools dependency)"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
test("normalizeAuthorValues decodes npubs, passes hex, drops garbage", async () => {
|
|
const { normalizeAuthorValues } = await import("../js/search/searchFilters.js");
|
|
const HEX = "a".repeat(64);
|
|
const decodeNpub = (v) => (v === "npub1good" ? HEX : null);
|
|
|
|
const { normalized, dropped } = normalizeAuthorValues(
|
|
["npub1good", "B".repeat(64), "npub1bad", "garbage"],
|
|
{ decodeNpub },
|
|
);
|
|
assert.deepEqual(normalized, [HEX, "b".repeat(64)]);
|
|
assert.deepEqual(dropped, ["npub1bad", "garbage"]);
|
|
});
|
|
|
|
test("author: token with hex passes into filters; garbage surfaces as an error", () => {
|
|
const HEX = "c".repeat(64);
|
|
const good = parseFilterQuery(`author:${HEX}`);
|
|
assert.deepEqual(good.filters.authorPubkeys, [HEX]);
|
|
const bad = parseFilterQuery("author:not-a-key");
|
|
assert.equal(bad.filters.authorPubkeys.length, 0);
|
|
assert.ok(bad.errors.some((e) => /decode author/i.test(e.message)));
|
|
});
|