settingsCenterDialog.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { mount, type VueWrapper } from '@vue/test-utils'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { defineComponent } from 'vue'
  4. import type { Profile } from '@shared/types'
  5. import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
  6. const profiles: Profile[] = [
  7. { name: 'ops:blue/team', agentName: '运维 Agent', isDefault: false },
  8. { name: 'default', agentName: '丫头', isDefault: true },
  9. ]
  10. function simpleStub(name: string, testId: string) {
  11. return defineComponent({ name, template: `<div data-testid="${testId}"></div>` })
  12. }
  13. const ModelServicesPanelStub = defineComponent({
  14. name: 'ModelServicesPanel',
  15. props: { profile: { type: String, required: true } },
  16. emits: ['dirty-change'],
  17. template: '<div data-testid="model-services" :data-profile="profile"><button data-testid="dirty-model" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
  18. })
  19. const DuplexVoicePanelStub = defineComponent({
  20. name: 'DuplexVoicePanel',
  21. emits: ['dirty-change'],
  22. template: '<div data-testid="duplex-voice"><button data-testid="dirty-voice" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
  23. })
  24. const SystemUpdatePanelStub = defineComponent({
  25. name: 'SystemUpdatePanel',
  26. emits: ['lock-change'],
  27. template: `
  28. <div data-testid="system-update">
  29. <button data-testid="lock-update" type="button" @click="$emit('lock-change', true)">lock</button>
  30. <button data-testid="unlock-update" type="button" @click="$emit('lock-change', false)">unlock</button>
  31. </div>
  32. `,
  33. })
  34. const childStubs = {
  35. Teleport: true,
  36. AgentAvatar: true,
  37. AppIcon: true,
  38. AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
  39. ModelServicesPanel: ModelServicesPanelStub,
  40. AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
  41. NodePairingPanel: simpleStub('NodePairingPanel', 'node-pairing'),
  42. SystemOverviewPanel: simpleStub('SystemOverviewPanel', 'system-overview'),
  43. SystemManagementPanel: simpleStub('SystemManagementPanel', 'system-management'),
  44. DuplexVoicePanel: DuplexVoicePanelStub,
  45. SystemUpdatePanel: SystemUpdatePanelStub,
  46. }
  47. function mountSettings(overrides: Record<string, unknown> = {}) {
  48. return mount(SettingsCenterDialog, {
  49. attachTo: document.body,
  50. props: {
  51. open: true,
  52. activeProfile: profiles[0],
  53. profiles,
  54. userName: '管理员',
  55. pairingUserName: 'owner',
  56. isAdmin: true,
  57. ...overrides,
  58. } as never,
  59. global: { stubs: childStubs },
  60. })
  61. }
  62. function navigationButton(wrapper: VueWrapper, label: string) {
  63. const button = wrapper.findAll<HTMLButtonElement>('.settings-sidebar nav button')
  64. .find(candidate => candidate.text().includes(label))
  65. expect(button, `missing navigation button: ${label}`).toBeDefined()
  66. return button!
  67. }
  68. afterEach(() => {
  69. document.body.innerHTML = ''
  70. vi.restoreAllMocks()
  71. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
  72. })
  73. describe('Settings center dialog', () => {
  74. it('groups administrator pages and routes Agent, voice, and system content to the right scope', async () => {
  75. const wrapper = mountSettings()
  76. expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual([
  77. '当前 Agent',
  78. '账号',
  79. '系统 · 仅管理员',
  80. ])
  81. await navigationButton(wrapper, '模型与 Provider').trigger('click')
  82. expect(navigationButton(wrapper, '模型与 Provider').attributes('aria-current')).toBe('page')
  83. const modelPanel = wrapper.get('[data-testid="model-services"]')
  84. expect(modelPanel.attributes('data-profile')).toBe('ops:blue/team')
  85. expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维 Agent / ops:blue/team')
  86. await navigationButton(wrapper, '登录与安全').trigger('click')
  87. expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
  88. expect(wrapper.get('.settings-account-summary').text()).toContain('owner')
  89. expect(wrapper.get('.settings-account-summary').text()).toContain('当前账号')
  90. expect(wrapper.get('.settings-account-summary .account-initial-avatar').text()).toBe('O')
  91. const voiceButton = navigationButton(wrapper, '双流语音')
  92. expect(voiceButton.text()).toContain('全局')
  93. expect(voiceButton.element.closest('section')?.textContent).toContain('系统 · 仅管理员')
  94. await voiceButton.trigger('click')
  95. expect(wrapper.find('[data-testid="duplex-voice"]').exists()).toBe(true)
  96. expect(wrapper.get('.settings-content__header').text()).toContain('全局设置 · 仅管理员')
  97. })
  98. it('hides model management and the complete system group from non-admin users', () => {
  99. const wrapper = mountSettings({ isAdmin: false, initialPage: 'system-update' })
  100. const navigation = wrapper.get('.settings-sidebar nav')
  101. expect(navigation.findAll('h3').map(heading => heading.text())).toEqual(['当前 Agent', '账号'])
  102. expect(navigation.text()).not.toContain('模型与 Provider')
  103. expect(navigation.text()).not.toContain('系统 · 仅管理员')
  104. expect(navigation.text()).not.toContain('系统概览')
  105. expect(navigation.text()).not.toContain('双流语音')
  106. expect(navigation.text()).not.toContain('更新与回滚')
  107. expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(false)
  108. expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
  109. })
  110. it('blocks closing and page changes while the update panel reports a lock', async () => {
  111. const wrapper = mountSettings({ initialPage: 'system-update' })
  112. expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(true)
  113. await wrapper.get('[data-testid="lock-update"]').trigger('click')
  114. expect(wrapper.get<HTMLButtonElement>('.settings-center__close').element.disabled).toBe(true)
  115. await navigationButton(wrapper, '登录与安全').trigger('click')
  116. await wrapper.get('.settings-center-layer').trigger('mousedown')
  117. expect(wrapper.emitted('close')).toBeUndefined()
  118. expect(wrapper.get('.settings-content__header').text()).toContain('更新与回滚')
  119. expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(false)
  120. await wrapper.get('[data-testid="unlock-update"]').trigger('click')
  121. await navigationButton(wrapper, '登录与安全').trigger('click')
  122. expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
  123. })
  124. it('asks before discarding an edited page and keeps the current page when declined', async () => {
  125. const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
  126. const wrapper = mountSettings()
  127. wrapper.findComponent({ name: 'AgentIdentityPanel' }).vm.$emit('dirty-change', true)
  128. await wrapper.vm.$nextTick()
  129. await navigationButton(wrapper, '登录与安全').trigger('click')
  130. expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
  131. expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
  132. await navigationButton(wrapper, '登录与安全').trigger('click')
  133. expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
  134. })
  135. it('protects Provider and global voice drafts from silent navigation or close', async () => {
  136. const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
  137. const modelWrapper = mountSettings({ initialPage: 'agent-models' })
  138. await modelWrapper.get('[data-testid="dirty-model"]').trigger('click')
  139. await navigationButton(modelWrapper, '登录与安全').trigger('click')
  140. expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
  141. expect(modelWrapper.find('[data-testid="model-services"]').exists()).toBe(true)
  142. modelWrapper.unmount()
  143. confirm.mockClear()
  144. const voiceWrapper = mountSettings({ initialPage: 'system-voice' })
  145. await voiceWrapper.get('[data-testid="dirty-voice"]').trigger('click')
  146. await voiceWrapper.get('.settings-center__close').trigger('click')
  147. expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
  148. expect(voiceWrapper.emitted('close')).toBeUndefined()
  149. })
  150. it('moves mobile focus into details and back to the active category', async () => {
  151. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
  152. const wrapper = mountSettings()
  153. const identity = navigationButton(wrapper, '身份与头像')
  154. await identity.trigger('click')
  155. await wrapper.vm.$nextTick()
  156. expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
  157. await wrapper.get('.mobile-back').trigger('click')
  158. await wrapper.vm.$nextTick()
  159. expect(document.activeElement).toBe(identity.element)
  160. const security = navigationButton(wrapper, '登录与安全')
  161. await security.trigger('click')
  162. await wrapper.vm.$nextTick()
  163. expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
  164. await wrapper.get('.mobile-back').trigger('click')
  165. await wrapper.vm.$nextTick()
  166. expect(document.activeElement).toBe(security.element)
  167. })
  168. it('supports listbox keyboard navigation for the Agent selector', async () => {
  169. const wrapper = mountSettings()
  170. const trigger = wrapper.get('.settings-agent-selector > button')
  171. expect(trigger.attributes('aria-haspopup')).toBe('listbox')
  172. await trigger.trigger('click')
  173. await wrapper.vm.$nextTick()
  174. const menu = wrapper.get('.settings-agent-menu')
  175. const options = menu.findAll<HTMLButtonElement>('[role="option"]')
  176. expect(document.activeElement).toBe(options[0]!.element)
  177. await menu.trigger('keydown', { key: 'ArrowDown' })
  178. expect(document.activeElement).toBe(options[1]!.element)
  179. })
  180. })