fischer-agentkit/src/agentkit/server/frontend/tests/unit/components/StickyModeHeader.test.ts

476 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 propopen=true 渲染 contentopen=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()
})
})