| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- import { mount, type VueWrapper } from '@vue/test-utils'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { defineComponent } from 'vue'
- import type { Profile } from '@shared/types'
- import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
- const profiles: Profile[] = [
- { name: 'ops:blue/team', agentName: '运维机器人', isDefault: false },
- { name: 'default', agentName: '丫头', isDefault: true },
- ]
- function simpleStub(name: string, testId: string) {
- return defineComponent({ name, template: `<div data-testid="${testId}"></div>` })
- }
- const ModelServicesPanelStub = defineComponent({
- name: 'ModelServicesPanel',
- props: { profile: { type: String, required: true } },
- emits: ['dirty-change'],
- template: '<div data-testid="model-services" :data-profile="profile"><button data-testid="dirty-model" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
- })
- const DuplexVoicePanelStub = defineComponent({
- name: 'DuplexVoicePanel',
- emits: ['dirty-change'],
- template: '<div data-testid="duplex-voice"><button data-testid="dirty-voice" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
- })
- const SystemUpdatePanelStub = defineComponent({
- name: 'SystemUpdatePanel',
- emits: ['lock-change'],
- template: `
- <div data-testid="system-update">
- <button data-testid="lock-update" type="button" @click="$emit('lock-change', true)">lock</button>
- <button data-testid="unlock-update" type="button" @click="$emit('lock-change', false)">unlock</button>
- </div>
- `,
- })
- const childStubs = {
- Teleport: true,
- AgentAvatar: true,
- AppIcon: true,
- BotPluginsPanel: simpleStub('BotPluginsPanel', 'bot-plugins'),
- BotAutomationPanel: simpleStub('BotAutomationPanel', 'bot-automations'),
- AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
- ModelServicesPanel: ModelServicesPanelStub,
- AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
- NodePairingPanel: simpleStub('NodePairingPanel', 'node-pairing'),
- SystemOverviewPanel: simpleStub('SystemOverviewPanel', 'system-overview'),
- SystemManagementPanel: simpleStub('SystemManagementPanel', 'system-management'),
- DuplexVoicePanel: DuplexVoicePanelStub,
- SystemUpdatePanel: SystemUpdatePanelStub,
- }
- function mountSettings(overrides: Record<string, unknown> = {}) {
- return mount(SettingsCenterDialog, {
- attachTo: document.body,
- props: {
- open: true,
- activeProfile: profiles[0],
- profiles,
- userName: '管理员',
- pairingUserName: 'owner',
- isAdmin: true,
- ...overrides,
- } as never,
- global: { stubs: childStubs },
- })
- }
- function navigationButton(wrapper: VueWrapper, label: string) {
- const button = wrapper.findAll<HTMLButtonElement>('.settings-sidebar nav button')
- .find(candidate => candidate.text().includes(label))
- expect(button, `missing navigation button: ${label}`).toBeDefined()
- return button!
- }
- afterEach(() => {
- document.body.innerHTML = ''
- vi.restoreAllMocks()
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
- })
- describe('Settings center dialog', () => {
- it('keeps plugin, connected-app and automation settings scoped to Bot mode', async () => {
- const normal = mountSettings({ initialPage: 'bot-plugins' })
- expect(normal.find('[data-testid="bot-plugins"]').exists()).toBe(false)
- expect(normal.findAll('.settings-sidebar nav button').some(b => b.text() === '插件')).toBe(false)
- normal.unmount()
- const bot = mountSettings({ botMode: true, initialPage: 'bot-plugins' })
- await vi.waitFor(() => expect(bot.find('[data-testid="bot-plugins"]').exists()).toBe(true))
- await navigationButton(bot, '自动化').trigger('click')
- await vi.waitFor(() => expect(bot.find('[data-testid="bot-automations"]').exists()).toBe(true))
- await navigationButton(bot, '关于').trigger('click')
- expect(bot.get('.bot-about').text()).toContain('夭夭 AI')
- expect(bot.get('.bot-about a').attributes('href')).toBe('https://yaoyao.samien.cn')
- bot.unmount()
- })
- it('groups administrator pages and routes Agent, voice, and system content to the right scope', async () => {
- const wrapper = mountSettings()
- expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual([
- '当前机器人',
- '账号',
- '系统 · 仅管理员',
- ])
- await navigationButton(wrapper, '模型与 Provider').trigger('click')
- expect(navigationButton(wrapper, '模型与 Provider').attributes('aria-current')).toBe('page')
- const modelPanel = wrapper.get('[data-testid="model-services"]')
- expect(modelPanel.attributes('data-profile')).toBe('ops:blue/team')
- expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维机器人 / ops:blue/team')
- await navigationButton(wrapper, '登录与安全').trigger('click')
- expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
- expect(wrapper.get('.settings-account-summary').text()).toContain('owner')
- expect(wrapper.get('.settings-account-summary').text()).toContain('当前账号')
- expect(wrapper.get('.settings-account-summary .account-initial-avatar').text()).toBe('O')
- const voiceButton = navigationButton(wrapper, '双流语音')
- expect(voiceButton.text()).toContain('全局')
- expect(voiceButton.element.closest('section')?.textContent).toContain('系统 · 仅管理员')
- await voiceButton.trigger('click')
- expect(wrapper.find('[data-testid="duplex-voice"]').exists()).toBe(true)
- expect(wrapper.get('.settings-content__header').text()).toContain('全局设置 · 仅管理员')
- })
- it('hides model management and the complete system group from non-admin users', () => {
- const wrapper = mountSettings({ isAdmin: false, initialPage: 'system-update' })
- const navigation = wrapper.get('.settings-sidebar nav')
- expect(navigation.findAll('h3').map(heading => heading.text())).toEqual(['当前机器人', '账号'])
- expect(navigation.text()).not.toContain('模型与 Provider')
- expect(navigation.text()).not.toContain('系统 · 仅管理员')
- expect(navigation.text()).not.toContain('系统概览')
- expect(navigation.text()).not.toContain('双流语音')
- expect(navigation.text()).not.toContain('更新与回滚')
- expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(false)
- expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
- })
- it('blocks closing and page changes while the update panel reports a lock', async () => {
- const wrapper = mountSettings({ initialPage: 'system-update' })
- expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(true)
- await wrapper.get('[data-testid="lock-update"]').trigger('click')
- expect(wrapper.get<HTMLButtonElement>('.settings-center__close').element.disabled).toBe(true)
- await navigationButton(wrapper, '登录与安全').trigger('click')
- await wrapper.get('.settings-center-layer').trigger('mousedown')
- expect(wrapper.emitted('close')).toBeUndefined()
- expect(wrapper.get('.settings-content__header').text()).toContain('更新与回滚')
- expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(false)
- await wrapper.get('[data-testid="unlock-update"]').trigger('click')
- await navigationButton(wrapper, '登录与安全').trigger('click')
- expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
- })
- it('asks before discarding an edited page and keeps the current page when declined', async () => {
- const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
- const wrapper = mountSettings()
- wrapper.findComponent({ name: 'AgentIdentityPanel' }).vm.$emit('dirty-change', true)
- await wrapper.vm.$nextTick()
- await navigationButton(wrapper, '登录与安全').trigger('click')
- expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
- expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
- await navigationButton(wrapper, '登录与安全').trigger('click')
- expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
- })
- it('protects Provider and global voice drafts from silent navigation or close', async () => {
- const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
- const modelWrapper = mountSettings({ initialPage: 'agent-models' })
- await modelWrapper.get('[data-testid="dirty-model"]').trigger('click')
- await navigationButton(modelWrapper, '登录与安全').trigger('click')
- expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
- expect(modelWrapper.find('[data-testid="model-services"]').exists()).toBe(true)
- modelWrapper.unmount()
- confirm.mockClear()
- const voiceWrapper = mountSettings({ initialPage: 'system-voice' })
- await voiceWrapper.get('[data-testid="dirty-voice"]').trigger('click')
- await voiceWrapper.get('.settings-center__close').trigger('click')
- expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
- expect(voiceWrapper.emitted('close')).toBeUndefined()
- })
- it('moves mobile focus into details and back to the active category', async () => {
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
- const wrapper = mountSettings()
- const identity = navigationButton(wrapper, '身份与头像')
- await identity.trigger('click')
- await wrapper.vm.$nextTick()
- expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
- await wrapper.get('.mobile-back').trigger('click')
- await wrapper.vm.$nextTick()
- expect(document.activeElement).toBe(identity.element)
- const security = navigationButton(wrapper, '登录与安全')
- await security.trigger('click')
- await wrapper.vm.$nextTick()
- expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
- await wrapper.get('.mobile-back').trigger('click')
- await wrapper.vm.$nextTick()
- expect(document.activeElement).toBe(security.element)
- })
- it('supports listbox keyboard navigation for the Agent selector', async () => {
- const wrapper = mountSettings()
- const trigger = wrapper.get('.settings-agent-selector > button')
- expect(trigger.attributes('aria-haspopup')).toBe('listbox')
- await trigger.trigger('click')
- await wrapper.vm.$nextTick()
- const menu = wrapper.get('.settings-agent-menu')
- const options = menu.findAll<HTMLButtonElement>('[role="option"]')
- expect(document.activeElement).toBe(options[0]!.element)
- await menu.trigger('keydown', { key: 'ArrowDown' })
- expect(document.activeElement).toBe(options[1]!.element)
- })
- })
|