Files
hermes-ui/tests/client/skills-usage-view.test.ts
toor 836d0228d2 Phase 007: migrate every remaining view from naive-ui to Arco Design Vue
The project now has exactly one UI library. Every naive-ui component in
packages/client/src is replaced by its Arco equivalent across 24 views and
roughly 80 components, and naive-ui is dropped from package.json along with
its transitive vueuc / @css-render / vdirs / seemly dependencies.

The migration is rendering-layer only. No store logic, no API contract, and
no /chat-run payload was touched.

Three classes of leftover were invisible to `grep naive-ui` and are now
covered by tests/client/no-naive-ui.test.ts, which asserts all three:
  - dead .n-* class names in style blocks
  - dead --n-* custom properties
  - naive-ui in package.json

Notable behavioural traps handled along the way, recorded in
docs/arco-migration-rules.md:
  - a-input-password defaults to masked; passing default-visibility="false"
    would have exposed API keys in clear text
  - a-upload fires a real XHR unless auto-upload is false
  - nested Trigger components fight over the first element child, so the
    group-chat settings dropdown was flattened
  - Arco renders tooltip default-slot text inline, which was silently
    collapsing the chat and group-chat toolbars
2026-10-18 22:57:25 +08:00

258 lines
8.9 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ArcoVue from '@arco-design/web-vue'
const fetchSkillUsageStatsMock = vi.hoisted(() => vi.fn())
const mockProfilesStore = vi.hoisted(() => ({
activeProfileName: 'default',
profiles: [{ name: 'default' }],
fetchProfiles: vi.fn(),
}))
vi.mock('@/api/hermes/skills', () => ({
fetchSkillUsageStats: fetchSkillUsageStatsMock,
}))
vi.mock('@/stores/hermes/profiles', () => ({
useProfilesStore: () => mockProfilesStore,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'skillsUsage.periodLabel') return `${params?.days}d`
if (key === 'skillsUsage.periodSummary') return `Last ${params?.days} days`
return key
},
}),
}))
import SkillsUsageView from '@/views/hermes/SkillsUsageView.vue'
const arcoGlobal = { plugins: [ArcoVue] }
const sevenDayStats = {
period_days: 7,
summary: {
total_skill_loads: 3,
total_skill_edits: 1,
total_skill_actions: 4,
distinct_skills_used: 2,
},
by_day: [
{
date: '2026-05-10',
view_count: 1,
manage_count: 0,
total_count: 1,
skills: [
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
],
},
{
date: '2026-05-11',
view_count: 2,
manage_count: 1,
total_count: 3,
skills: [
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
],
},
],
top_skills: [
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 75, last_used_at: 1_700_000_000 },
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 25, last_used_at: null },
],
}
const thirtyDayStats = {
period_days: 30,
summary: {
total_skill_loads: 5,
total_skill_edits: 2,
total_skill_actions: 7,
distinct_skills_used: 1,
},
by_day: [
{
date: '2026-05-12',
view_count: 5,
manage_count: 2,
total_count: 7,
skills: [
{ skill: 'thirty-day-skill', view_count: 5, manage_count: 2, total_count: 7 },
],
},
],
top_skills: [
{ skill: 'thirty-day-skill', view_count: 5, manage_count: 2, total_count: 7, percentage: 100, last_used_at: null },
],
}
describe('SkillsUsageView', () => {
beforeEach(() => {
fetchSkillUsageStatsMock.mockReset()
fetchSkillUsageStatsMock.mockResolvedValue(sevenDayStats)
mockProfilesStore.activeProfileName = 'default'
mockProfilesStore.profiles = [{ name: 'default' }]
mockProfilesStore.fetchProfiles.mockReset()
})
it('loads rolling 7 day skill usage and renders statistics beside a skill-colored visual trend', async () => {
const wrapper = mount(SkillsUsageView, { global: arcoGlobal })
await flushPromises()
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(7)
expect(wrapper.text()).toContain('skillsUsage.title')
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
expect(wrapper.findAll('.skill-bar-col')).toHaveLength(2)
expect(wrapper.findAll('.skill-bar-segment[data-skill="hermes-agent"]')).toHaveLength(1)
expect(wrapper.findAll('.skill-bar-segment[data-skill="github-pr-workflow"]')).toHaveLength(1)
expect(wrapper.find('[data-testid="skills-usage-legend"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="skills-usage-stats"]').text()).toContain('4')
expect(wrapper.text()).toContain('hermes-agent')
expect(wrapper.text()).toContain('github-pr-workflow')
expect(wrapper.text()).toContain('75.0%')
})
it('reloads the selected period when the period button changes', async () => {
const wrapper = mount(SkillsUsageView, { global: arcoGlobal })
await flushPromises()
fetchSkillUsageStatsMock.mockClear()
const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d')
expect(thirtyDayButton).toBeTruthy()
await thirtyDayButton!.trigger('click')
await flushPromises()
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(30)
expect(thirtyDayButton!.attributes('aria-pressed')).toBe('true')
})
it('flips the chart tooltip away from the hovered side of the bars', async () => {
const wrapper = mount(SkillsUsageView, { global: arcoGlobal })
await flushPromises()
const bars = wrapper.findAll('.skill-bar-col')
expect(bars).toHaveLength(2)
await bars[1].trigger('mouseenter')
expect(wrapper.find('.floating-tooltip.align-left').exists()).toBe(true)
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-11')
await bars[0].trigger('mouseenter')
expect(wrapper.find('.floating-tooltip.align-right').exists()).toBe(true)
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-10')
})
it('keeps stale data visible while refreshing an already loaded period', async () => {
const wrapper = mount(SkillsUsageView, { global: arcoGlobal })
await flushPromises()
let resolveRefresh!: (value: unknown) => void
fetchSkillUsageStatsMock.mockReturnValueOnce(new Promise(resolve => {
resolveRefresh = resolve
}))
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
expect(refreshButton).toBeTruthy()
await refreshButton!.trigger('click')
expect(fetchSkillUsageStatsMock).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
expect(wrapper.find('.usage-panel.is-refreshing').exists()).toBe(true)
resolveRefresh({
period_days: 7,
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
by_day: [
{
date: '2026-05-12',
view_count: 1,
manage_count: 0,
total_count: 1,
skills: [{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1 }],
},
],
top_skills: [
{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
],
})
await flushPromises()
expect(wrapper.text()).toContain('test-driven-development')
})
it('does not let an older refresh overwrite newer stats for the same period', async () => {
// Arco buttons swallow clicks while `loading`, so the overlapping same-period
// requests are staged the way a user can actually produce them: a slow 7 day
// request, a detour through the 30 day period, then back to 7 days before the
// first one settles.
let resolveOlder!: (value: unknown) => void
let resolveNewer!: (value: unknown) => void
fetchSkillUsageStatsMock
.mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve }))
.mockResolvedValueOnce(thirtyDayStats)
.mockReturnValueOnce(new Promise(resolve => { resolveNewer = resolve }))
const wrapper = mount(SkillsUsageView, { global: arcoGlobal })
const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d')
expect(thirtyDayButton).toBeTruthy()
await thirtyDayButton!.trigger('click')
await flushPromises()
const sevenDayButton = wrapper.findAll('button').find(button => button.text() === '7d')
expect(sevenDayButton).toBeTruthy()
await sevenDayButton!.trigger('click')
expect(fetchSkillUsageStatsMock).toHaveBeenNthCalledWith(1, 7)
expect(fetchSkillUsageStatsMock).toHaveBeenNthCalledWith(2, 30)
expect(fetchSkillUsageStatsMock).toHaveBeenNthCalledWith(3, 7)
resolveNewer({
period_days: 7,
summary: { total_skill_loads: 2, total_skill_edits: 0, total_skill_actions: 2, distinct_skills_used: 1 },
by_day: [
{
date: '2026-05-13',
view_count: 2,
manage_count: 0,
total_count: 2,
skills: [{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2 }],
},
],
top_skills: [
{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2, percentage: 100, last_used_at: null },
],
})
await flushPromises()
expect(wrapper.text()).toContain('newer-skill')
resolveOlder({
period_days: 7,
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
by_day: [
{
date: '2026-05-12',
view_count: 1,
manage_count: 0,
total_count: 1,
skills: [{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1 }],
},
],
top_skills: [
{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
],
})
await flushPromises()
expect(wrapper.text()).toContain('newer-skill')
expect(wrapper.text()).not.toContain('older-skill')
})
})