This commit is contained in:
pa
2026-03-09 10:49:01 +09:00
parent 90a17bb0ba
commit cd832fb96a
20 changed files with 4655 additions and 6 deletions

View File

@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
const mocks = vi.hoisted(() => ({
appearanceStore: {
hideNicknames: false
},
friendStore: {
isRefreshFriendsLoading: false,
allFavoriteFriendIds: new Set(),
confirmDeleteFriend: vi.fn()
},
userStore: {
showUserDialog: vi.fn()
}
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('../../../../stores', () => ({
useAppearanceSettingsStore: () => mocks.appearanceStore,
useFriendStore: () => mocks.friendStore,
useUserStore: () => mocks.userStore
}));
vi.mock('../../../../shared/utils', () => ({
userImage: vi.fn(() => 'https://example.com/avatar.png'),
userStatusClass: vi.fn(() => 'status-online')
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('@/components/ui/avatar', () => ({
Avatar: {
template: '<div data-testid="avatar"><slot /></div>'
},
AvatarImage: {
props: ['src'],
template: '<img data-testid="avatar-image" :src="src" />'
},
AvatarFallback: {
template: '<span data-testid="avatar-fallback"><slot /></span>'
}
}));
vi.mock('@/components/ui/button', () => ({
Button: {
emits: ['click'],
template:
'<button data-testid="delete-button" @click="$emit(\'click\', $event)"><slot /></button>'
}
}));
vi.mock('@/components/ui/spinner', () => ({
Spinner: {
template: '<span data-testid="spinner" />'
}
}));
vi.mock('@/components/Location.vue', () => ({
default: {
props: ['location', 'traveling', 'link'],
template:
'<span data-testid="location">{{ location }}|{{ traveling }}</span>'
}
}));
vi.mock('@/components/Timer.vue', () => ({
default: {
props: ['epoch'],
template: '<span data-testid="timer">{{ epoch }}</span>'
}
}));
vi.mock('lucide-vue-next', () => ({
User: {
template: '<span data-testid="icon-user" />'
},
Trash2: {
template: '<span data-testid="icon-trash" />'
}
}));
import FriendItem from '../FriendItem.vue';
function makeFriend(overrides = {}) {
return {
id: 'usr_1',
name: 'Alice',
state: 'active',
pendingOffline: false,
$nickName: 'Ali',
ref: {
displayName: 'Alice',
$userColour: '#fff',
statusDescription: 'Online',
location: 'wrld_abc:123',
travelingToLocation: '',
$location_at: 123
},
...overrides
};
}
function mountItem(props = {}) {
return mount(FriendItem, {
props: {
friend: makeFriend(),
isGroupByInstance: false,
...props
}
});
}
describe('FriendItem.vue', () => {
beforeEach(() => {
mocks.appearanceStore.hideNicknames = false;
mocks.friendStore.isRefreshFriendsLoading = false;
mocks.friendStore.allFavoriteFriendIds = new Set();
mocks.friendStore.confirmDeleteFriend.mockReset();
mocks.userStore.showUserDialog.mockReset();
});
test('renders nickname when hideNicknames is false', () => {
const wrapper = mountItem();
expect(wrapper.text()).toContain('Alice (Ali)');
});
test('renders favorite star when grouped by instance and friend is favorite', () => {
mocks.appearanceStore.hideNicknames = true;
mocks.friendStore.allFavoriteFriendIds = new Set(['usr_1']);
const wrapper = mountItem({
friend: makeFriend({ $nickName: '' }),
isGroupByInstance: true
});
expect(wrapper.text()).toContain('Alice ⭐');
});
test('clicking row opens user dialog', async () => {
const wrapper = mountItem();
await wrapper.get('div').trigger('click');
expect(mocks.userStore.showUserDialog).toHaveBeenCalledWith('usr_1');
});
test('renders delete action for orphan friend and triggers confirmDeleteFriend', async () => {
const wrapper = mountItem({
friend: makeFriend({
id: 'usr_orphan',
name: 'Ghost',
ref: null
})
});
expect(wrapper.text()).toContain('Ghost');
const button = wrapper.get('[data-testid="delete-button"]');
await button.trigger('click');
expect(mocks.friendStore.confirmDeleteFriend).toHaveBeenCalledWith(
'usr_orphan'
);
expect(mocks.userStore.showUserDialog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,290 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
const mocks = vi.hoisted(() => ({
friendStore: {
allFavoriteOnlineFriends: { value: [] },
allFavoriteFriendIds: { value: new Set() },
onlineFriends: { value: [] },
activeFriends: { value: [] },
offlineFriends: { value: [] },
friendsInSameInstance: { value: [] }
},
appearanceStore: {
isSidebarGroupByInstance: { value: false },
isHideFriendsInSameInstance: { value: false },
isSidebarDivideByFriendGroup: { value: false },
sidebarFavoriteGroups: { value: [] },
sidebarFavoriteGroupOrder: { value: [] },
sidebarSortMethods: { value: [] }
},
advancedStore: {
gameLogDisabled: { value: false }
},
userStore: {
showUserDialog: vi.fn(),
showSendBoopDialog: vi.fn(),
currentUser: {
value: {
id: 'usr_me',
displayName: 'Me',
$userColour: '#fff',
statusDescription: 'Ready',
status: 'active',
statusHistory: [],
isBoopingEnabled: true,
$locationTag: 'wrld_me:123',
$travelingToLocation: ''
}
}
},
launchStore: {
showLaunchDialog: vi.fn()
},
favoriteStore: {
favoriteFriendGroups: { value: [] },
groupedByGroupKeyFavoriteFriends: { value: {} },
localFriendFavorites: { value: {} }
},
locationStore: {
lastLocation: { value: { location: 'wrld_home:123', friendList: new Map() } },
lastLocationDestination: { value: '' }
},
gameStore: {
isGameRunning: { value: true }
},
configRepository: {
getBool: vi.fn(),
setBool: vi.fn()
},
notificationRequest: {
sendRequestInvite: vi.fn().mockResolvedValue({}),
sendInvite: vi.fn().mockResolvedValue({})
},
worldRequest: {
getCachedWorld: vi.fn().mockResolvedValue({ ref: { name: 'World' } })
},
instanceRequest: {
selfInvite: vi.fn().mockResolvedValue({})
},
userRequest: {
saveCurrentUser: vi.fn().mockResolvedValue({})
},
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn()
}
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('@tanstack/vue-virtual', () => ({
useVirtualizer: (optionsRef) => ({
value: {
getVirtualItems: () => {
const options = optionsRef.value;
return Array.from({ length: options.count }, (_, index) => ({
index,
key: options.getItemKey?.(index) ?? index,
start: index * 52
}));
},
getTotalSize: () => optionsRef.value.count * 52,
measure: vi.fn(),
measureElement: vi.fn()
}
})
}));
vi.mock('../../../../stores', () => ({
useFriendStore: () => mocks.friendStore,
useAppearanceSettingsStore: () => mocks.appearanceStore,
useAdvancedSettingsStore: () => mocks.advancedStore,
useFavoriteStore: () => mocks.favoriteStore,
useGameStore: () => mocks.gameStore,
useLaunchStore: () => mocks.launchStore,
useLocationStore: () => mocks.locationStore,
useUserStore: () => mocks.userStore
}));
vi.mock('../../../../shared/utils', () => ({
getFriendsSortFunction: () => (a, b) => a.id.localeCompare(b.id),
isRealInstance: (location) =>
typeof location === 'string' && location.startsWith('wrld_'),
userImage: vi.fn(() => 'https://example.com/avatar.png'),
userStatusClass: vi.fn(() => ''),
parseLocation: vi.fn((location) => ({
worldId: location?.split(':')[0] ?? '',
instanceId: location?.split(':')[1] ?? '',
tag: location ?? ''
}))
}));
vi.mock('../../../../shared/utils/invite.js', () => ({
checkCanInvite: vi.fn(() => true),
checkCanInviteSelf: vi.fn(() => true)
}));
vi.mock('../../../../shared/utils/location.js', () => ({
getFriendsLocations: vi.fn(() => 'wrld_same:1')
}));
vi.mock('../../../../service/config', () => ({
default: mocks.configRepository
}));
vi.mock('../../../../api', () => ({
notificationRequest: mocks.notificationRequest,
worldRequest: mocks.worldRequest,
instanceRequest: mocks.instanceRequest,
userRequest: mocks.userRequest
}));
vi.mock('vue-sonner', () => ({
toast: mocks.toast
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('../../../../components/ui/context-menu', () => ({
ContextMenu: { template: '<div><slot /></div>' },
ContextMenuTrigger: { template: '<div><slot /></div>' },
ContextMenuContent: { template: '<div><slot /></div>' },
ContextMenuItem: {
emits: ['click'],
props: ['disabled'],
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>'
},
ContextMenuSeparator: { template: '<hr />' },
ContextMenuSub: { template: '<div><slot /></div>' },
ContextMenuSubContent: { template: '<div><slot /></div>' },
ContextMenuSubTrigger: { template: '<div><slot /></div>' },
ContextMenuCheckboxItem: {
emits: ['click'],
props: ['modelValue'],
template: '<button @click="$emit(\'click\')"><slot /></button>'
}
}));
vi.mock('../../../../components/BackToTop.vue', () => ({
default: { template: '<div data-testid="back-to-top" />' }
}));
vi.mock('../../../../components/Location.vue', () => ({
default: {
props: ['location', 'traveling', 'link'],
template: '<span data-testid="location">{{ location }}</span>'
}
}));
vi.mock('../FriendItem.vue', () => ({
default: {
props: ['friend'],
template: '<div data-testid="friend-item">{{ friend.id }}</div>'
}
}));
vi.mock('lucide-vue-next', () => ({
ChevronDown: { template: '<span data-testid="chevron" />' }
}));
import FriendsSidebar from '../FriendsSidebar.vue';
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function makeFriend(id, location = 'wrld_online:1') {
return {
id,
state: 'online',
pendingOffline: false,
ref: {
location,
$location: {
tag: location
}
}
};
}
describe('FriendsSidebar.vue', () => {
beforeEach(() => {
mocks.friendStore.allFavoriteOnlineFriends.value = [];
mocks.friendStore.allFavoriteFriendIds.value = new Set();
mocks.friendStore.onlineFriends.value = [];
mocks.friendStore.activeFriends.value = [];
mocks.friendStore.offlineFriends.value = [];
mocks.friendStore.friendsInSameInstance.value = [];
mocks.appearanceStore.isSidebarGroupByInstance.value = false;
mocks.appearanceStore.isHideFriendsInSameInstance.value = false;
mocks.appearanceStore.isSidebarDivideByFriendGroup.value = false;
mocks.appearanceStore.sidebarFavoriteGroups.value = [];
mocks.appearanceStore.sidebarFavoriteGroupOrder.value = [];
mocks.appearanceStore.sidebarSortMethods.value = [];
mocks.configRepository.getBool.mockImplementation(
(_key, defaultValue) => Promise.resolve(defaultValue ?? false)
);
mocks.configRepository.setBool.mockResolvedValue(undefined);
vi.clearAllMocks();
});
test('renders online section and friend rows', async () => {
mocks.friendStore.onlineFriends.value = [makeFriend('usr_online')];
const wrapper = mount(FriendsSidebar);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain('side_panel.online');
expect(wrapper.findAll('[data-testid="friend-item"]').length).toBe(1);
expect(wrapper.text()).toContain('usr_online');
});
test('clicking online header collapses online rows and persists state', async () => {
mocks.friendStore.onlineFriends.value = [makeFriend('usr_online')];
const wrapper = mount(FriendsSidebar);
await flushPromises();
await nextTick();
const onlineHeader = wrapper
.findAll('div.cursor-pointer')
.find((node) => node.text().includes('side_panel.online'));
expect(onlineHeader).toBeTruthy();
await onlineHeader.trigger('click');
await flushPromises();
await nextTick();
expect(wrapper.findAll('[data-testid="friend-item"]').length).toBe(0);
expect(mocks.configRepository.setBool).toHaveBeenCalledWith(
'VRCX_isFriendsGroupOnline',
false
);
});
test('renders same-instance section when grouping is enabled', async () => {
mocks.appearanceStore.isSidebarGroupByInstance.value = true;
mocks.friendStore.friendsInSameInstance.value = [
[makeFriend('usr_a', 'wrld_same:1'), makeFriend('usr_b', 'wrld_same:1')]
];
const wrapper = mount(FriendsSidebar);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain('side_panel.same_instance');
expect(wrapper.findAll('[data-testid="friend-item"]').length).toBe(2);
expect(wrapper.text()).toContain('(2)');
});
});

View File

@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
const mocks = vi.hoisted(() => ({
router: {
push: vi.fn()
},
inviteStore: {
refreshInviteMessageTableData: vi.fn()
},
galleryStore: {
clearInviteImageUpload: vi.fn()
},
notificationStore: null
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('vue-router', () => ({
useRouter: () => mocks.router
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('../../../../stores', () => ({
useInviteStore: () => mocks.inviteStore,
useGalleryStore: () => mocks.galleryStore,
useNotificationStore: () => mocks.notificationStore
}));
vi.mock('@/components/ui/sheet', () => ({
Sheet: {
props: ['open'],
emits: ['update:open'],
template: '<div data-testid="sheet"><slot /></div>'
},
SheetContent: {
template: '<div data-testid="sheet-content"><slot /></div>'
},
SheetHeader: {
template: '<div data-testid="sheet-header"><slot /></div>'
},
SheetTitle: {
template: '<div data-testid="sheet-title"><slot /></div>'
}
}));
vi.mock('@/components/ui/tabs', () => ({
Tabs: {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<div data-testid="tabs" :data-model-value="modelValue"><slot /></div>'
},
TabsList: { template: '<div data-testid="tabs-list"><slot /></div>' },
TabsTrigger: {
props: ['value'],
template:
'<button data-testid="tabs-trigger" :data-value="value"><slot /></button>'
},
TabsContent: {
props: ['value'],
template:
'<div data-testid="tabs-content" :data-value="value"><slot /></div>'
}
}));
vi.mock('../NotificationList.vue', () => ({
default: {
props: ['notifications', 'recentNotifications'],
emits: [
'show-invite-response',
'show-invite-request-response',
'navigate-to-table'
],
template:
'<div data-testid="notification-list">' +
'<button data-testid="emit-show-invite-response" @click="$emit(\'show-invite-response\', { id: \'invite_1\' })">invite-response</button>' +
'<button data-testid="emit-show-invite-request-response" @click="$emit(\'show-invite-request-response\', { id: \'invite_2\' })">invite-request-response</button>' +
'<button data-testid="emit-navigate" @click="$emit(\'navigate-to-table\')">navigate</button>' +
'</div>'
}
}));
vi.mock('../../../Notifications/dialogs/SendInviteResponseDialog.vue', () => ({
default: {
props: ['sendInviteResponseDialogVisible'],
template:
'<div data-testid="dialog-response" :data-visible="String(sendInviteResponseDialogVisible)" />'
}
}));
vi.mock(
'../../../Notifications/dialogs/SendInviteRequestResponseDialog.vue',
() => ({
default: {
props: ['sendInviteRequestResponseDialogVisible'],
template:
'<div data-testid="dialog-request-response" :data-visible="String(sendInviteRequestResponseDialogVisible)" />'
}
})
);
import NotificationCenterSheet from '../NotificationCenterSheet.vue';
describe('NotificationCenterSheet.vue', () => {
beforeEach(() => {
mocks.router.push.mockReset();
mocks.inviteStore.refreshInviteMessageTableData.mockReset();
mocks.galleryStore.clearInviteImageUpload.mockReset();
mocks.notificationStore = {
isNotificationCenterOpen: ref(false),
unseenFriendNotifications: ref([]),
unseenGroupNotifications: ref([]),
unseenOtherNotifications: ref([]),
recentFriendNotifications: ref([]),
recentGroupNotifications: ref([]),
recentOtherNotifications: ref([])
};
});
test('selects group tab when opening and only group unseen notifications exist', async () => {
mocks.notificationStore.unseenGroupNotifications.value = [{ id: 'g1' }];
const wrapper = mount(NotificationCenterSheet);
mocks.notificationStore.isNotificationCenterOpen.value = true;
await wrapper.vm.$nextTick();
expect(wrapper.get('[data-testid="tabs"]').attributes('data-model-value')).toBe(
'group'
);
});
test('navigate-to-table closes center and routes to notification page', async () => {
mocks.notificationStore.isNotificationCenterOpen.value = true;
const wrapper = mount(NotificationCenterSheet);
await wrapper.get('[data-testid="emit-navigate"]').trigger('click');
expect(mocks.notificationStore.isNotificationCenterOpen.value).toBe(
false
);
expect(mocks.router.push).toHaveBeenCalledWith({ name: 'notification' });
});
test('show invite response/request dialogs trigger side effects', async () => {
const wrapper = mount(NotificationCenterSheet);
await wrapper
.get('[data-testid="emit-show-invite-response"]')
.trigger('click');
expect(mocks.inviteStore.refreshInviteMessageTableData).toHaveBeenCalledWith(
'response'
);
expect(mocks.galleryStore.clearInviteImageUpload).toHaveBeenCalled();
expect(
wrapper.get('[data-testid="dialog-response"]').attributes('data-visible')
).toBe('true');
await wrapper
.get('[data-testid="emit-show-invite-request-response"]')
.trigger('click');
expect(mocks.inviteStore.refreshInviteMessageTableData).toHaveBeenCalledWith(
'requestResponse'
);
expect(
wrapper
.get('[data-testid="dialog-request-response"]')
.attributes('data-visible')
).toBe('true');
});
});

View File

@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const mocks = vi.hoisted(() => ({
notificationStore: {
acceptFriendRequestNotification: vi.fn(),
acceptRequestInvite: vi.fn(),
hideNotificationPrompt: vi.fn(),
deleteNotificationLogPrompt: vi.fn(),
sendNotificationResponse: vi.fn(),
queueMarkAsSeen: vi.fn(),
openNotificationLink: vi.fn(),
isNotificationExpired: vi.fn(() => false)
},
userStore: {
cachedUsers: new Map(),
showUserDialog: vi.fn(),
showSendBoopDialog: vi.fn()
},
groupStore: {
showGroupDialog: vi.fn()
},
locationStore: {
lastLocation: { value: { location: 'wrld_home:123' } }
},
gameStore: {
isGameRunning: { value: true }
}
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('../../../../stores', () => ({
useNotificationStore: () => mocks.notificationStore,
useUserStore: () => mocks.userStore,
useGroupStore: () => mocks.groupStore,
useLocationStore: () => mocks.locationStore,
useGameStore: () => mocks.gameStore
}));
vi.mock('../../../../shared/utils', () => ({
checkCanInvite: vi.fn(() => true),
userImage: vi.fn(() => 'https://example.com/avatar.png')
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
te: () => false
})
}));
vi.mock('@/components/ui/item', () => ({
Item: { template: '<div data-testid="item"><slot /></div>' },
ItemMedia: { template: '<div data-testid="item-media"><slot /></div>' },
ItemContent: { template: '<div data-testid="item-content"><slot /></div>' },
ItemTitle: { template: '<div data-testid="item-title"><slot /></div>' },
ItemDescription: {
template: '<div data-testid="item-description"><slot /></div>'
}
}));
vi.mock('@/components/ui/avatar', () => ({
Avatar: { template: '<div data-testid="avatar"><slot /></div>' },
AvatarImage: {
props: ['src'],
template: '<img data-testid="avatar-image" :src="src" />'
},
AvatarFallback: { template: '<span data-testid="avatar-fallback"><slot /></span>' }
}));
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: { template: '<div data-testid="hover-card"><slot /></div>' },
HoverCardTrigger: { template: '<div data-testid="hover-trigger"><slot /></div>' },
HoverCardContent: { template: '<div data-testid="hover-content"><slot /></div>' }
}));
vi.mock('@/components/ui/badge', () => ({
Badge: { template: '<span data-testid="badge"><slot /></span>' }
}));
vi.mock('@/components/ui/separator', () => ({
Separator: { template: '<hr data-testid="separator" />' }
}));
vi.mock('@/components/ui/tooltip', () => ({
TooltipWrapper: { template: '<span data-testid="tooltip"><slot /></span>' }
}));
vi.mock('../../../../components/Location.vue', () => ({
default: {
props: ['location'],
template: '<span data-testid="location">{{ location }}</span>'
}
}));
vi.mock('lucide-vue-next', () => {
function icon(name) {
return { template: `<span data-icon="${name}" />` };
}
return {
Ban: icon('Ban'),
Bell: icon('Bell'),
BellOff: icon('BellOff'),
CalendarDays: icon('CalendarDays'),
Check: icon('Check'),
Link: icon('Link'),
Mail: icon('Mail'),
MessageCircle: icon('MessageCircle'),
Reply: icon('Reply'),
Send: icon('Send'),
Tag: icon('Tag'),
Trash2: icon('Trash2'),
UserPlus: icon('UserPlus'),
Users: icon('Users'),
X: icon('X')
};
});
import NotificationItem from '../NotificationItem.vue';
function makeNotification(overrides = {}) {
return {
id: 'noty_1',
type: 'friendRequest',
senderUserId: 'usr_123',
senderUsername: 'Alice',
created_at: '2026-03-09T00:00:00.000Z',
seen: false,
details: {},
...overrides
};
}
describe('NotificationItem.vue', () => {
beforeEach(() => {
mocks.notificationStore.acceptFriendRequestNotification.mockReset();
mocks.notificationStore.acceptRequestInvite.mockReset();
mocks.notificationStore.hideNotificationPrompt.mockReset();
mocks.notificationStore.deleteNotificationLogPrompt.mockReset();
mocks.notificationStore.sendNotificationResponse.mockReset();
mocks.notificationStore.queueMarkAsSeen.mockReset();
mocks.notificationStore.openNotificationLink.mockReset();
mocks.notificationStore.isNotificationExpired.mockReturnValue(false);
mocks.userStore.showUserDialog.mockReset();
mocks.userStore.showSendBoopDialog.mockReset();
mocks.groupStore.showGroupDialog.mockReset();
mocks.userStore.cachedUsers = new Map();
});
test('renders sender and opens user dialog on sender click', async () => {
const wrapper = mount(NotificationItem, {
props: {
notification: makeNotification()
}
});
expect(wrapper.text()).toContain('Alice');
await wrapper.get('span.truncate.cursor-pointer').trigger('click');
expect(mocks.userStore.showUserDialog).toHaveBeenCalledWith('usr_123');
});
test('clicking accept icon calls acceptFriendRequestNotification', async () => {
const wrapper = mount(NotificationItem, {
props: {
notification: makeNotification()
}
});
await wrapper.get('[data-icon="Check"]').trigger('click');
expect(
mocks.notificationStore.acceptFriendRequestNotification
).toHaveBeenCalledWith(expect.objectContaining({ id: 'noty_1' }));
});
test('link response calls openNotificationLink', async () => {
const wrapper = mount(NotificationItem, {
props: {
notification: makeNotification({
type: 'message',
responses: [
{
type: 'link',
icon: 'reply',
text: 'Open',
data: 'group:grp_123'
}
]
})
}
});
await wrapper.get('[data-icon="Link"]').trigger('click');
expect(mocks.notificationStore.openNotificationLink).toHaveBeenCalledWith(
'group:grp_123'
);
});
test('unmount queues mark-as-seen for unseen notification', () => {
const wrapper = mount(NotificationItem, {
props: {
notification: makeNotification({
version: 2
}),
isUnseen: true
}
});
wrapper.unmount();
expect(mocks.notificationStore.queueMarkAsSeen).toHaveBeenCalledWith(
'noty_1',
2
);
});
});

View File

@@ -0,0 +1,127 @@
import { describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
vi.mock('@tanstack/vue-virtual', () => ({
useVirtualizer: (optionsRef) => ({
value: {
getVirtualItems: () => {
const options = optionsRef.value;
return Array.from({ length: options.count }, (_, index) => ({
index,
key: options.getItemKey?.(index) ?? index,
start: index * 56
}));
},
getTotalSize: () => optionsRef.value.count * 56,
measure: vi.fn(),
measureElement: vi.fn()
}
})
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
})
}));
vi.mock('@/components/ui/button', () => ({
Button: {
emits: ['click'],
template:
'<button data-testid="view-more" @click="$emit(\'click\')"><slot /></button>'
}
}));
vi.mock('@/components/ui/separator', () => ({
Separator: { template: '<hr data-testid="separator" />' }
}));
vi.mock('../NotificationItem.vue', () => ({
default: {
props: ['notification', 'isUnseen'],
emits: ['show-invite-response', 'show-invite-request-response'],
template:
'<div data-testid="notification-item" :data-id="notification.id" :data-unseen="String(isUnseen)">' +
'{{ notification.id }}' +
'<button data-testid="emit-invite-response" @click="$emit(\'show-invite-response\', notification)">invite-response</button>' +
'<button data-testid="emit-invite-request-response" @click="$emit(\'show-invite-request-response\', notification)">invite-request-response</button>' +
'</div>'
}
}));
import NotificationList from '../NotificationList.vue';
function makeNoty(id, createdAt) {
return {
id,
created_at: createdAt,
type: 'friendRequest'
};
}
describe('NotificationList.vue', () => {
test('renders empty state when there are no rows', () => {
const wrapper = mount(NotificationList, {
props: {
notifications: [],
recentNotifications: []
}
});
expect(wrapper.text()).toContain(
'side_panel.notification_center.no_new_notifications'
);
});
test('sorts unseen notifications desc and renders recent section header', () => {
const wrapper = mount(NotificationList, {
props: {
notifications: [
makeNoty('old', '2026-03-08T00:00:00.000Z'),
makeNoty('new', '2026-03-09T00:00:00.000Z')
],
recentNotifications: [makeNoty('recent1', '2026-03-07T00:00:00.000Z')]
}
});
const items = wrapper.findAll('[data-testid="notification-item"]');
expect(items.map((x) => x.attributes('data-id'))).toEqual([
'new',
'old',
'recent1'
]);
expect(wrapper.text()).toContain(
'side_panel.notification_center.past_notifications'
);
});
test('emits navigate-to-table when view-more button is clicked', async () => {
const wrapper = mount(NotificationList, {
props: {
notifications: [makeNoty('n1', '2026-03-09T00:00:00.000Z')],
recentNotifications: []
}
});
await wrapper.get('[data-testid="view-more"]').trigger('click');
expect(wrapper.emitted('navigate-to-table')).toBeTruthy();
});
test('re-emits invite-related events from NotificationItem', async () => {
const wrapper = mount(NotificationList, {
props: {
notifications: [makeNoty('n1', '2026-03-09T00:00:00.000Z')],
recentNotifications: []
}
});
await wrapper.get('[data-testid="emit-invite-response"]').trigger('click');
await wrapper
.get('[data-testid="emit-invite-request-response"]')
.trigger('click');
expect(wrapper.emitted('show-invite-response')).toBeTruthy();
expect(wrapper.emitted('show-invite-request-response')).toBeTruthy();
});
});