c22f37d64d
The gallery's newest/oldest sort keys off image_record.effective_date = COALESCE(primary post's post_date, created_at). The primary post is often the repost/download the file came from, so the grid led with download dates rather than when content was first posted (operator-flagged). Add a second materialized sort key, earliest_post_date = MIN(post_date) across ALL of an image's provenance posts (every post it appears in), else created_at — the original publish date. Mirrors the effective_date pattern so the sort stays a forward index scan. - alembic 0071: add earliest_post_date + index (DESC, id DESC); backfill created_at baseline then MIN over image_provenance ⋈ post. - importer: recompute earliest_post_date whenever a dated post is linked (MIN over the image's provenance, which now includes the just-added row). - gallery_service: new sorts posted_new / posted_old key off earliest_post_date; cursor + year/month grouping follow the active column transparently. - api: accept posted_new|posted_old; DEFAULT is now posted_new so the grid leads with original publish date. newest/oldest (effective_date) still available. - frontend: sort dropdown gains "Newest/Oldest post date" (default Newest post date); existing effective-date sorts relabelled "Newest/Oldest added". - tests: service test asserts posted_new/posted_old key off earliest_post_date; frontend default-sort omission test updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
252 lines
9.9 KiB
JavaScript
252 lines
9.9 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'
|
||
|
||
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')
|
||
})
|
||
})
|