OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / facets.test.mjs

92 lines · javascript ref:
// facets on WRITE — the inverse of renderRichText. Run: node test/facets.test.mjs
//
// Until 0.3.11 favela read facets and wrote none, so every link, @mention and #tag it posted was inert
// plain text in every other client. These pin the two things that make a facet correct rather than merely
// present: UTF-8 BYTE offsets, and a mention that carries a resolved DID or nothing at all.
import { findCandidates, detectFacets } from '../lib/facets.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }
const eq = (a, b, m) => ok(JSON.stringify(a) === JSON.stringify(b), m + ` (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)

const enc = new TextEncoder()
// The property that matters: slicing the ENCODED text by a facet's index must reproduce the visible token.
const slice = (text, f) => new TextDecoder().decode(enc.encode(text).slice(f.index.byteStart, f.index.byteEnd))

// ── links ────────────────────────────────────────────────────────────────────────────────────────
const f1 = await detectFacets('see https://example.com/x now')
eq(f1.length, 1, 'a bare url produces one facet')
eq(f1[0].features[0].$type, 'app.bsky.richtext.facet#link', 'typed as a link')
eq(f1[0].features[0].uri, 'https://example.com/x', 'carrying the uri')
eq(slice('see https://example.com/x now', f1[0]), 'https://example.com/x', 'and the byte range covers exactly the url')

// Trailing punctuation is not part of the link — "see https://example.com." must not linkify the full stop.
const f2 = await detectFacets('see https://example.com.')
eq(slice('see https://example.com.', f2[0]), 'https://example.com', 'trailing punctuation is excluded')

// ── THE BYTE-OFFSET TRAP ─────────────────────────────────────────────────────────────────────────
// Emoji are 4 UTF-8 bytes and 2 UTF-16 code units. If offsets were computed from string indices, every
// facet after one would be shifted and would render as a garbled link rather than as an error.
const emoji = '🎉🎉 https://example.com'
const f3 = await detectFacets(emoji)
eq(slice(emoji, f3[0]), 'https://example.com', 'offsets survive emoji before the token')
ok(f3[0].index.byteStart === 9, 'byteStart is the BYTE offset (9), not the string index (5)')

const accents = 'héllo wörld https://example.com'
const f4 = await detectFacets(accents)
eq(slice(accents, f4[0]), 'https://example.com', 'offsets survive multi-byte latin characters')

// ── tags ─────────────────────────────────────────────────────────────────────────────────────────
const tagText = 'shipping #favela and #p2p today'
const f5 = await detectFacets(tagText)
eq(f5.length, 2, 'two hashtags → two facets')
eq(f5[0].features[0].$type, 'app.bsky.richtext.facet#tag', 'typed as a tag')
eq(f5[0].features[0].tag, 'favela', 'the tag value EXCLUDES the # (the lexicon wants the bare tag)')
eq(slice(tagText, f5[0]), '#favela', 'but the byte range INCLUDES it, so the whole token renders as the link')

// A '#' mid-word is not a tag (c#, a#b) — only at a boundary.
eq((await detectFacets('the key is a#b')).length, 0, 'a # inside a word is not a hashtag')
// Unicode tags work.
eq((await detectFacets('#café')).length, 1, 'unicode hashtags are detected')

// ── mentions ─────────────────────────────────────────────────────────────────────────────────────
// A mention MUST carry a did. Without a resolver we emit nothing rather than a handle-shaped guess.
eq((await detectFacets('hi @alice.bsky.social')).length, 0, 'no resolver → no mention facet (never a guess)')

const resolved = await detectFacets('hi @alice.bsky.social!', { resolveHandle: async (h) => h === 'alice.bsky.social' ? 'did:plc:alice' : null })
eq(resolved.length, 1, 'a resolvable handle produces a mention')
eq(resolved[0].features[0].$type, 'app.bsky.richtext.facet#mention', 'typed as a mention')
eq(resolved[0].features[0].did, 'did:plc:alice', 'carrying the resolved DID, not the handle')
eq(slice('hi @alice.bsky.social!', resolved[0]), '@alice.bsky.social', 'the range covers @handle and stops before the punctuation')

// An UNRESOLVABLE handle is left as plain text. Being unlinked is a papercut; mentioning the wrong
// account sends a stranger a notification.
eq((await detectFacets('hi @nobody.example.com', { resolveHandle: async () => null })).length, 0,
  'an unresolvable handle emits NO facet rather than a wrong did')
// A resolver that throws must not take the post down with it.
eq((await detectFacets('hi @a.b.com', { resolveHandle: async () => { throw new Error('dns down') } })).length, 0,
  'a throwing resolver degrades to no mention, not an exception')
// '@someone' with no domain is not a handle.
eq((await detectFacets('thanks @bob', { resolveHandle: async () => 'did:plc:bob' })).length, 0,
  'a bare @word with no domain is not a handle')

// ── overlaps ─────────────────────────────────────────────────────────────────────────────────────
// A '#fragment' inside a URL must not also become a tag — interleaved facets render as nothing sane.
const frag = 'see https://example.com/a#section now'
const f6 = await detectFacets(frag)
eq(f6.length, 1, 'a # inside a url does not also emit a tag facet')
eq(f6[0].features[0].$type, 'app.bsky.richtext.facet#link', 'the url wins')

// Ordering is by position, which is what a renderer walks.
const many = await detectFacets('#one https://x.test #two')
ok(many.length === 3, 'three tokens → three facets')
ok(many[0].index.byteStart < many[1].index.byteStart && many[1].index.byteStart < many[2].index.byteStart,
  'facets are sorted by byteStart')

// ── candidates are pure ──────────────────────────────────────────────────────────────────────────
eq(findCandidates('').length, 0, 'empty text → no candidates')
eq(findCandidates(null).length, 0, 'null text is tolerated')
eq((await detectFacets('just some plain words')).length, 0, 'plain prose produces no facets at all')

console.log(`\nFACETS OK: ${pass} assertions`)