feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NImage, NScrollbar } from 'naive-ui'
|
||||
import { AssetImage } from '@/components/ui'
|
||||
import IdentityImageDialog from '@/features/subject-identity/components/IdentityImageDialog.vue'
|
||||
import IdentityGallery from '@/features/subject-identity/components/IdentityGallery.vue'
|
||||
import CastingCandidateDialog from '@/features/subject-identity/components/CastingCandidateDialog.vue'
|
||||
import { subjectIdentityApi } from '@/features/subject-identity/api'
|
||||
import {
|
||||
canBeAnchor,
|
||||
castingStatusLabel,
|
||||
currentAnchor,
|
||||
groupIdentitySubjects,
|
||||
mergeCastingSubjects,
|
||||
readImageProvenance
|
||||
} from '@/features/subject-identity/model'
|
||||
import { identityImageFixture } from '@/features/subject-identity/testing/fixtures'
|
||||
import { formFixture } from '@/features/subject-images/testing/fixtures'
|
||||
import { expandSections, selectControl } from '@/testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 从真实 Naive 弹窗内获取确认按钮。 */
|
||||
function button(label: string) {
|
||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||
return item
|
||||
}
|
||||
|
||||
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
|
||||
function input(selector: string, value: string) {
|
||||
if (selector === '#identity-view' || selector === '#identity-reference') {
|
||||
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
|
||||
return
|
||||
}
|
||||
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
|
||||
item.value = value
|
||||
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('身份图与母版契约', () => {
|
||||
it('详情大图 contain 完整显示,历史仍为 cover,缩略图只切换记录', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [
|
||||
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png' }),
|
||||
identityImageFixture({
|
||||
id: 'front',
|
||||
viewType: 'front',
|
||||
isAnchor: false,
|
||||
imageUrl: '/storage/front.png'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
const images = wrapper.findAllComponents(NImage)
|
||||
expect(images).toHaveLength(3)
|
||||
expect(images.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover'])
|
||||
expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true])
|
||||
await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
const main = wrapper.get('.asset-image-preview img')
|
||||
expect((main.element as HTMLImageElement).style.objectFit).toBe('contain')
|
||||
expect(main.attributes('src')).toContain('/storage/front.png')
|
||||
await main.trigger('click')
|
||||
await flushPromises()
|
||||
const original = document.querySelector<HTMLImageElement>('.n-image-preview')!
|
||||
expect(original.getAttribute('src')).toBe(main.attributes('src'))
|
||||
expect(original.style.objectFit).not.toBe('cover')
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
|
||||
const image = identityImageFixture({ prompt: '本次实际生成提示词' })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
|
||||
})
|
||||
const panel = wrapper.get('.n-collapse-item')
|
||||
expect(panel.classes()).toContain('n-collapse-item--active')
|
||||
expect(panel.text()).toContain('本次实际生成提示词')
|
||||
await panel.get('.n-collapse-item__header-main').trigger('click')
|
||||
expect(panel.classes()).not.toContain('n-collapse-item--active')
|
||||
await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] })
|
||||
expect(panel.classes()).not.toContain('n-collapse-item--active')
|
||||
await panel.get('.n-collapse-item__header-main').trigger('click')
|
||||
expect(panel.classes()).toContain('n-collapse-item--active')
|
||||
expect(panel.text()).toContain('更新后的实际提示词')
|
||||
})
|
||||
|
||||
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
|
||||
const images = [
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
imageUrl: '/storage/candidate.png',
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
}),
|
||||
identityImageFixture({
|
||||
id: 'failed',
|
||||
status: 'failed',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
error: '图片模型超时'
|
||||
}),
|
||||
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }),
|
||||
identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }),
|
||||
identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }),
|
||||
identityImageFixture({
|
||||
id: 'generating',
|
||||
status: 'generating',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
})
|
||||
]
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images }
|
||||
})
|
||||
const history = wrapper.get('[aria-label="身份图片历史"]')
|
||||
expect(history.findAll('.image-history-item')).toHaveLength(6)
|
||||
expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({
|
||||
xScrollable: true,
|
||||
trigger: 'none',
|
||||
contentStyle: { width: 'max-content' }
|
||||
})
|
||||
expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条')
|
||||
expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element)
|
||||
expect(history.text()).toContain('当前母版')
|
||||
expect(history.text()).toContain('母版候选')
|
||||
expect(history.text()).toContain('正面')
|
||||
expect(history.text()).toContain('生成失败')
|
||||
expect(history.text()).toContain('排队中')
|
||||
expect(history.text()).toContain('生成中')
|
||||
expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
|
||||
for (const image of images) {
|
||||
await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click')
|
||||
expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1)
|
||||
expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true')
|
||||
const preview = wrapper
|
||||
.findAllComponents(AssetImage)
|
||||
.find(item => item.classes().includes('asset-image-preview'))!
|
||||
expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null)
|
||||
expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`)
|
||||
}
|
||||
await history.get('[aria-label="查看身份图片 failed"]').trigger('click')
|
||||
expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败')
|
||||
expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时')
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
const anchor = identityImageFixture({ id: 'anchor' })
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [anchor, front]
|
||||
}
|
||||
})
|
||||
await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click')
|
||||
const viewport = wrapper.get<HTMLElement>('.image-history .n-scrollbar-container').element
|
||||
viewport.scrollLeft = 120
|
||||
await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] })
|
||||
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport)
|
||||
expect(viewport.scrollLeft).toBe(120)
|
||||
await wrapper.setProps({ images: [anchor] })
|
||||
expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条')
|
||||
})
|
||||
|
||||
it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: ['candidate-a', 'candidate-b'].map(id =>
|
||||
identityImageFixture({ id, isAnchor: false, enabled: false })
|
||||
)
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
expect(button('确认演员选择').disabled).toBe(false)
|
||||
await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员')
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
button('确认演员选择').click()
|
||||
expect(wrapper.emitted('anchor')).toEqual([['candidate-b']])
|
||||
})
|
||||
|
||||
it('无历史图片时只展示空状态,不伪造缩略图或母版', () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] }
|
||||
})
|
||||
expect(wrapper.text()).toContain('身份参考图 · 0')
|
||||
expect(wrapper.text()).toContain('尚无身份参考图')
|
||||
expect(wrapper.find('.image-history').exists()).toBe(false)
|
||||
expect(wrapper.find('.asset-image-preview').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
|
||||
expect(canBeAnchor(front)).toBe(false)
|
||||
expect(canBeAnchor(candidate)).toBe(true)
|
||||
expect(currentAnchor([front, candidate])).toBeUndefined()
|
||||
expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false)
|
||||
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
|
||||
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
|
||||
expect(readImageProvenance('{bad')).toEqual({})
|
||||
expect(readImageProvenance(null)).toEqual({})
|
||||
expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
|
||||
identityAnchorImageId: 'anchor',
|
||||
referenceImageId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('选角就绪结果可以补入尚无形态的角色目录', () => {
|
||||
const rows = mergeCastingSubjects(
|
||||
groupIdentitySubjects([formFixture()]),
|
||||
[
|
||||
{
|
||||
subjectId: 'character-without-form',
|
||||
subjectRef: '@CH0002',
|
||||
subjectName: '陆川',
|
||||
status: 'missing_identity',
|
||||
isLocked: false
|
||||
}
|
||||
],
|
||||
'project-1'
|
||||
)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
|
||||
projectId: 'project-1',
|
||||
module: 'character',
|
||||
forms: []
|
||||
})
|
||||
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
|
||||
})
|
||||
|
||||
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
input('#identity-view', 'three-quarter')
|
||||
input('#identity-reference', 'identity-image-1')
|
||||
input('#identity-width', '2048')
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
input('#identity-height', '2048')
|
||||
input('#identity-image-prompt', ' 自定义身份提示词 ')
|
||||
await flushPromises()
|
||||
button('确认生成身份图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([
|
||||
[
|
||||
{
|
||||
viewType: 'three-quarter',
|
||||
referenceImageId: 'identity-image-1',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
prompt: '自定义身份提示词'
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
input('#identity-reference', 'identity-image-1')
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ images: [] })
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
|
||||
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
|
||||
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
const select = selectControl(wrapper!, 'id', 'identity-view')
|
||||
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
||||
expect(select.props('value')).toBe('front')
|
||||
})
|
||||
|
||||
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
|
||||
}
|
||||
})
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
await expandSections()
|
||||
expect(wrapper.text()).toContain('<script>不执行</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: [identityImageFixture({ isAnchor: true })]
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
|
||||
button('确认演员选择').click()
|
||||
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
|
||||
})
|
||||
|
||||
it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => {
|
||||
wrapper = mount(CastingCandidateDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不会复用当前身份母版')
|
||||
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成候选').click()
|
||||
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
||||
})
|
||||
|
||||
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
total: 0,
|
||||
targetCount: 0,
|
||||
generated: 0,
|
||||
skipped: 0,
|
||||
skippedLocked: 0,
|
||||
failed: 0,
|
||||
failures: []
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
|
||||
await subjectIdentityApi.generateCastingCandidate('subject/1', {})
|
||||
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/projects/project%2F1/character-identities/generate',
|
||||
'/api/subjects/subject%2F1/identity/casting-candidates',
|
||||
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
|
||||
])
|
||||
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
|
||||
})
|
||||
})
|
||||
@@ -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('母版暂不可用')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user