feat: make navigation use native links (#973)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import RouteLinkItem from '@/components/common/RouteLinkItem.vue'
|
||||
|
||||
describe('RouteLinkItem', () => {
|
||||
it('renders a real anchor with href from RouterLink custom slot', () => {
|
||||
const wrapper = mount(RouteLinkItem, {
|
||||
props: {
|
||||
to: { name: 'hermes.session', params: { id: 's1' } },
|
||||
active: true,
|
||||
},
|
||||
slots: {
|
||||
default: 'Session S1',
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
RouterLink: defineComponent({
|
||||
props: ['to', 'custom'],
|
||||
template: '<slot href="/session/s1" :navigate="() => {}" :is-active="true" :is-exact-active="true" />',
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const link = wrapper.get('a')
|
||||
expect(link.attributes('href')).toBe('/session/s1')
|
||||
expect(link.classes()).toContain('route-link-item')
|
||||
expect(link.classes()).toContain('active')
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
expect(link.text()).toContain('Session S1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
|
||||
|
||||
vi.mock('@/stores/hermes/app', () => ({
|
||||
useAppStore: () => ({
|
||||
profileModelGroups: [],
|
||||
displayModelName: (model: string) => model,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/profiles', () => ({
|
||||
useProfilesStore: () => ({ profiles: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/session-display', () => ({
|
||||
formatTimestampMs: () => 'now',
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NPopconfirm: defineComponent({
|
||||
name: 'NPopconfirm',
|
||||
emits: ['positive-click'],
|
||||
template: '<span><slot name="trigger" /><slot /></span>',
|
||||
}),
|
||||
NCheckbox: defineComponent({
|
||||
name: 'NCheckbox',
|
||||
props: ['checked'],
|
||||
emits: ['click'],
|
||||
template: '<input type="checkbox" :checked="checked" @click="$emit(\'click\')" />',
|
||||
}),
|
||||
NTooltip: defineComponent({
|
||||
name: 'NTooltip',
|
||||
template: '<span><slot name="trigger" /><slot /></span>',
|
||||
}),
|
||||
}))
|
||||
|
||||
const session = {
|
||||
id: 's1',
|
||||
title: 'Session One',
|
||||
model: 'gpt-test',
|
||||
provider: 'openai',
|
||||
createdAt: Date.now(),
|
||||
profile: 'kira',
|
||||
}
|
||||
|
||||
describe('SessionListItem', () => {
|
||||
it('renders normal mode as a link to the session route', () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const link = wrapper.get('a.session-item')
|
||||
expect(link.attributes('href')).toBe('/session/s1')
|
||||
expect(wrapper.find('button.session-item').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders selectable mode as a button and does not expose row href', () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
selectable: true,
|
||||
selected: false,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('button.session-item').exists()).toBe(true)
|
||||
expect(wrapper.find('a.session-item').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not select the row when clicking nested action controls', async () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('button.session-item-delete').trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not hijack modified clicks on normal links', async () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const link = wrapper.get('a.session-item')
|
||||
link.element.addEventListener('click', event => event.preventDefault())
|
||||
await link.trigger('click', { ctrlKey: true })
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,30 @@ vi.mock('/logo.png', () => ({
|
||||
default: 'logo.png',
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/ProfileSelector.vue', () => ({
|
||||
default: { name: 'ProfileSelector', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/ModelSelector.vue', () => ({
|
||||
default: { name: 'ModelSelector', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/LanguageSwitch.vue', () => ({
|
||||
default: { name: 'LanguageSwitch', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/ThemeSwitch.vue', () => ({
|
||||
default: { name: 'ThemeSwitch', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/RouteLinkItem.vue', () => ({
|
||||
default: {
|
||||
name: 'RouteLinkItem',
|
||||
props: ['to', 'active'],
|
||||
template: '<a class="route-link-item" :class="{ active }" href="#"><slot /></a>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { usePersistentRecord } from '@/composables/usePersistentRecord'
|
||||
|
||||
describe('usePersistentRecord', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('loads saved record and persists updates', () => {
|
||||
localStorage.setItem('hermes.sidebar.collapsedGroups', JSON.stringify({ agent: true }))
|
||||
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
|
||||
|
||||
expect(state.record.agent).toBe(true)
|
||||
state.record.system = true
|
||||
state.persist()
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('hermes.sidebar.collapsedGroups') || '{}')).toEqual({
|
||||
agent: true,
|
||||
system: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores invalid stored values', () => {
|
||||
localStorage.setItem('hermes.sidebar.collapsedGroups', 'not-json')
|
||||
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
|
||||
|
||||
expect({ ...state.record }).toEqual({})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user