agentAvatar.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { mount } from '@vue/test-utils'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { nextTick } from 'vue'
  4. import { defaultAgentIdentity, encodeAgentAvatar } from '@shared/agentIdentity'
  5. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  6. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  7. const transparentPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
  8. describe('AgentAvatar', () => {
  9. it.each(['mascot', 'image'] as const)('keeps %s identity and animates ribbons only during live work', async (mode) => {
  10. const identity = { ...defaultAgentIdentity('Bot'), shape: 'triangle' as const, color: '#1488ff',
  11. avatarMode: mode, ...(mode === 'image' ? { imageDataURL: transparentPng } : {}) }
  12. const wrapper = mount(AgentAvatar, { props: { name: 'Bot', avatar: encodeAgentAvatar(identity), state: 'working', fixedTime: 500 } })
  13. expect(wrapper.findAll('[data-part=trails] path')).toHaveLength(3)
  14. if (mode === 'image') {
  15. expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
  16. expect((wrapper.get('[data-part=bodyGroup]').element as SVGElement).style.visibility).toBe('hidden')
  17. } else {
  18. expect(wrapper.get('[data-part=outline] path').attributes('fill')).toBe('#1488ff')
  19. expect(wrapper.get('[data-part=bodyContent]').attributes('transform')).toContain('scale(.72)')
  20. }
  21. await wrapper.setProps({ state: 'idle', avatar: encodeAgentAvatar({ ...identity, expression: 'working' }) })
  22. expect(wrapper.findAll('[data-part=trails] path')).toHaveLength(0)
  23. wrapper.unmount()
  24. })
  25. it('marks real images so caller backgrounds cannot show through transparent pixels', () => {
  26. const wrapper = mount(AgentAvatar, { props: { name: '丫头', avatar: transparentPng } })
  27. expect(wrapper.classes()).toContain('agent-avatar--image')
  28. expect(wrapper.find('img').attributes('src')).toBe(transparentPng)
  29. wrapper.unmount()
  30. })
  31. it('renders a stable animated mascot when no image is configured', () => {
  32. const wrapper = mount(AgentAvatar, { props: { name: '丫头' } })
  33. expect(wrapper.classes()).not.toContain('agent-avatar--image')
  34. expect(wrapper.find('img').exists()).toBe(false)
  35. expect(wrapper.find('svg').exists()).toBe(true)
  36. expect(wrapper.find('[data-part=outline] path').attributes('fill')).toBe('#00c875')
  37. expect(wrapper.attributes('data-animated')).toBe('false')
  38. expect(wrapper.findAll('[data-part=eye0], [data-part=eye1]')).toHaveLength(2)
  39. wrapper.unmount()
  40. })
  41. it('uses the compact triangle and exposes the notifying motion state', () => {
  42. const wrapper = mount(AgentAvatar, {
  43. props: {
  44. name: 'Scout',
  45. avatar: encodeAgentAvatar({...defaultAgentIdentity('Scout'),shape:'triangle',color:'#00b9ac',expression:'curious'}),
  46. state: 'notifying',
  47. },
  48. })
  49. expect(wrapper.classes()).toContain('agent-avatar--notifying')
  50. expect(wrapper.find('[data-part=outline] path[fill="#00b9ac"]').exists()).toBe(true)
  51. wrapper.unmount()
  52. })
  53. it('keeps a group-message image transparent when MessageTimeline adds its fallback class', () => {
  54. const wrapper = mount(MessageTimeline, {
  55. attachTo: document.body,
  56. props: {
  57. messages: [{ id: 'assistant-1', role: 'assistant', profile: 'default', author: '丫头', content: '收到,测试正常。' }],
  58. agentAvatars: { default: transparentPng },
  59. },
  60. })
  61. const avatar = wrapper.get('.message__avatar')
  62. expect(avatar.classes()).toEqual(expect.arrayContaining(['agent-avatar--image', 'message__avatar']))
  63. expect(getComputedStyle(avatar.element).backgroundColor).toBe('rgba(0, 0, 0, 0)')
  64. wrapper.unmount()
  65. })
  66. })
  67. it('parks animation frames for idle, hidden, background and reduced-motion avatars', async () => {
  68. let visible: ((entries: Array<{isIntersecting:boolean}>) => void) | undefined
  69. let motion: (() => void) | undefined
  70. let reduced=false, background=false
  71. const descriptor=Object.getOwnPropertyDescriptor(document,'visibilityState')
  72. const raf=vi.fn(()=>42), cancel=vi.fn()
  73. vi.stubGlobal('requestAnimationFrame',raf);vi.stubGlobal('cancelAnimationFrame',cancel)
  74. vi.stubGlobal('IntersectionObserver',class {constructor(callback:typeof visible){visible=callback} observe(){} disconnect(){}})
  75. vi.stubGlobal('matchMedia',()=>({get matches(){return reduced},addEventListener(_event:string,fn:()=>void){motion=fn},removeEventListener(){}}))
  76. Object.defineProperty(document,'visibilityState',{configurable:true,get:()=>background?'hidden':'visible'})
  77. const wrapper=mount(AgentAvatar,{props:{name:'quiet',size:32}})
  78. try {
  79. expect(raf).not.toHaveBeenCalled()
  80. await wrapper.setProps({state:'working'})
  81. expect(wrapper.attributes('data-animated')).toBe('true')
  82. expect(raf).toHaveBeenCalled()
  83. visible?.([{isIntersecting:false}]);await nextTick()
  84. expect(wrapper.attributes('data-animated')).toBe('false')
  85. expect(cancel).toHaveBeenCalled()
  86. visible?.([{isIntersecting:true}]);await nextTick()
  87. background=true;document.dispatchEvent(new Event('visibilitychange'));await nextTick()
  88. expect(wrapper.attributes('data-animated')).toBe('false')
  89. background=false;document.dispatchEvent(new Event('visibilitychange'));reduced=true;motion?.();await nextTick()
  90. expect(wrapper.attributes('data-animated')).toBe('false')
  91. } finally {wrapper.unmount();vi.unstubAllGlobals();if(descriptor)Object.defineProperty(document,'visibilityState',descriptor);else delete (document as any).visibilityState}
  92. })
  93. it('renders all picture crops from the same bytes and falls back cleanly on decode failure',async()=>{
  94. const identity={...defaultAgentIdentity('picture'),avatarMode:'image' as const,imageDataURL:transparentPng}
  95. const wrapper=mount(AgentAvatar,{props:{name:'picture',avatar:encodeAgentAvatar({...identity,imageCrop:'circle'})}})
  96. expect(wrapper.get('img').element.style.borderRadius).toBe('50%')
  97. await wrapper.setProps({avatar:encodeAgentAvatar({...identity,imageCrop:'square'})})
  98. expect(wrapper.get('img').element.style.borderRadius).toBe('0')
  99. expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
  100. await wrapper.get('img').trigger('error');await nextTick()
  101. expect(wrapper.find('img').exists()).toBe(false)
  102. expect(wrapper.get('[data-part=outline] path').attributes('fill')).toBe('#00c875')
  103. wrapper.unmount()
  104. })