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 the default sort 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('parses tag_or OR-groups + tag_not and forwards them (incl. repeated tag_or)', 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 } }) // tag_or arrives as an array (two groups); tag_not as a csv exclude list. await s.applyFilterFromQuery({ tag_or: ['1,2', '3'], tag_not: '4,5' }) expect(s.filter.tag_or).toEqual([[1, 2], [3]]) expect(s.filter.tag_exclude).toEqual([4, 5]) const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll'))) expect(scroll).toContain('tag_or=1,2') // first OR-group expect(scroll).toContain('tag_or=3') // second OR-group (repeated key) expect(scroll).toContain('tag_not=4,5') }) it('normalizes a single-string tag_or and drops empty groups', async () => { const s = useGalleryStore() stubFetch((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_or: '7,8' }) // single string → one group expect(s.filter.tag_or).toEqual([[7, 8]]) await s.applyFilterFromQuery({ tag_or: ['', '9'] }) // empty group dropped expect(s.filter.tag_or).toEqual([[9]]) }) 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: 'posted_new', 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() // posted_new 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('serializes tag_or OR-groups (repeated key) + tag_not, dropping empties', () => { const q = filterToQuery({ tag_ids: [], tag_or: [[1, 2], [3], []], tag_exclude: [4, 5], }) expect(q.tag_or).toEqual(['1,2', '3']) // empty group dropped → array expect(q.tag_not).toBe('4,5') const empty = filterToQuery({ tag_ids: [], tag_or: [], tag_exclude: [] }) expect(empty.tag_or).toBeUndefined() expect(empty.tag_not).toBeUndefined() }) it('cloneFilter deep-clones OR-groups + exclude', () => { const orig = { tag_ids: [], tag_or: [[1, 2]], tag_exclude: [3] } const c = cloneFilter(orig) c.tag_or[0].push(9) c.tag_exclude.push(8) expect(orig.tag_or).toEqual([[1, 2]]) // group detached expect(orig.tag_exclude).toEqual([3]) // exclude detached }) 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') }) })