CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 30s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m44s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
tests/factories.py holds image_row/make_image/make_image_async/make_tag. The 17 byte-identical _img/_tag helpers (15 modules) now import them under their old names, so no call site changed. frontend/test/support/stubFetch.js replaces 15 copies that differed only in formatting. Copies whose bodies differ (other defaults, other columns, a url-only stub) are left as they are; folding those needs a look at each caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
241 lines
9.7 KiB
JavaScript
241 lines
9.7 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
import { setActivePinia, createPinia } from 'pinia'
|
||
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
|
||
import { stubFetch } from './support/stubFetch.js'
|
||
|
||
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')
|
||
})
|
||
})
|