test(app): add unit tests for contentExtraction composable

49 tests covering all extraction functions:
- extractAllFilms: tag parsing, library lookup, dedup, malformed input
- extractAllSongs: ext tags, library lookup, dedup, false positive filtering
- extractAllPodcasts: ext tags, library lookup, bad content filtering
- extractAllBooks: ext tags, optional fields, query filtering
- extractAllTVSeries: ext tags, creator parsing, query filtering
- extractAllPlaces: full fields, optional fields
- extractAllImages: markdown images, bare URLs, query gating
- extractMagazineSections: headings, numbered lists, hero images
- stripContentTags: all tag types, preservation of non-tag content
- extractBoldDomainLinks / extractMarkdownLinks / mergeNewsResults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 19:42:24 +00:00
co-authored by Claude Opus 4.6
parent f31e0ba28b
commit c6843fca1a
@@ -0,0 +1,435 @@
import { describe, it, expect } from 'vitest'
import {
extractAllFilms,
extractAllSongs,
extractAllPodcasts,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractMagazineSections,
extractMagazineHeroImage,
extractBoldDomainLinks,
extractMarkdownLinks,
mergeNewsResults,
stripContentTags,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripBookTags,
stripTVTags,
stripPlaceTags,
extractFilmIds,
extractSongIds,
extractPodcastIds,
} from '@/composables/contentExtraction'
// ─── Films ────────────────────────────────────────────────────────
describe('extractAllFilms', () => {
it('extracts film_ext tags with title, year, director', () => {
const text = 'Check out [[film_ext:Inception|2010|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(films[0].year).toBe(2010)
expect(films[0].director).toBe('Christopher Nolan')
})
it('extracts film:id tags and looks up from library', () => {
const text = 'You should watch [[film:f1]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].id).toBe('f1')
})
it('returns empty array for text with no film tags', () => {
const text = 'Just some text about movies without any tags'
const films = extractAllFilms(text)
expect(films).toHaveLength(0)
})
it('handles multiple films in one message', () => {
const text = '[[film_ext:Inception|2010|Christopher Nolan]] and [[film_ext:Interstellar|2014|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(2)
expect(films[0].title).toBe('Inception')
expect(films[1].title).toBe('Interstellar')
})
it('handles malformed tags gracefully', () => {
const text = '[[film_ext:]] [[film_ext:Incomplete]] [[film:]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(0)
})
it('deduplicates films by title and year', () => {
const text = '[[film_ext:Inception|2010|Nolan]] and again [[film_ext:Inception|2010|Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
})
it('normalizes film IDs with or without f prefix', () => {
const ids1 = extractFilmIds('[[film:1]] [[film:f2]]')
expect(ids1).toEqual(['f1', 'f2'])
})
})
// ─── Songs ────────────────────────────────────────────────────────
describe('extractAllSongs', () => {
it('extracts song_ext tags with title, artist, year', () => {
const text = '[[song_ext:Bohemian Rhapsody|Queen|1975]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Bohemian Rhapsody')
expect(songs[0].artist).toBe('Queen')
expect(songs[0].year).toBe(1975)
})
it('extracts song_ext without year', () => {
const text = '[[song_ext:Paranoid Android|Radiohead]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Paranoid Android')
expect(songs[0].artist).toBe('Radiohead')
expect(songs[0].year).toBeUndefined()
})
it('extracts song:id tags from library', () => {
const text = 'Listen to [[song:s1]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].id).toBe('s1')
})
it('deduplicates songs by title+artist', () => {
const text = '[[song_ext:Creep|Radiohead]] again [[song_ext:Creep|Radiohead]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
})
it('returns empty for text with film tags (not music)', () => {
const text = '[[film_ext:Inception|2010|Nolan]] great movie'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(0)
})
it('filters out non-song content using looksLikeSong', () => {
const text = '[[song_ext:Latest News|Web Search]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(0)
})
it('normalizes song IDs with or without s prefix', () => {
const ids = extractSongIds('[[song:1]] [[song:s2]]')
expect(ids).toEqual(['s1', 's2'])
})
})
// ─── Podcasts ─────────────────────────────────────────────────────
describe('extractAllPodcasts', () => {
it('extracts podcast_ext tags', () => {
const text = '[[podcast_ext:Bitcoin Audible|Guy Swann|2018]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(1)
expect(podcasts[0].title).toBe('Bitcoin Audible')
expect(podcasts[0].host).toBe('Guy Swann')
expect(podcasts[0].year).toBe(2018)
})
it('extracts podcast:id from library', () => {
const text = '[[podcast:p1]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(1)
expect(podcasts[0].id).toBe('p1')
})
it('returns empty for no podcast tags', () => {
const text = 'No podcasts here'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(0)
})
it('filters out non-podcast titles', () => {
const text = '[[podcast_ext:Bitcoin Mailing List|GitHub]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(0)
})
it('normalizes podcast IDs', () => {
const ids = extractPodcastIds('[[podcast:1]] [[podcast:p2]]')
expect(ids).toEqual(['p1', 'p2'])
})
})
// ─── Books ────────────────────────────────────────────────────────
describe('extractAllBooks', () => {
it('extracts book_ext tags with title, author, year', () => {
const text = '[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]]'
const books = extractAllBooks(text, 'best bitcoin books')
expect(books).toHaveLength(1)
expect(books[0].title).toBe('The Bitcoin Standard')
expect(books[0].author).toBe('Saifedean Ammous')
expect(books[0].year).toBe(2018)
})
it('handles optional year field', () => {
const text = '[[book_ext:Mastering Bitcoin|Andreas Antonopoulos]]'
const books = extractAllBooks(text, 'bitcoin books')
expect(books).toHaveLength(1)
expect(books[0].year).toBeUndefined()
})
it('returns empty when no book tags and not book query', () => {
const text = 'Just talking about tech'
const books = extractAllBooks(text, 'what is javascript')
expect(books).toHaveLength(0)
})
it('extracts multiple books', () => {
const text = '[[book_ext:Book One|Author A|2020]] and [[book_ext:Book Two|Author B|2021]]'
const books = extractAllBooks(text, 'books')
expect(books).toHaveLength(2)
})
})
// ─── TV Series ────────────────────────────────────────────────────
describe('extractAllTVSeries', () => {
it('extracts tv_ext tags', () => {
const text = '[[tv_ext:Breaking Bad|Vince Gilligan|2008]]'
const series = extractAllTVSeries(text, 'best tv shows')
expect(series).toHaveLength(1)
expect(series[0].title).toBe('Breaking Bad')
expect(series[0].creator).toBe('Vince Gilligan')
expect(series[0].year).toBe(2008)
})
it('parses creator field', () => {
const text = '[[tv_ext:The Wire|David Simon|2002]]'
const series = extractAllTVSeries(text, 'tv shows')
expect(series).toHaveLength(1)
expect(series[0].creator).toBe('David Simon')
})
it('returns empty when no TV tags and not TV query', () => {
const text = 'Some random text'
const series = extractAllTVSeries(text, 'cooking recipe')
expect(series).toHaveLength(0)
})
it('handles tv_ext without year', () => {
const text = '[[tv_ext:The Sopranos|David Chase]]'
const series = extractAllTVSeries(text, 'tv')
expect(series).toHaveLength(1)
expect(series[0].year).toBeUndefined()
})
})
// ─── Places ───────────────────────────────────────────────────────
describe('extractAllPlaces', () => {
it('extracts place_ext tags with all fields', () => {
const text = '[[place_ext:Joe\'s Pizza|Pizza|New York|4.5|2|123 Main St]]'
const places = extractAllPlaces(text, 'pizza near me')
expect(places).toHaveLength(1)
expect(places[0].name).toBe("Joe's Pizza")
expect(places[0].cuisine).toBe('Pizza')
expect(places[0].city).toBe('New York')
expect(places[0].rating).toBe(4.5)
expect(places[0].priceLevel).toBe(2)
expect(places[0].address).toBe('123 Main St')
})
it('handles missing optional fields (rating, price)', () => {
const text = '[[place_ext:The Diner|American]]'
const places = extractAllPlaces(text, 'restaurants')
expect(places).toHaveLength(1)
expect(places[0].name).toBe('The Diner')
expect(places[0].cuisine).toBe('American')
expect(places[0].rating).toBeUndefined()
expect(places[0].priceLevel).toBeUndefined()
})
it('returns empty for non-place queries without tags', () => {
const text = 'Nothing about restaurants here'
const places = extractAllPlaces(text, 'what is bitcoin')
expect(places).toHaveLength(0)
})
})
// ─── Images ───────────────────────────────────────────────────────
describe('extractAllImages', () => {
it('extracts markdown image syntax', () => {
const text = '![alt text](https://example.com/image.jpg)'
const images = extractAllImages(text, 'show me images')
expect(images).toHaveLength(1)
expect(images[0].url).toBe('https://example.com/image.jpg')
expect(images[0].alt).toBe('alt text')
})
it('extracts bare image URLs', () => {
const text = 'Check this: https://example.com/photo.png and https://example.com/pic.webp'
const images = extractAllImages(text, 'images')
expect(images).toHaveLength(2)
})
it('returns empty when not an image query and only one image', () => {
const text = 'https://example.com/photo.jpg'
const images = extractAllImages(text, 'what is bitcoin')
expect(images).toHaveLength(0)
})
})
// ─── Magazine Sections ────────────────────────────────────────────
describe('extractMagazineSections', () => {
it('extracts sections from markdown headings', () => {
const text = `## First Section
This is the content of the first section with enough text to pass the minimum length filter.
## Second Section
This is the content of the second section, also with enough detail to be meaningful.`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(2)
const titles = sections.map(s => s.title)
expect(titles).toContain('First Section')
expect(titles).toContain('Second Section')
})
it('captures content between headings', () => {
const text = `## Market Update
Bitcoin surged to new highs today as institutional demand increased significantly and retail sentiment improved.
## Analysis
Analysts believe the trend will continue through the end of the quarter as macro conditions stabilize.`
const sections = extractMagazineSections(text)
const marketSection = sections.find(s => s.title === 'Market Update')
expect(marketSection).toBeDefined()
expect(marketSection!.content).toContain('Bitcoin surged')
})
it('extracts hero images', () => {
const text = 'Some text with ![hero](https://example.com/hero.jpg) embedded'
const hero = extractMagazineHeroImage(text)
expect(hero).toBe('https://example.com/hero.jpg')
})
it('returns undefined for no images', () => {
const hero = extractMagazineHeroImage('No images here')
expect(hero).toBeUndefined()
})
it('extracts sections from numbered lists with bold titles', () => {
const text = `Here are the key developments:
1. **Strong price recovery** — Bitcoin climbed back above $60,000 as market confidence returned.
2. **Institutional adoption grows** — Major banks announced new crypto custody services for their clients.
3. **Regulatory clarity emerges** — New framework provides guidelines for digital asset companies.`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(3)
const titles = sections.map(s => s.title)
expect(titles).toContain('Strong price recovery')
})
it('handles empty or short text', () => {
const sections = extractMagazineSections('')
expect(sections).toHaveLength(0)
})
})
// ─── Tag Stripping ────────────────────────────────────────────────
describe('stripContentTags', () => {
it('removes all tag types from text', () => {
const text = 'Watch [[film:f1]] and listen to [[song:s1]] and read [[book_ext:Title|Author|2020]]'
const stripped = stripContentTags(text)
expect(stripped).not.toContain('[[film:')
expect(stripped).not.toContain('[[song:')
expect(stripped).not.toContain('[[book_ext:')
})
it('preserves non-tag content', () => {
const text = 'Watch this great movie [[film:f1]] and enjoy'
const stripped = stripContentTags(text)
expect(stripped).toContain('Watch this great movie')
expect(stripped).toContain('and enjoy')
})
it('handles adjacent tags', () => {
const text = '[[film:f1]][[song:s1]][[podcast:p1]]'
const stripped = stripContentTags(text)
expect(stripped).toBe('')
})
it('strips all specific tag types individually', () => {
expect(stripFilmTags('[[film:f1]] [[film_ext:Title|2020|Dir]]')).toBe('')
expect(stripSongTags('[[song:s1]] [[song_ext:Title|Artist]]')).toBe('')
expect(stripPodcastTags('[[podcast:p1]] [[podcast_ext:Title|Host]]')).toBe('')
expect(stripBookTags('[[book_ext:Title|Author|2020]]')).toBe('')
expect(stripTVTags('[[tv_ext:Title|Creator|2020]]')).toBe('')
expect(stripPlaceTags('[[place_ext:Name|Cuisine]]')).toBe('')
})
})
// ─── Links Extraction ─────────────────────────────────────────────
describe('extractBoldDomainLinks', () => {
it('extracts **domain.com** patterns with URLs', () => {
const text = '**CoinDesk** (coindesk.com) — the best source'
const links = extractBoldDomainLinks(text)
expect(links).toHaveLength(1)
expect(links[0].title).toBe('CoinDesk')
expect(links[0].url).toBe('https://coindesk.com')
})
it('deduplicates URLs', () => {
const text = '**CoinDesk** (coindesk.com) and **CoinDesk News** (coindesk.com)'
const links = extractBoldDomainLinks(text)
expect(links).toHaveLength(1)
})
})
describe('extractMarkdownLinks', () => {
it('extracts markdown links', () => {
const text = 'Check out [Bitcoin](https://bitcoin.org) for more'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(1)
expect(links[0].title).toBe('Bitcoin')
expect(links[0].url).toBe('https://bitcoin.org')
})
it('handles multiple links', () => {
const text = '[Link 1](https://example.com) and [Link 2](https://example.org/page)'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(2)
})
it('skips invalid URLs', () => {
const text = '[Bad Link](not-a-url)'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(0)
})
})
describe('mergeNewsResults', () => {
it('merges web results with text-extracted results', () => {
const web = [{ title: 'Web A', url: 'https://a.com', content: 'content a' }]
const fromText = [
{ title: 'Text B', url: 'https://b.com', content: undefined },
{ title: 'Text A Dup', url: 'https://a.com', content: undefined },
]
const merged = mergeNewsResults(web, fromText)
expect(merged).toHaveLength(2)
// Web result takes priority for same URL
const aResult = merged.find(r => r.url.includes('a.com'))
expect(aResult!.title).toBe('Web A')
})
})