feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
@@ -0,0 +1,182 @@
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('母版暂不可用')
})
})