agentAvatar.test.ts 5.2 KB

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