6d630d13d6
Gallery now has in-view filtering, styled like the app's sticky v-tabs
chrome (pinned at top:64px under TopNav).
- GalleryFilterBar: combined tag+artist autocomplete (searches
/api/tags + /api/artists), closable filter chips (multi-tag AND),
media toggle (All/Images/Videos), Newest/Oldest sort, Clear. Writes all
state to the URL via router.push.
- gallery store: filter is now { tag_ids, artist_id, media_type, sort,
post_id }; applyFilterFromQuery makes the URL the single source of truth
(deep-linkable, back-button works); chip labels resolved by id or
pre-noted on pick. Replaces the standalone tag chip + setTag/PostFilter.
- GalleryView: renders the bar (hidden in post-detail), syncs route.query
→ store on mount + every query change.
Also untracks the transient .claude/scheduled_tasks.lock committed in
3f30327 and gitignores it.
Tests: store parses query → composable scroll params, post_id exclusivity,
newest-sort omitted, label pre-seed, single initial fetch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
3.3 KiB
JavaScript
91 lines
3.3 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
import { setActivePinia, createPinia } from 'pinia'
|
||
import { 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('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')
|
||
})
|
||
})
|