183 lines
7.7 KiB
TypeScript
183 lines
7.7 KiB
TypeScript
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { NImage } from 'naive-ui'
|
|
import IdentityThumbnail from '@/features/subject-identity/components/IdentityThumbnail.vue'
|
|
import { identityFixture, identityImageFixture } from '@/features/subject-identity/testing/fixtures'
|
|
|
|
let wrapper: VueWrapper | undefined
|
|
let observers: {
|
|
callback: IntersectionObserverCallback
|
|
observe: ReturnType<typeof vi.fn<(element: Element) => void>>
|
|
disconnect: ReturnType<typeof vi.fn<() => void>>
|
|
}[] = []
|
|
const props = {
|
|
projectId: 'project',
|
|
subjectId: 'subject-db-1',
|
|
name: '书店',
|
|
module: 'scene',
|
|
identityId: 'identity-db-1',
|
|
refreshKey: 0
|
|
}
|
|
|
|
beforeEach(() => {
|
|
observers = []
|
|
vi.stubGlobal(
|
|
'IntersectionObserver',
|
|
class {
|
|
observe = vi.fn<(element: Element) => void>()
|
|
disconnect = vi.fn<() => void>()
|
|
unobserve = vi.fn<(element: Element) => void>()
|
|
constructor(callback: IntersectionObserverCallback) {
|
|
observers.push({ callback, observe: this.observe, disconnect: this.disconnect })
|
|
}
|
|
}
|
|
)
|
|
})
|
|
afterEach(() => {
|
|
wrapper?.unmount()
|
|
wrapper = undefined
|
|
document.body.innerHTML = ''
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
/** 仅触发主体缩略图的可见事件,不触发 NImage 内部图片懒加载观察器。 */
|
|
async function reveal() {
|
|
const observer = observers.find(item => item.observe.mock.calls.some(([element]) => element === wrapper!.element))!
|
|
observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
|
await flushPromises()
|
|
expect(observer.disconnect).toHaveBeenCalled()
|
|
}
|
|
|
|
/** 测试接口返回独立 Response,读取图片元数据不消耗生成额度。 */
|
|
function response(data: unknown) {
|
|
return new Response(JSON.stringify({ data }))
|
|
}
|
|
|
|
describe('主体目录母版缩略图', () => {
|
|
it.each([
|
|
['character', '人物'],
|
|
['scene', '场景'],
|
|
['prop', '道具']
|
|
])('没有母版的 %s 显示类型占位,不补查或生成图片', async (module, label) => {
|
|
const fetcher = vi.fn<typeof fetch>()
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(IdentityThumbnail, { props: { ...props, module, source: null } })
|
|
await reveal()
|
|
expect(wrapper.text()).toBe(label)
|
|
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
|
|
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
|
expect(fetcher).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('直接复用目录母版地址,图片不抢占选择点击,加载失败显示默认占位', async () => {
|
|
const fetcher = vi.fn<typeof fetch>()
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(IdentityThumbnail, {
|
|
attachTo: document.body,
|
|
props: { ...props, source: '/storage/anchor.png' }
|
|
})
|
|
await reveal()
|
|
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
|
|
await wrapper.get('img').trigger('click')
|
|
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
|
await wrapper.get('img').trigger('error')
|
|
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
|
await wrapper.setProps({ source: '/storage/new-anchor.png' })
|
|
expect(wrapper.get('img').attributes('src')).toContain('/storage/new-anchor.png')
|
|
expect(fetcher).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('进入可视区域后才 GET 图库,仅选择权威母版,刷新时重新读取', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
|
response([
|
|
identityImageFixture({
|
|
id: 'candidate',
|
|
isAnchor: false,
|
|
enabled: false,
|
|
imageUrl: '/storage/candidate.png'
|
|
}),
|
|
identityImageFixture({
|
|
id: 'front',
|
|
isAnchor: false,
|
|
viewType: 'front',
|
|
imageUrl: '/storage/front.png'
|
|
}),
|
|
identityImageFixture({ imageUrl: '/storage/anchor.png' })
|
|
])
|
|
)
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(IdentityThumbnail, { props })
|
|
await flushPromises()
|
|
expect(fetcher).not.toHaveBeenCalled()
|
|
await reveal()
|
|
expect(fetcher).toHaveBeenCalledOnce()
|
|
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
|
expect(wrapper.get('img').attributes('src')).toContain('/storage/anchor.png')
|
|
await wrapper.setProps({ refreshKey: 1 })
|
|
await flushPromises()
|
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
|
})
|
|
|
|
it('目录未附带身份摘要时先校验正式身份,再读取母版', async () => {
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async url =>
|
|
response(String(url).endsWith('/images') ? [identityImageFixture()] : identityFixture())
|
|
)
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(IdentityThumbnail, { props: { ...props, identityId: undefined } })
|
|
await reveal()
|
|
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
|
'/api/subjects/subject-db-1/identity',
|
|
'/api/subjects/subject-db-1/identity/images'
|
|
])
|
|
expect(wrapper.find('img').exists()).toBe(true)
|
|
})
|
|
|
|
it('切项目中止旧请求,即使迟到也不能覆盖新主体占位', async () => {
|
|
let finish!: (response: Response) => void
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
|
() =>
|
|
new Promise(resolve => {
|
|
finish = resolve
|
|
})
|
|
)
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(IdentityThumbnail, { props })
|
|
await reveal()
|
|
await wrapper.setProps({
|
|
projectId: 'other-project',
|
|
subjectId: 'other-subject',
|
|
name: '另一主体',
|
|
source: null
|
|
})
|
|
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
|
finish(response([identityImageFixture()]))
|
|
await flushPromises()
|
|
expect(wrapper.find('img').exists()).toBe(false)
|
|
expect(wrapper.get('[role="img"]').attributes('aria-label')).toBe('另一主体 · 暂无母版')
|
|
})
|
|
|
|
it('候选与辅助视角不能冒充母版,跨身份图片返回错误占位', async () => {
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(response([identityImageFixture({ isAnchor: false })]))
|
|
.mockResolvedValueOnce(response([identityImageFixture({ identityId: 'wrong-identity' })]))
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(IdentityThumbnail, { props })
|
|
await reveal()
|
|
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
|
|
await wrapper.setProps({ refreshKey: 1 })
|
|
await flushPromises()
|
|
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
|
expect(wrapper.find('img').exists()).toBe(false)
|
|
})
|
|
|
|
it('危险地址不写入图片,无效图片也不会触发生图', () => {
|
|
wrapper = mount(IdentityThumbnail, { props: { ...props, source: 'javascript:alert(1)' } })
|
|
expect(wrapper.find('img').exists()).toBe(false)
|
|
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
|
})
|
|
})
|