chatAppearance.test.ts 5.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. import { describe, expect, it, afterEach, beforeEach, vi } from 'vitest'
  2. import { nextTick } from 'vue'
  3. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  4. import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
  5. import { mount } from '@vue/test-utils'
  6. import { appearancePreset, bubbleColors, bubbleVariables, contrast, normalizeAppearance } from '@/utils/chatAppearance'
  7. import { useChatAppearance, CHAT_APPEARANCE_KEY } from '@/stores/chatAppearance'
  8. import ChatAppearancePanel from '@/components/app/ChatAppearancePanel.vue'
  9. beforeEach(() => {
  10. const values = new Map<string, string>()
  11. vi.stubGlobal('localStorage', {
  12. getItem: (key: string) => values.get(key) ?? null,
  13. setItem: (key: string, value: string) => { values.set(key, value) },
  14. clear: () => values.clear(),
  15. })
  16. vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
  17. HTMLElement.prototype.scrollTo = () => {}
  18. useChatAppearance().selectPreset('current')
  19. })
  20. afterEach(() => { vi.restoreAllMocks(); useChatAppearance().selectPreset('current') })
  21. describe('chat bubble appearance', () => {
  22. it.each([MessageTimeline, WorkspaceMessageTimeline])('updates rendered bubbles when a preset changes without replacing messages', async (component) => {
  23. const store = useChatAppearance()
  24. const wrapper = mount(component as typeof MessageTimeline, {
  25. props: {identity: 'appearance', messages: [{id:'u',role:'user',content:'问题'}, {id:'a',role:'assistant',content:'长回复'}, {id:'s',role:'system',content:'系统信息'}]},
  26. global: {stubs: {AgentAvatar:true, AppIcon:true, MarkdownContent:true}},
  27. })
  28. store.selectPreset('codex'); await nextTick()
  29. expect(wrapper.findAll('.message__content.chat-bubble')).toHaveLength(2)
  30. expect(wrapper.get('.message--user .chat-bubble').attributes('style')).toContain('#E4F3FC')
  31. store.selectPreset('grok'); await nextTick()
  32. expect(wrapper.get('.message--user .chat-bubble').attributes('style')).toContain('#090909')
  33. expect(wrapper.find('.message--system .chat-bubble').exists()).toBe(false)
  34. wrapper.unmount()
  35. })
  36. it('keeps both sides filled in Codex and Grok Bot presets with readable text', () => {
  37. expect(appearancePreset('grok').user.light).toBe('#090909')
  38. expect(appearancePreset('codex').user.light).toBe('#E4F3FC')
  39. for (const id of ['current', 'grok', 'codex'] as const) for (const role of ['user', 'assistant'] as const) {
  40. const style = appearancePreset(id)[role]
  41. for (const dark of [false, true]) {
  42. const colors = bubbleColors(style, dark)
  43. expect(colors.background).not.toContain('transparent')
  44. expect(contrast(colors.background, colors.text)).toBeGreaterThanOrEqual(4.5)
  45. }
  46. }
  47. })
  48. it('rejects corrupt persisted settings and clamps custom shapes', () => {
  49. for (const data of [null, {version: 2, preset: 'grok'}, {version: 1, preset: 'custom', user: {}}]) expect(normalizeAppearance(data)).toEqual(appearancePreset('current'))
  50. const custom = {...appearancePreset('codex'), preset: 'custom', user: {...appearancePreset('codex').user, radius: 300}}
  51. expect(normalizeAppearance(custom).user.radius).toBe(28)
  52. })
  53. it('protects text against extreme custom gradients', () => {
  54. const style = {...appearancePreset('codex').assistant, gradient: true, light: '#FFFFFF', lightEnd: '#000000'}
  55. expect(bubbleColors(style, false).background).toContain('rgba')
  56. expect(bubbleVariables({...style, tail:true}, 'assistant')['--bubble-radius']).toMatch(/4px$/)
  57. })
  58. it('persists preset selection and undo across store consumers', () => {
  59. const a = useChatAppearance(), b = useChatAppearance()
  60. a.selectPreset('grok'); a.selectPreset('codex')
  61. expect(b.appearance.value.preset).toBe('codex')
  62. expect(JSON.parse(window.localStorage.getItem(CHAT_APPEARANCE_KEY)! ).preset).toBe('codex')
  63. b.selectPreset('codex') // Selecting the active preset must preserve the undo target.
  64. b.undo()
  65. expect(a.appearance.value.preset).toBe('grok')
  66. expect(JSON.parse(window.localStorage.getItem(CHAT_APPEARANCE_KEY)!).preset).toBe('grok')
  67. })
  68. it('does not claim success when storage is unavailable', () => {
  69. const store = useChatAppearance()
  70. store.selectPreset('grok')
  71. vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => {throw new Error('quota')})
  72. store.selectPreset('codex')
  73. expect(store.appearance.value.preset).toBe('grok')
  74. expect(store.saveError.value).toContain('无法保存')
  75. })
  76. it('edits the selected role and theme without changing the other role', async () => {
  77. const store = useChatAppearance(); store.selectPreset('codex')
  78. const wrapper = mount(ChatAppearancePanel, { global: {stubs: {AgentAvatar:true, AppIcon:true}} })
  79. await wrapper.get('[aria-label="调整哪一方"] button:nth-child(2)').trigger('click')
  80. await wrapper.get('[aria-label="预览深色气泡"]').trigger('click')
  81. await wrapper.get('[aria-label="颜色十六进制值"]').setValue('#090909')
  82. expect(store.appearance.value.assistant.dark).toBe('#090909')
  83. expect(store.appearance.value.assistant.light).toBe('#F1F1F1')
  84. expect(store.appearance.value.user.light).toBe('#E4F3FC')
  85. expect(store.appearance.value.preset).toBe('custom')
  86. await wrapper.get('[aria-label="颜色十六进制值"]').setValue('oops')
  87. expect(wrapper.get('[role="alert"]').text()).toContain('六位颜色')
  88. expect(store.appearance.value.assistant.dark).toBe('#090909')
  89. wrapper.unmount()
  90. })
  91. })