Files
FabledCurator/frontend/test/gallery.spec.js
T
bvandeusen 21a73cd1dc
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
feat(gallery): visual 'more like this' UI (Phase 3 frontend)
Modal 'Related' strip (RelatedStrip.vue) — top-12 similar thumbs, fetched on
its own DEFERRED, single-flighted path (200ms after the modal is up) so it
never blocks or slows the modal; collapses silently on empty/slow/error and is
hidden when the image has no embedding (has_embedding flag). 'See all similar'
closes the modal and navigates the gallery to ?similar_to=<id>.

Gallery store: similar_to filter field + loadSimilar() (ranked, hasMore=false,
no timeline); applyFilterFromQuery routes similar-mode to /similar with the
scope filters composed; cloneFilter/filterToQuery carry similar_to. Filter bar:
clearable 'Similar to #id' chip, sort hidden in similar-mode; timeline sidebar
hidden too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:52:42 -04:00

198 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => {
const { status, body } = handler(url, init)
return {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
text: async () => (body == null ? '' : JSON.stringify(body)),
}
})
}
const EMPTY = { images: [], date_groups: [], next_cursor: null }
describe('gallery store: composable filter', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('applyFilterFromQuery parses the query and loadMore sends composable params', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/tags/')) {
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
}
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ tag_id: '1,2', artist_id: '5', media: 'video', sort: 'oldest' })
expect(s.filter.tag_ids).toEqual([1, 2])
expect(s.filter.artist_id).toBe(5)
expect(s.filter.media_type).toBe('video')
expect(s.filter.sort).toBe('oldest')
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('tag_id=1,2')
expect(scroll).toContain('artist_id=5')
expect(scroll).toContain('media=video')
expect(scroll).toContain('sort=oldest')
})
it('omits sort=newest and sends no filter params when empty', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({})
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
expect(scroll).not.toContain('sort=')
expect(scroll).not.toContain('tag_id=')
expect(scroll).not.toContain('media=')
})
it('treats post_id as the exclusive filter param', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({ post_id: '7', tag_id: '1' })
expect(s.filter.post_id).toBe(7)
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
expect(scroll).toContain('post_id=7')
expect(scroll).not.toContain('tag_id=')
})
it('noteTagLabel and noteArtistLabel pre-seed chip labels', () => {
const s = useGalleryStore()
s.noteTagLabel(9, 'Rukia')
expect(s.tagLabels[9]).toBe('Rukia')
s.noteArtistLabel('Kubo')
expect(s.artistLabel).toBe('Kubo')
})
it('parses faceted refine params and loadMore forwards them', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({
platform: 'pixiv', untagged: '1', no_artist: '1',
date_from: '2024-01-01', date_to: '2024-12-31',
})
expect(s.filter.platform).toBe('pixiv')
expect(s.filter.untagged).toBe(true)
expect(s.filter.no_artist).toBe(true)
expect(s.filter.date_from).toBe('2024-01-01')
expect(s.filter.date_to).toBe('2024-12-31')
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('platform=pixiv')
expect(scroll).toContain('untagged=1')
expect(scroll).toContain('no_artist=1')
expect(scroll).toContain('date_from=2024-01-01')
expect(scroll).toContain('date_to=2024-12-31')
})
it('drops a malformed date in the query', async () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
await s.applyFilterFromQuery({ date_from: 'garbage' })
expect(s.filter.date_from).toBe(null)
})
it('loadFacets fetches counts scoped to the active filter', async () => {
const s = useGalleryStore()
const urls = []
const FACETS = {
total: 4, platforms: [{ value: 'patreon', count: 2 }],
untagged: 1, no_artist: 0,
date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
}
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
await s.loadFacets()
const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
expect(facetsUrl).toContain('platform=patreon')
expect(facetsUrl).toContain('untagged=1')
expect(s.facets.total).toBe(4)
})
it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/gallery/similar')) {
return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } }
}
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' })
expect(s.filter.similar_to).toBe(5)
const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar')))
expect(sim).toContain('similar_to=5')
expect(sim).toContain('limit=100')
expect(sim).toContain('tag_id=3') // scope composes
expect(s.images.map((i) => i.id)).toEqual([9])
expect(s.hasMore).toBe(false) // ranked + bounded → no pages
expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false)
expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore()
const urls = []
// Non-null cursor: the old 10×serial-batch loop would have fired ten
// requests here. The single-fetch path must fire exactly one.
stubFetch((url) => {
urls.push(url)
return { status: 200, body: { images: [], date_groups: [], next_cursor: 'c1' } }
})
await s.loadInitial()
const scrollCalls = urls.filter((u) => u.includes('/api/gallery/scroll'))
expect(scrollCalls).toHaveLength(1)
expect(scrollCalls[0]).toContain('limit=50')
})
})
describe('filterToQuery / cloneFilter', () => {
it('serializes faceted params incl. platform sentinel and flags', () => {
const q = filterToQuery({
tag_ids: [3], artist_id: null, media_type: null, sort: 'newest',
platform: '__unsourced__', untagged: true, no_artist: false,
date_from: '2024-01-01', date_to: null,
})
expect(q.tag_id).toBe('3')
expect(q.platform).toBe('__unsourced__')
expect(q.untagged).toBe('1')
expect(q.no_artist).toBeUndefined()
expect(q.date_from).toBe('2024-01-01')
expect(q.date_to).toBeUndefined()
expect(q.sort).toBeUndefined() // newest is the default → omitted
})
it('serializes similar_to', () => {
expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42')
expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
})
it('cloneFilter copies refine fields and detaches tag_ids', () => {
const orig = {
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
platform: 'patreon', untagged: true, no_artist: true,
date_from: '2024-01-01', date_to: '2024-02-01',
}
const c = cloneFilter(orig)
c.tag_ids.push(9)
expect(orig.tag_ids).toEqual([1]) // detached
expect(c.platform).toBe('patreon')
expect(c.untagged).toBe(true)
expect(c.date_to).toBe('2024-02-01')
})
})