/** * StickyModeHeader (U2) 单元测试。 * * 覆盖场景: * - @team 模式渲染 sticky 条("专家团" badge + 任务目标 + 专家头像) * - @board 模式渲染 sticky 条("私董会" badge + 主题 + 专家头像) * - 非 team/board 模式不渲染 * - 头像 popover 内容(name / description / persona) * - Popover 打开(openChange true)→ 关闭(openChange false)→ 焦点回归触发头像 * - 头像 > 5 时显示 +N 溢出标识,点击打开完整专家列表 popover * * Mount strategy: native Vue createApp + reactive props wrapper (no @vue/test-utils * dependency — happy-dom + vi.mock stubs keep the suite hermetic). * * 说明:viewport<768px 隐藏任务主题文本是 CSS media query 行为(声明式), * happy-dom 不计算 CSS,故不单独测试该场景 — 由 tokens.css + 组件 style 保证。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computed, createApp, defineComponent, h, nextTick, reactive, ref, type Ref } from 'vue' import type { IExpertTeamState, IExpertInfo } from '@/api/types' import type { BoardState } from '@/stores/chatStream' // ── 共享 mock 状态(vi.mock 工厂延迟执行,引用此处已初始化的 ref) ── const teamState: Ref = ref(null) const boardState: Ref = ref(null) vi.mock('@/stores/team', () => ({ useTeamStore: () => reactive({ teamState, activeExperts: computed(() => teamState.value?.experts.filter((e) => e.status === 'active') || [], ), isTeamMode: computed( () => teamState.value !== null && teamState.value.status !== 'dissolved', ), }), })) vi.mock('@/stores/chatStore', () => ({ useChatStore: () => reactive({ boardState }), })) // ── Mock ant-design-vue Popover:受控渲染 + 点击触发 openChange ── // 受控模式(open prop):open=true 渲染 content;open=false 不渲染。 // 点击 trigger 时 emit openChange(!open),让组件的 handleOpenChange 接管。 vi.mock('ant-design-vue', async () => { const { defineComponent, h } = await import('vue') const Popover = defineComponent({ name: 'APopover', props: { trigger: { type: String, default: 'hover' }, placement: { type: String, default: 'top' }, open: { type: Boolean, default: undefined }, overlayClassName: { type: String, default: '' }, }, emits: ['openChange'], setup(props, { slots, emit }) { return () => { const triggerEl = slots.default?.() const showContent = props.open === undefined ? true : props.open return h('div', { class: ['ant-popover-stub', props.overlayClassName] }, [ h( 'div', { class: 'ant-popover-stub__trigger', onClick: () => emit('openChange', !props.open), }, triggerEl, ), showContent && slots.content ? h('div', { class: 'ant-popover-stub__content' }, slots.content()) : null, ]) } }, }) return { Popover } }) const { default: StickyModeHeader } = await import( '@/components/chat/StickyModeHeader.vue' ) // ── Fixtures ──────────────────────────────────────────────────────── function makeExpert(overrides: Partial = {}): IExpertInfo { return { id: 'e1', name: '专家A', persona: '资深架构师', avatar: '🤖', color: '#3b82f6', is_lead: false, bound_skills: ['react'], status: 'active', ...overrides, } } function makeTeamState( overrides: Partial = {}, ): IExpertTeamState { return { team_id: 'team-1', status: 'executing', experts: [makeExpert()], plan_phases: [], lead_expert: '专家A', task_description: '实现用户登录功能', ...overrides, } } function makeBoardState(overrides: Partial = {}): BoardState { return { topic: '如何提升团队协作效率', experts: [ { name: '主持人', avatar: '🎯', color: '#a855f7', is_moderator: true, persona: '引导讨论', }, ], max_rounds: 3, current_round: 1, status: 'discussing', ...overrides, } } // ── Mount helper ──────────────────────────────────────────────────── interface MountHandle { container: HTMLElement root: HTMLElement | null unmount: () => void } function mountStickyHeader(): MountHandle { const container = document.createElement('div') document.body.appendChild(container) const Wrapper = defineComponent({ render() { return h(StickyModeHeader as never) }, }) const app = createApp(Wrapper) app.mount(container) const root = container.querySelector('.sticky-mode-header') return { container, root: root as HTMLElement | null, unmount() { app.unmount() container.remove() }, } } // ── Tests ─────────────────────────────────────────────────────────── describe('StickyModeHeader (U2)', () => { beforeEach(() => { teamState.value = null boardState.value = null }) afterEach(() => { teamState.value = null boardState.value = null }) it('@team 模式渲染 sticky 条:专家团 badge + 任务目标 + 专家头像', async () => { teamState.value = makeTeamState({ task_description: '实现用户登录功能', experts: [ makeExpert({ id: 'e1', name: '专家A', avatar: '🤖', is_lead: true }), makeExpert({ id: 'e2', name: '专家B', avatar: '🐱' }), ], }) const { container, unmount } = mountStickyHeader() await nextTick() const root = container.querySelector('.sticky-mode-header') expect(root).toBeTruthy() expect(root?.classList.contains('sticky-mode-header--team')).toBe(true) const badge = container.querySelector('.sticky-mode-header__badge') expect(badge?.textContent).toBe('专家团') const task = container.querySelector('.sticky-mode-header__task') expect(task?.textContent).toBe('实现用户登录功能') const avatars = container.querySelectorAll('.sticky-mode-header__avatar') expect(avatars.length).toBe(2) expect(avatars[0].textContent).toBe('🤖') unmount() }) it('@team 模式无 task_description 时回退到首阶段 name', async () => { teamState.value = makeTeamState({ task_description: undefined, plan_phases: [ { id: 'p1', name: '需求分析', assigned_expert: '专家A', depends_on: [], status: 'in_progress', }, ], }) const { container, unmount } = mountStickyHeader() await nextTick() const task = container.querySelector('.sticky-mode-header__task') expect(task?.textContent).toBe('需求分析') unmount() }) it('@board 模式渲染 sticky 条:私董会 badge + 主题 + 专家头像', async () => { boardState.value = makeBoardState({ topic: '产品定价策略', experts: [ { name: '主持人', avatar: '🎯', color: '#a855f7', is_moderator: true, persona: '引导讨论', }, { name: '专家1', avatar: '💡', color: '#3b82f6', is_moderator: false, persona: '市场分析', }, ], }) const { container, unmount } = mountStickyHeader() await nextTick() const root = container.querySelector('.sticky-mode-header') expect(root).toBeTruthy() expect(root?.classList.contains('sticky-mode-header--board')).toBe(true) const badge = container.querySelector('.sticky-mode-header__badge') expect(badge?.textContent).toBe('私董会') const task = container.querySelector('.sticky-mode-header__task') expect(task?.textContent).toBe('产品定价策略') const avatars = container.querySelectorAll('.sticky-mode-header__avatar') expect(avatars.length).toBe(2) unmount() }) it('非 team/board 模式不渲染 sticky 条', async () => { const { container, unmount } = mountStickyHeader() await nextTick() expect(container.querySelector('.sticky-mode-header')).toBeNull() unmount() }) it('@board 模式 status=dissolved 时不渲染', async () => { boardState.value = makeBoardState({ status: 'dissolved' }) const { container, unmount } = mountStickyHeader() await nextTick() expect(container.querySelector('.sticky-mode-header')).toBeNull() unmount() }) it('专家头像点击弹出 popover 显示 name/description/persona', async () => { teamState.value = makeTeamState({ experts: [ makeExpert({ id: 'e1', name: '架构师', avatar: '🤖', persona: '专注系统设计', bound_skills: ['react', 'vue'], is_lead: true, }), ], }) const { container, unmount } = mountStickyHeader() await nextTick() const avatar = container.querySelector( '.sticky-mode-header__avatar', ) as HTMLElement expect(avatar).toBeTruthy() // 初始 popover 未打开(open=false → content 不渲染) expect(container.querySelector('.expert-detail')).toBeNull() // 点击头像 → openChange(true) → 组件设置 openKey → content 渲染 avatar.click() await nextTick() const detail = container.querySelector('.expert-detail') expect(detail).toBeTruthy() expect(detail?.querySelector('.expert-detail__name')?.textContent).toBe( '架构师', ) // description 来自 bound_skills("技能: react, vue") expect(detail?.querySelector('.expert-detail__desc')?.textContent).toBe( '技能: react, vue', ) expect(detail?.querySelector('.expert-detail__persona')?.textContent).toBe( '专注系统设计', ) unmount() }) it('Popover 关闭后焦点回归触发头像', async () => { teamState.value = makeTeamState({ experts: [makeExpert({ id: 'e1', name: '专家A' })], }) const { container, unmount } = mountStickyHeader() await nextTick() const avatar = container.querySelector( '.sticky-mode-header__avatar', ) as HTMLButtonElement expect(avatar).toBeTruthy() // 打开 popover avatar.click() await nextTick() expect(container.querySelector('.expert-detail')).toBeTruthy() // 再次点击 → openChange(false) → 组件清除 openKey + 焦点回归 avatar.click() await nextTick() expect(container.querySelector('.expert-detail')).toBeNull() // 焦点回归到触发头像 expect(document.activeElement).toBe(avatar) unmount() }) it('头像超过 5 个时显示 +N 溢出标识', async () => { const experts: IExpertInfo[] = Array.from({ length: 7 }, (_, i) => makeExpert({ id: `e${i + 1}`, name: `专家${i + 1}`, avatar: `${i + 1}`, }), ) teamState.value = makeTeamState({ experts }) const { container, unmount } = mountStickyHeader() await nextTick() // 5 个可见头像 + 1 个 +N 溢出标识 const avatars = container.querySelectorAll('.sticky-mode-header__avatar') expect(avatars.length).toBe(6) // 5 visible + 1 overflow const overflow = container.querySelector( '.sticky-mode-header__avatar--overflow', ) expect(overflow).toBeTruthy() expect(overflow?.textContent?.trim()).toBe('+2') unmount() }) it('+N 点击打开完整专家列表 popover', async () => { const experts: IExpertInfo[] = Array.from({ length: 7 }, (_, i) => makeExpert({ id: `e${i + 1}`, name: `专家${i + 1}`, avatar: `${i + 1}`, }), ) teamState.value = makeTeamState({ experts }) const { container, unmount } = mountStickyHeader() await nextTick() const overflow = container.querySelector( '.sticky-mode-header__avatar--overflow', ) as HTMLButtonElement expect(overflow).toBeTruthy() // 初始无专家列表 expect(container.querySelector('.expert-list')).toBeNull() // 点击 +N → 打开列表 popover overflow.click() await nextTick() const list = container.querySelector('.expert-list') expect(list).toBeTruthy() // 溢出专家 = 7 - 5 = 2 const items = list?.querySelectorAll('.expert-list__item') expect(items?.length).toBe(2) unmount() }) it('@team 模式 lead 头像标记 --lead 样式', async () => { teamState.value = makeTeamState({ experts: [ makeExpert({ id: 'e1', name: 'Lead', is_lead: true }), makeExpert({ id: 'e2', name: 'Member', is_lead: false }), ], }) const { container, unmount } = mountStickyHeader() await nextTick() const avatars = container.querySelectorAll('.sticky-mode-header__avatar') expect(avatars[0].classList.contains('sticky-mode-header__avatar--lead')) .toBe(true) expect(avatars[1].classList.contains('sticky-mode-header__avatar--lead')) .toBe(false) unmount() }) it('@board 模式主持人显示 description', async () => { boardState.value = makeBoardState({ experts: [ { name: '主持人', avatar: '🎯', color: '#a855f7', is_moderator: true, persona: '引导者', }, ], }) const { container, unmount } = mountStickyHeader() await nextTick() const avatar = container.querySelector( '.sticky-mode-header__avatar', ) as HTMLButtonElement avatar.click() await nextTick() const desc = container.querySelector('.expert-detail__desc') expect(desc?.textContent).toBe('主持人') unmount() }) it('sticky 定位使用 --z-sticky token', async () => { teamState.value = makeTeamState() const { container, unmount } = mountStickyHeader() await nextTick() const root = container.querySelector( '.sticky-mode-header', ) as HTMLElement expect(root).toBeTruthy() // position: sticky 由 CSS 设置,happy-dom 不计算样式 // 断言 class 表明模式 token 应用 expect(root.classList.contains('sticky-mode-header--team')).toBe(true) unmount() }) })