feat(editor): add lazy emoji data loader and filter
This commit is contained in:
parent
f6ec5d8e96
commit
542cab5ef6
|
|
@ -0,0 +1,58 @@
|
|||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||
import {filterEmojis, __resetEmojiCacheForTest, loadEmojis} from './emojiData'
|
||||
|
||||
const fixture = [
|
||||
{shortcodes: ['grinning', 'grinning_face'], annotation: 'grinning face', tags: ['face', 'grin'], emoji: '😀'},
|
||||
{shortcodes: ['eyes'], annotation: 'eyes', tags: ['look'], emoji: '👀'},
|
||||
{shortcodes: ['eyeglasses'], annotation: 'glasses', tags: ['eye'], emoji: '👓'},
|
||||
{shortcodes: ['smile'], annotation: 'grinning face with smiling eyes', tags: ['eye', 'smile'], emoji: '😄'},
|
||||
]
|
||||
|
||||
describe('emojiData', () => {
|
||||
beforeEach(() => {
|
||||
__resetEmojiCacheForTest()
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => fixture,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('flattens multi-shortcode entries and sorts alphabetically', async () => {
|
||||
const idx = await loadEmojis()
|
||||
const codes = idx.map(e => e.shortcode)
|
||||
expect(codes).toEqual(['eyeglasses', 'eyes', 'grinning', 'grinning_face', 'smile'])
|
||||
})
|
||||
|
||||
it('returns [] for empty query', () => {
|
||||
expect(filterEmojis([{shortcode: 'eyes', emoji: '👀', annotation: '', tags: []}], '')).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers startsWith matches over substring matches', () => {
|
||||
const loaded = [
|
||||
{shortcode: 'eyeglasses', emoji: '👓', annotation: 'glasses', tags: ['eye']},
|
||||
{shortcode: 'eyes', emoji: '👀', annotation: 'eyes', tags: []},
|
||||
{shortcode: 'smile', emoji: '😄', annotation: 'grinning face with smiling eyes', tags: ['eye']},
|
||||
]
|
||||
const result = filterEmojis(loaded, 'eye')
|
||||
expect(result[0].shortcode).toBe('eyeglasses')
|
||||
expect(result[1].shortcode).toBe('eyes')
|
||||
expect(result[2].shortcode).toBe('smile')
|
||||
})
|
||||
|
||||
it('limits results to 15', () => {
|
||||
const big = Array.from({length: 100}, (_, i) => ({
|
||||
shortcode: `foo_${String(i).padStart(3, '0')}`, emoji: '✨', annotation: '', tags: [],
|
||||
}))
|
||||
expect(filterEmojis(big, 'foo')).toHaveLength(15)
|
||||
})
|
||||
|
||||
it('caches the fetch promise across calls', async () => {
|
||||
await loadEmojis()
|
||||
await loadEmojis()
|
||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
export interface EmojiEntry {
|
||||
emoji: string
|
||||
shortcode: string
|
||||
annotation: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface RawEmoji {
|
||||
shortcodes: string[]
|
||||
annotation: string
|
||||
tags?: string[]
|
||||
emoji: string
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 15
|
||||
|
||||
let cache: Promise<EmojiEntry[]> | null = null
|
||||
|
||||
export function __resetEmojiCacheForTest() {
|
||||
cache = null
|
||||
}
|
||||
|
||||
export function loadEmojis(): Promise<EmojiEntry[]> {
|
||||
if (cache) return cache
|
||||
cache = fetch('/emojis.json')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`emojis.json HTTP ${res.status}`)
|
||||
return res.json() as Promise<RawEmoji[]>
|
||||
})
|
||||
.then(raw => {
|
||||
const flat: EmojiEntry[] = []
|
||||
for (const entry of raw) {
|
||||
for (const shortcode of entry.shortcodes) {
|
||||
flat.push({
|
||||
emoji: entry.emoji,
|
||||
shortcode,
|
||||
annotation: entry.annotation,
|
||||
tags: entry.tags ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
flat.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
|
||||
return flat
|
||||
})
|
||||
.catch(err => {
|
||||
cache = null
|
||||
throw err
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
export function filterEmojis(index: EmojiEntry[], rawQuery: string): EmojiEntry[] {
|
||||
const query = rawQuery.toLowerCase()
|
||||
if (query === '') return []
|
||||
|
||||
const starts: EmojiEntry[] = []
|
||||
const contains: EmojiEntry[] = []
|
||||
|
||||
for (const entry of index) {
|
||||
if (entry.shortcode.startsWith(query)) {
|
||||
starts.push(entry)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
entry.shortcode.includes(query) ||
|
||||
entry.annotation.toLowerCase().includes(query) ||
|
||||
entry.tags.some(t => t.toLowerCase().includes(query))
|
||||
) {
|
||||
contains.push(entry)
|
||||
}
|
||||
if (starts.length >= MAX_RESULTS) break
|
||||
}
|
||||
|
||||
return [...starts, ...contains].slice(0, MAX_RESULTS)
|
||||
}
|
||||
Loading…
Reference in New Issue