// search matching + ranking. Run: node test/search.test.mjs
//
// Pure by design: no index, no network. The rules here were chosen to behave the way people expect
// rather than the way a regex is easiest — the substring/boundary one especially, because matching "art"
// inside "start" is the fastest way to make a search feel broken.
import { fold, parseQuery, matches, score, postText, actorText } from '../lib/search.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const q = (s) => parseQuery(s)
// ── query parsing ────────────────────────────────────────────────────────────────────────────────
ok(q('hello world').terms.join(',') === 'hello,world', 'terms split on whitespace')
ok(q('"two words" solo').phrases[0] === 'two words', 'a quoted run becomes a phrase')
ok(q('"two words" solo').terms.join(',') === 'solo', 'and is removed from the loose terms')
ok(q('').terms.length === 0 && q(null).terms.length === 0, 'empty/null query yields nothing rather than throwing')
ok(q(' ').terms.length === 0, 'whitespace-only too')
ok(q('a "unterminated').terms.length === 2, 'a stray quote degrades to terms — it cannot 500 a page')
// ── matching ─────────────────────────────────────────────────────────────────────────────────────
ok(matches('hello world', q('hello')) === true, 'a term matches')
// ALL terms must be present. An OR search over a small corpus returns essentially everything.
ok(matches('hello world', q('hello world')) === true, 'all terms present → match')
ok(matches('hello there', q('hello world')) === false, 'a missing term → no match (AND, not OR)')
// Word boundary, not substring.
ok(matches('artist at work', q('art')) === true, 'a term matches at a word start')
ok(matches('start the engine', q('art')) === false, '…but NOT inside a word — "art" must not match "start"')
// Case and accents.
ok(matches('Café Society', q('cafe')) === true, 'accent-insensitive: cafe finds Café')
ok(matches('HELLO', q('hello')) === true, 'case-insensitive')
// Phrases are contiguous.
ok(matches('one two three', q('"two three"')) === true, 'a phrase matches when contiguous')
ok(matches('two and three', q('"two three"')) === false, 'and not when it is split')
// An empty query matches nothing — never everything.
ok(matches('anything', q('')) === false, 'an empty query matches NOTHING, not everything')
// ── ranking ──────────────────────────────────────────────────────────────────────────────────────
ok(score('p2p p2p p2p', q('p2p')) > score('p2p once', q('p2p')), 'more occurrences rank higher')
ok(score('p2p first', q('p2p')) > score('about p2p', q('p2p')), 'a match at the start ranks higher')
ok(score('exact phrase here', q('"exact phrase"')) > score('exact and phrase', q('exact phrase')), 'a phrase hit outranks scattered terms')
// ── what text is searchable ──────────────────────────────────────────────────────────────────────
// ALT TEXT IS INCLUDED on purpose: someone who wrote "a photo of the harbour at dusk" described a post
// whose own text may not mention any of it, and excluding alt makes those posts unfindable.
const withAlt = { text: 'look', embed: { $type: 'app.bsky.embed.images', images: [{ alt: 'harbour at dusk' }] } }
ok(matches(postText(withAlt), q('harbour')) === true, 'image ALT text is searchable')
const withCard = { text: 'read', embed: { $type: 'app.bsky.embed.external', external: { title: 'Distributed Systems', description: 'a primer' } } }
ok(matches(postText(withCard), q('distributed')) === true, 'a link card title is searchable')
ok(postText(null) === '', 'a null record yields no text rather than throwing')
// Actors are findable by name, handle, bio — and by DID, because pasting one is a real thing operators do.
const prof = { displayName: 'Alice', handle: 'alice.test', description: 'builds p2p things' }
ok(matches(actorText('did:plc:alice', prof), q('alice')) === true, 'an actor matches by display name')
ok(matches(actorText('did:plc:alice', prof), q('p2p')) === true, 'and by bio')
ok(matches(actorText('did:plc:xyz123', {}), q('did:plc:xyz123')) === true, 'and by pasted DID')
ok(fold('ÀÉÎÕÜ') === 'aeiou', 'fold strips diacritics and case')
console.log(`\nSEARCH OK: ${pass} assertions`)