476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
/**
|
||
* 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<IExpertTeamState | null> = ref(null)
|
||
const boardState: Ref<BoardState | null> = 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> = {}): IExpertInfo {
|
||
return {
|
||
id: 'e1',
|
||
name: '专家A',
|
||
persona: '资深架构师',
|
||
avatar: '🤖',
|
||
color: '#3b82f6',
|
||
is_lead: false,
|
||
bound_skills: ['react'],
|
||
status: 'active',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeTeamState(
|
||
overrides: Partial<IExpertTeamState> = {},
|
||
): IExpertTeamState {
|
||
return {
|
||
team_id: 'team-1',
|
||
status: 'executing',
|
||
experts: [makeExpert()],
|
||
plan_phases: [],
|
||
lead_expert: '专家A',
|
||
task_description: '实现用户登录功能',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeBoardState(overrides: Partial<BoardState> = {}): 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()
|
||
})
|
||
})
|