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 void>> disconnect: ReturnType 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() 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() 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().mockImplementation(async () => response([ identityImageFixture({ id: 'candidate', isAnchor: 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() .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().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() .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('母版暂不可用') }) })