import { test } from "node:test"; import assert from "node:assert/strict"; import { newGroup, newLeaf } from "../searchQuery"; import type { SearchAlias } from "../searchAliases"; import { VIDEO_STATES, type VideoState } from "../availability"; import { MCP_POLICY, VIEWER_POLICY } from "./policy"; import { buildMatcher, buildLeafMatchers, evalLeaf, evalNode, passesFilters, needsAvailability, filterIsSelective, type RecordCtx, type SearchFilters, } from "./evalTree"; const ctx = (over: Partial = {}): RecordCtx => ({ title: "", channel: "", description: "", tags: "", cues: [], chatCues: [], snippetsPerVideo: 4, includeSnippets: true, snippetChars: MCP_POLICY.snippetChars, ...over, }); const cue = (start: number, text: string) => ({ start, end: start + 2, text }); const ALL_STATES: ReadonlySet = new Set(VIDEO_STATES); const openFilters = (over: Partial = {}): SearchFilters => ({ videos: true, livestreams: true, allAges: true, restricted: true, states: ALL_STATES, ...over, }); const alias = (over: Partial = {}): SearchAlias => ({ id: "a1", label: "k cups", triggers: ["k cups"], suggestion: "(k|cake)[ -]?cup", useRegex: true, ...over, }); // ─── matcher compilation ─── test("buildMatcher is plain substring, case-insensitive, and alias-free by default", () => { const { match, firedAliases } = buildMatcher({ query: "Needle" }); assert.equal(match("a needle here"), true); assert.equal(match("nothing"), false); assert.deepEqual(firedAliases, []); }); test("buildMatcher ORs a fired alias onto the plain match", () => { const { match, firedAliases } = buildMatcher({ query: "k cups", aliases: [alias()], }); assert.equal(firedAliases.length, 1); assert.equal(match("k cups"), true, "the literal still matches"); assert.equal(match("cakecup"), true, "…and so does the curated regex"); assert.equal(match("unrelated"), false); }); test("an explicit regex query takes NO alias expansion", () => { const { match, firedAliases } = buildMatcher({ query: "k c.ps", regex: true, aliases: [alias()], }); assert.deepEqual(firedAliases, []); assert.equal(match("k cups"), true); assert.equal(match("cakecup"), false); }); test("useAliases:false suppresses expansion even when an alias would fire", () => { const { firedAliases } = buildMatcher({ query: "k cups", useAliases: false, aliases: [alias()], }); assert.deepEqual(firedAliases, []); }); test("a malformed alias regex degrades to substring instead of throwing", () => { const { match } = buildMatcher({ query: "k cups", aliases: [alias({ suggestion: "([unclosed" })], }); assert.equal(match("k cups"), true); assert.equal(match("([unclosed"), true); }); test("buildLeafMatchers compiles only ACTIVE leaves and unions fired aliases", () => { const a = newLeaf({ id: "l1", query: "k cups" }); const b = newLeaf({ id: "l2", query: "" }); // inactive const c = newLeaf({ id: "l3", query: "k cups", scope: "description" }); const { matchers, fired } = buildLeafMatchers( newGroup({ children: [a, b, c] }), [alias()], true, ); assert.deepEqual([...matchers.keys()], ["l1", "l3"]); assert.equal(fired.length, 1, "the same alias fires once, not per leaf"); // Only the transcripts leaf is alias-aware. assert.equal(matchers.get("l1")!.test("cakecup"), true); assert.equal(matchers.get("l3")!.test("cakecup"), false); }); // ─── per-record leaf evaluation ─── test("a transcripts leaf counts every matched cue and caps the snippets", () => { const leaf = newLeaf({ id: "l", query: "hit" }); const r = evalLeaf( leaf, { scope: "transcripts", test: (t) => t.includes("hit") }, ctx({ cues: [cue(0, "hit"), cue(5, "hit"), cue(9, "miss"), cue(12, "hit")], snippetsPerVideo: 2, }), ); assert.equal(r.matched, true); assert.equal(r.count, 3, "count is every match, not every snippet"); assert.equal(r.hits.length, 2, "…and the snippets stop at snippetsPerVideo"); assert.equal(r.hits[0].clock, "0:00"); assert.equal(r.hits[0].seconds, 0); assert.equal(r.hits[0].scope, "transcripts"); }); test("includeSnippets:false keeps the count and drops the text", () => { const r = evalLeaf( newLeaf({ id: "l", query: "hit" }), { scope: "transcripts", test: () => true }, ctx({ cues: [cue(0, "hit")], includeSnippets: false }), ); assert.equal(r.count, 1); assert.deepEqual(r.hits, []); }); test("the untimed scopes carry seconds 0 and their own scope tag", () => { const leaf = newLeaf({ id: "l", query: "x" }); const hit = (scope: "description" | "tags" | "posts", c: Partial) => evalLeaf(leaf, { scope, test: () => true }, ctx(c)).hits[0]; assert.equal(hit("description", { description: "d" })!.seconds, 0); assert.equal(hit("description", { description: "d" })!.scope, "description"); assert.equal(hit("tags", { tags: "a, b" })!.text, "a, b"); assert.equal(hit("posts", { postText: "body" })!.scope, "posts"); }); test("a posts leaf never matches a video record (no postText)", () => { const r = evalLeaf( newLeaf({ id: "l", query: "x" }), { scope: "posts", test: () => true }, ctx({ title: "x", cues: [cue(0, "x")] }), ); assert.equal(r.matched, false); }); test("metadata matches title OR channel, and names the channel when only it hit", () => { const leaf = newLeaf({ id: "l", query: "acme" }); const m = { scope: "metadata" as const, test: (t: string) => t.includes("acme") }; const titled = evalLeaf(leaf, m, ctx({ title: "acme news", channel: "bob" })); assert.equal(titled.hits[0].text, "acme news"); const chan = evalLeaf(leaf, m, ctx({ title: "news", channel: "acme tv" })); assert.equal(chan.hits[0].text, "Channel: acme tv"); assert.equal(chan.count, 1, "title+channel is ONE metadata match, not two"); }); test("a chat leaf reads chatCues and tags the live_chat track", () => { const r = evalLeaf( newLeaf({ id: "l", query: "x" }), { scope: "chat", test: () => true }, ctx({ chatCues: [cue(30, "lol")] }), ); assert.equal(r.hits[0].track, "live_chat"); assert.equal(r.hits[0].seconds, 30); }); // `RecordCtx.snippetChars` is REQUIRED — no MCP default survives in lib/search, // because a viewer adopter that omitted it used to get 240-character excerpts // with nothing failing. tsc enforces that it is passed; this pins that passing // the viewer's policy actually widens the excerpt. test("snippetChars comes from the policy the caller passes", () => { const long = "y".repeat(300); const run = (snippetChars: number) => evalLeaf( newLeaf({ id: "l", query: "y" }), { scope: "transcripts", test: () => true }, ctx({ cues: [cue(0, long)], snippetChars }), ); assert.equal(run(VIEWER_POLICY.snippetChars).hits[0].text.length, 300); assert.equal(run(MCP_POLICY.snippetChars).hits[0].text.length, 240); }); // ─── the tree algebra, per record ─── const treeOf = (...leaves: ReturnType[]) => newGroup({ children: leaves }); function evalOver( root: ReturnType, record: Partial, ) { const { matchers } = buildLeafMatchers(root, [], false); return evalNode(root, matchers, ctx(record)); } test("AND requires every active child; OR requires one", () => { const both = treeOf( newLeaf({ query: "alpha" }), newLeaf({ query: "beta" }), ); assert.equal(evalOver(both, { cues: [cue(0, "alpha beta")] }).match, true); assert.equal(evalOver(both, { cues: [cue(0, "alpha only")] }).match, false); const either = newGroup({ op: "OR", children: [newLeaf({ query: "alpha" }), newLeaf({ query: "beta" })], }); assert.equal(evalOver(either, { cues: [cue(0, "alpha only")] }).match, true); assert.equal(evalOver(either, { cues: [cue(0, "gamma")] }).match, false); }); test("a negated leaf inverts the match and contributes NO hits", () => { const tree = treeOf( newLeaf({ query: "alpha" }), newLeaf({ query: "beta", negate: true }), ); const yes = evalOver(tree, { cues: [cue(0, "alpha")] }); assert.equal(yes.match, true); assert.equal(yes.count, 1, "only the positive leaf contributes"); assert.equal(evalOver(tree, { cues: [cue(0, "alpha beta")] }).match, false); }); test("contributeHits:false filters without contributing count or snippets", () => { const tree = treeOf( newLeaf({ query: "alpha", contributeHits: false }), newLeaf({ query: "beta" }), ); const r = evalOver(tree, { cues: [cue(0, "alpha beta"), cue(3, "beta")] }); assert.equal(r.match, true); assert.equal(r.count, 2, "both beta cues, neither alpha"); }); test("an inactive (empty) subtree is identity, not a filter", () => { const tree = treeOf(newLeaf({ query: "alpha" }), newLeaf({ query: "" })); assert.equal(evalOver(tree, { cues: [cue(0, "alpha")] }).match, true); const allEmpty = treeOf(newLeaf({ query: "" })); const r = evalOver(allEmpty, { cues: [cue(0, "anything")] }); assert.equal(r.match, true); assert.equal(r.count, 0); }); test("a negated GROUP inverts its children and contributes no hits", () => { const tree = newGroup({ children: [ newLeaf({ query: "alpha" }), newGroup({ negate: true, op: "OR", children: [newLeaf({ query: "beta" }), newLeaf({ query: "gamma" })], }), ], }); assert.equal(evalOver(tree, { cues: [cue(0, "alpha")] }).match, true); assert.equal(evalOver(tree, { cues: [cue(0, "alpha beta")] }).match, false); const r = evalOver(tree, { cues: [cue(0, "alpha")] }); assert.equal(r.count, 1); }); // ─── the filter predicate ─── test("passesFilters applies type, audience, state and the date range", () => { const rec = { uploadDate: "20250601", isLivestream: false, ageRestricted: false }; assert.equal(passesFilters(rec, openFilters(), undefined), true); assert.equal(passesFilters(rec, openFilters({ videos: false }), undefined), false); assert.equal( passesFilters({ ...rec, isLivestream: true }, openFilters({ livestreams: false }), undefined), false, ); assert.equal( passesFilters({ ...rec, ageRestricted: true }, openFilters({ allAges: false }), undefined), true, ); assert.equal( passesFilters({ ...rec, ageRestricted: true }, openFilters({ restricted: false }), undefined), false, ); assert.equal(passesFilters(rec, openFilters({ dateFrom: "20250701" }), undefined), false); assert.equal(passesFilters(rec, openFilters({ dateTo: "20250501" }), undefined), false); assert.equal( passesFilters(rec, openFilters({ dateFrom: "20250101", dateTo: "20251231" }), undefined), true, ); }); test("an absent availability record reads as 'available'", () => { const rec = { uploadDate: "20250601" }; const onlyDeleted = openFilters({ states: new Set(["deleted"]) }); assert.equal(passesFilters(rec, onlyDeleted, undefined), false); assert.equal(passesFilters(rec, onlyDeleted, { state: "deleted" }), true); }); test("needsAvailability and filterIsSelective are both false for an all-permissive filter", () => { assert.equal(needsAvailability(openFilters()), false); assert.equal(filterIsSelective(openFilters()), false); assert.equal(needsAvailability(null), false); assert.equal(filterIsSelective(null), false); assert.equal(filterIsSelective(openFilters({ videos: false })), true); assert.equal(filterIsSelective(openFilters({ dateFrom: "20200101" })), true); assert.equal( needsAvailability(openFilters({ states: new Set(["available"]) })), true, ); }); // ─── curated tags (tg) ─── test("the curated-tag filter is an OR over the selection", () => { const untagged = { uploadDate: "20250601" }; const collab = { uploadDate: "20250601", curatedTags: ["eva-collab"] }; const both = { uploadDate: "20250601", curatedTags: ["eva-collab", "eva-topic"], }; // ANY of the selected tags is enough — the chips union, they do not intersect. const one = openFilters({ curatedTags: ["eva-collab"] }); assert.equal(passesFilters(collab, one, undefined), true); assert.equal(passesFilters(both, one, undefined), true); assert.equal(passesFilters(untagged, one, undefined), false); const two = openFilters({ curatedTags: ["eva-collab", "eva-in-chat"] }); assert.equal(passesFilters(collab, two, undefined), true); assert.equal( passesFilters({ uploadDate: "20250601", curatedTags: ["eva-in-chat"] }, two, undefined), true, ); assert.equal(passesFilters(untagged, two, undefined), false); // A tag nothing carries excludes everything rather than erroring. const none = openFilters({ curatedTags: ["nobody"] }); assert.equal(passesFilters(both, none, undefined), false); }); test("an empty curated-tag selection is no filter at all", () => { // The chip row starts empty and hands its state straight through, so [] must // read as "unfiltered" — never as "matches nothing". const empty = openFilters({ curatedTags: [] }); assert.equal(passesFilters({ uploadDate: "20250601" }, empty, undefined), true); assert.equal(filterIsSelective(empty), false); }); test("a record from a pre-spec-4 site carries no tags and passes no tag filter", () => { // Records built before curated tags existed have no `curatedTags` key at all. // That is not "matches every tag" — it is "carries none". const legacy = { uploadDate: "20250601", isLivestream: false }; assert.equal( passesFilters(legacy, openFilters({ curatedTags: ["eva-collab"] }), undefined), false, ); }); test("a non-empty curated-tag selection makes a filter selective", () => { // filterIsSelective gates the MCP's filter-first page pruner: without this, // a tag-only query would plan a full scan. assert.equal(filterIsSelective(openFilters({ curatedTags: ["eva-collab"] })), true); }); test("a transcripts leaf reads the record's alternate tracks; a hit only one holds wears its track", () => { const r = evalLeaf( newLeaf({ id: "l", query: "zeppelin" }), { scope: "transcripts", test: (t) => t.includes("zeppelin") || t.includes("bridge") }, ctx({ cues: [cue(3, "the bridge")], altTracks: [{ track: "en", cues: [cue(4, "the bridge"), cue(100, "a zeppelin")] }], }), ); assert.equal(r.count, 2, "the bridge once (primary), the zeppelin once (en)"); assert.deepEqual( r.hits.map((h) => [h.seconds, h.track]), [ [3, undefined], [100, "en"], ], ); });