mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-30 12:13:48 +02:00
add test
This commit is contained in:
260
src/views/Feed/__tests__/Feed.test.js
Normal file
260
src/views/Feed/__tests__/Feed.test.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
makeRef: (value) => ({ value, __v_isRef: true }),
|
||||
feedTable: null,
|
||||
feedTableData: null,
|
||||
feedTableLookup: vi.fn(),
|
||||
pagination: null,
|
||||
filteredRows: [{ id: 'r1' }],
|
||||
tablePageSizes: [10, 25, 50],
|
||||
tablePageSize: 25,
|
||||
maxTableSize: 100
|
||||
}));
|
||||
|
||||
mocks.feedTable = mocks.makeRef({
|
||||
loading: false,
|
||||
vip: false,
|
||||
filter: [],
|
||||
search: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
});
|
||||
mocks.feedTableData = mocks.makeRef([]);
|
||||
mocks.pagination = mocks.makeRef({
|
||||
pageIndex: 2,
|
||||
pageSize: 10
|
||||
});
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
locale: { value: 'en-US' }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('@internationalized/date', () => ({
|
||||
getLocalTimeZone: () => 'UTC',
|
||||
today: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useFeedStore: () => ({
|
||||
feedTable: mocks.feedTable,
|
||||
feedTableData: mocks.feedTableData,
|
||||
feedTableLookup: mocks.feedTableLookup
|
||||
}),
|
||||
useAppearanceSettingsStore: () => ({
|
||||
tablePageSizes: mocks.tablePageSizes,
|
||||
tablePageSize: mocks.tablePageSize
|
||||
}),
|
||||
useVrcxStore: () => ({
|
||||
maxTableSize: mocks.maxTableSize
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/table/useVrcxVueTable', () => ({
|
||||
useVrcxVueTable: () => ({
|
||||
table: {
|
||||
getFilteredRowModel: () => ({ rows: mocks.filteredRows })
|
||||
},
|
||||
pagination: mocks.pagination
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../composables/useDataTableScrollHeight', () => ({
|
||||
useDataTableScrollHeight: () => ({
|
||||
tableStyle: {}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../columns.jsx', () => ({
|
||||
columns: []
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/data-table', () => ({
|
||||
DataTableLayout: {
|
||||
props: ['totalItems', 'onPageSizeChange'],
|
||||
template:
|
||||
'<div data-testid="data-table-layout">' +
|
||||
'<slot name="toolbar" />' +
|
||||
'<button data-testid="set-page-size" @click="onPageSizeChange?.(50)">set-page-size</button>' +
|
||||
'<span data-testid="total-items">{{ totalItems }}</span>' +
|
||||
'</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/popover', () => ({
|
||||
Popover: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||
PopoverContent: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/toggle-group', () => ({
|
||||
ToggleGroup: {
|
||||
emits: ['update:model-value'],
|
||||
template:
|
||||
'<div data-testid="toggle-group">' +
|
||||
'<button data-testid="select-gps" @click="$emit(\'update:model-value\', [\'GPS\'])">gps</button>' +
|
||||
'<button data-testid="select-all" @click="$emit(\'update:model-value\', [\'All\'])">all</button>' +
|
||||
'<slot />' +
|
||||
'</div>'
|
||||
},
|
||||
ToggleGroupItem: {
|
||||
props: ['value'],
|
||||
template: '<button :data-value="value"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/toggle', () => ({
|
||||
Toggle: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="vip-toggle" @click="$emit(\'update:modelValue\', !modelValue)"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/input-group', () => ({
|
||||
InputGroupField: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue', 'change', 'keyup.enter'],
|
||||
template:
|
||||
'<input data-testid="feed-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @change="$emit(\'change\')" />'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/range-calendar', () => ({
|
||||
RangeCalendar: {
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="set-date-range" @click="$emit(\'update:modelValue\', { start: { year: 2026, month: 3, day: 1 }, end: { year: 2026, month: 3, day: 2 } })">set-date-range</button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/badge', () => ({
|
||||
Badge: { template: '<span><slot /></span>' }
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/tooltip', () => ({
|
||||
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
ListFilter: { template: '<span />' },
|
||||
Star: { template: '<span />' }
|
||||
}));
|
||||
|
||||
import Feed from '../Feed.vue';
|
||||
|
||||
function clickButtonByText(wrapper, text) {
|
||||
const button = wrapper.findAll('button').find((node) => node.text().includes(text));
|
||||
if (!button) {
|
||||
throw new Error(`Cannot find button with text: ${text}`);
|
||||
}
|
||||
return button.trigger('click');
|
||||
}
|
||||
|
||||
describe('Feed.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.feedTable.value = {
|
||||
loading: false,
|
||||
vip: false,
|
||||
filter: [],
|
||||
search: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
};
|
||||
mocks.feedTableData.value = [];
|
||||
mocks.feedTableLookup.mockReset();
|
||||
mocks.pagination.value = { pageIndex: 2, pageSize: 10 };
|
||||
mocks.filteredRows = [{ id: 'r1' }];
|
||||
mocks.maxTableSize = 100;
|
||||
});
|
||||
|
||||
test('applies date filter from calendar and triggers lookup', async () => {
|
||||
const wrapper = mount(Feed);
|
||||
|
||||
await wrapper.get('[data-testid="set-date-range"]').trigger('click');
|
||||
await clickButtonByText(wrapper, 'common.actions.confirm');
|
||||
|
||||
expect(mocks.feedTable.value.dateFrom).toContain('T');
|
||||
expect(mocks.feedTable.value.dateTo).toContain('T');
|
||||
expect(new Date(mocks.feedTable.value.dateFrom).getTime()).toBeLessThan(
|
||||
new Date(mocks.feedTable.value.dateTo).getTime()
|
||||
);
|
||||
expect(mocks.feedTableLookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('clears date filter and triggers lookup', async () => {
|
||||
mocks.feedTable.value.dateFrom = '2026-03-01T00:00:00.000Z';
|
||||
mocks.feedTable.value.dateTo = '2026-03-02T23:59:59.999Z';
|
||||
const wrapper = mount(Feed);
|
||||
|
||||
await clickButtonByText(wrapper, 'common.actions.clear');
|
||||
|
||||
expect(mocks.feedTable.value.dateFrom).toBe('');
|
||||
expect(mocks.feedTable.value.dateTo).toBe('');
|
||||
expect(mocks.feedTableLookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('updates feed type filters via toggle group', async () => {
|
||||
const wrapper = mount(Feed);
|
||||
|
||||
await wrapper.get('[data-testid="select-gps"]').trigger('click');
|
||||
expect(mocks.feedTable.value.filter).toEqual(['GPS']);
|
||||
|
||||
await wrapper.get('[data-testid="select-all"]').trigger('click');
|
||||
expect(mocks.feedTable.value.filter).toEqual([]);
|
||||
expect(mocks.feedTableLookup).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('resets page index when page size changes', async () => {
|
||||
const wrapper = mount(Feed);
|
||||
|
||||
await wrapper.get('[data-testid="set-page-size"]').trigger('click');
|
||||
|
||||
expect(mocks.pagination.value).toEqual({
|
||||
pageIndex: 0,
|
||||
pageSize: 50
|
||||
});
|
||||
});
|
||||
|
||||
test('caps total items when table length is slightly above max table size', () => {
|
||||
mocks.filteredRows = Array.from({ length: 120 }, (_, i) => ({ id: i }));
|
||||
mocks.maxTableSize = 100;
|
||||
const wrapper = mount(Feed);
|
||||
|
||||
expect(wrapper.get('[data-testid="total-items"]').text()).toBe('100');
|
||||
});
|
||||
|
||||
test('builds stable row id fallback for rows without id', () => {
|
||||
const wrapper = mount(Feed);
|
||||
|
||||
const key = wrapper.vm.getFeedRowId({
|
||||
type: 'Online',
|
||||
created_at: '2026-03-01T00:00:00.000Z',
|
||||
userId: 'usr_123',
|
||||
location: 'wrld_abc',
|
||||
message: 'hello'
|
||||
});
|
||||
|
||||
expect(key).toBe('Online:2026-03-01T00:00:00.000Z:usr_123:wrld_abc:hello');
|
||||
});
|
||||
});
|
||||
358
src/views/FriendList/__tests__/FriendList.test.js
Normal file
358
src/views/FriendList/__tests__/FriendList.test.js
Normal file
@@ -0,0 +1,358 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
makeRef: (value) => ({ value, __v_isRef: true }),
|
||||
route: { path: '/friend-list' },
|
||||
routerPush: vi.fn(),
|
||||
friends: null,
|
||||
allFavoriteFriendIds: null,
|
||||
randomUserColours: null,
|
||||
stringComparer: null,
|
||||
friendsListSearch: null,
|
||||
getAllUserStats: vi.fn(),
|
||||
getAllUserMutualCount: vi.fn(),
|
||||
confirmDeleteFriend: vi.fn(),
|
||||
handleFriendDelete: vi.fn(),
|
||||
showUserDialog: vi.fn(),
|
||||
modalConfirm: vi.fn().mockResolvedValue({ ok: true }),
|
||||
modalAlert: vi.fn(),
|
||||
userGetUser: vi.fn().mockResolvedValue({}),
|
||||
friendDeleteFriend: vi.fn().mockResolvedValue({}),
|
||||
toastSuccess: vi.fn(),
|
||||
setOptions: vi.fn(),
|
||||
setPageIndex: vi.fn(),
|
||||
setSorting: vi.fn(),
|
||||
toggleBulkColumnVisibility: vi.fn(),
|
||||
pagination: null,
|
||||
sorting: null
|
||||
}));
|
||||
|
||||
mocks.friends = mocks.makeRef(new Map());
|
||||
mocks.allFavoriteFriendIds = mocks.makeRef(new Set());
|
||||
mocks.randomUserColours = mocks.makeRef(false);
|
||||
mocks.stringComparer = mocks.makeRef(null);
|
||||
mocks.friendsListSearch = mocks.makeRef('');
|
||||
mocks.pagination = mocks.makeRef({
|
||||
pageIndex: 3,
|
||||
pageSize: 10
|
||||
});
|
||||
mocks.sorting = mocks.makeRef([]);
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mocks.route
|
||||
}));
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
success: (...args) => mocks.toastSuccess(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useFriendStore: () => ({
|
||||
friends: mocks.friends,
|
||||
allFavoriteFriendIds: mocks.allFavoriteFriendIds,
|
||||
getAllUserStats: mocks.getAllUserStats,
|
||||
getAllUserMutualCount: mocks.getAllUserMutualCount,
|
||||
confirmDeleteFriend: mocks.confirmDeleteFriend,
|
||||
handleFriendDelete: mocks.handleFriendDelete
|
||||
}),
|
||||
useModalStore: () => ({
|
||||
confirm: (...args) => mocks.modalConfirm(...args),
|
||||
alert: (...args) => mocks.modalAlert(...args)
|
||||
}),
|
||||
useSearchStore: () => ({
|
||||
stringComparer: mocks.stringComparer,
|
||||
friendsListSearch: mocks.friendsListSearch
|
||||
}),
|
||||
useUserStore: () => ({
|
||||
showUserDialog: (...args) => mocks.showUserDialog(...args)
|
||||
}),
|
||||
useAppearanceSettingsStore: () => ({
|
||||
tablePageSizes: [10, 25, 50],
|
||||
tablePageSize: 25,
|
||||
randomUserColours: mocks.randomUserColours
|
||||
}),
|
||||
useVrcxStore: () => ({
|
||||
maxTableSize: 100
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
router: {
|
||||
push: (...args) => mocks.routerPush(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
userRequest: {
|
||||
getUser: (...args) => mocks.userGetUser(...args)
|
||||
},
|
||||
friendRequest: {
|
||||
deleteFriend: (...args) => mocks.friendDeleteFriend(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/confusables', () => ({
|
||||
default: (value) => value,
|
||||
removeWhitespace: (value) => String(value ?? '').replace(/\s+/g, '')
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils', () => ({
|
||||
localeIncludes: (source, query) =>
|
||||
String(source ?? '').toLowerCase().includes(String(query ?? '').toLowerCase())
|
||||
}));
|
||||
|
||||
vi.mock('../../../composables/useDataTableScrollHeight', () => ({
|
||||
useDataTableScrollHeight: () => ({
|
||||
tableStyle: {}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/table/useVrcxVueTable', () => ({
|
||||
useVrcxVueTable: (options) => ({
|
||||
table: {
|
||||
setOptions: (...args) => mocks.setOptions(...args),
|
||||
setPageIndex: (...args) => mocks.setPageIndex(...args),
|
||||
setSorting: (...args) => mocks.setSorting(...args),
|
||||
getFilteredRowModel: () => ({ rows: options.data }),
|
||||
getColumn: (id) =>
|
||||
id === 'bulkSelect'
|
||||
? {
|
||||
toggleVisibility: (...args) => mocks.toggleBulkColumnVisibility(...args)
|
||||
}
|
||||
: null
|
||||
},
|
||||
sorting: mocks.sorting,
|
||||
pagination: mocks.pagination
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../columns.jsx', () => ({
|
||||
createColumns: () => [{ id: 'bulkSelect' }]
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/data-table', () => ({
|
||||
DataTableLayout: {
|
||||
props: ['totalItems', 'onPageSizeChange', 'onRowClick'],
|
||||
template:
|
||||
'<div data-testid="friend-list-layout">' +
|
||||
'<slot name="toolbar" />' +
|
||||
'<button data-testid="set-page-size" @click="onPageSizeChange?.(50)">set-page-size</button>' +
|
||||
'<button data-testid="trigger-row-click" @click="onRowClick?.({ original: { id: \'usr_row\' } })">row-click</button>' +
|
||||
'<span data-testid="total-items">{{ totalItems }}</span>' +
|
||||
'</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/input-group', () => ({
|
||||
InputGroupField: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template:
|
||||
'<input data-testid="friend-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @change="$emit(\'change\')" />'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/progress', () => ({
|
||||
Progress: { template: '<div data-testid="progress" />' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/select', () => ({
|
||||
Select: {
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div data-testid="select">' +
|
||||
'<button data-testid="apply-memo-filter" @click="$emit(\'update:modelValue\', [\'Memo\'])">memo</button>' +
|
||||
'<slot />' +
|
||||
'</div>'
|
||||
},
|
||||
SelectContent: { template: '<div><slot /></div>' },
|
||||
SelectGroup: { template: '<div><slot /></div>' },
|
||||
SelectItem: { template: '<div><slot /></div>' },
|
||||
SelectTrigger: { template: '<div><slot /></div>' },
|
||||
SelectValue: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogFooter: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/switch', () => ({
|
||||
Switch: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="bulk-switch" @click="$emit(\'update:modelValue\', !modelValue)">switch</button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toggle', () => ({
|
||||
Toggle: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="vip-toggle" @click="$emit(\'update:modelValue\', !modelValue)"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
Star: { template: '<span />' }
|
||||
}));
|
||||
|
||||
import FriendList from '../FriendList.vue';
|
||||
|
||||
function makeFriendCtx({ id, displayName, memo = '', dateJoined = null }) {
|
||||
return {
|
||||
id,
|
||||
memo,
|
||||
ref: {
|
||||
id,
|
||||
displayName,
|
||||
statusDescription: '',
|
||||
note: '',
|
||||
bio: '',
|
||||
$trustLevel: 'trusted',
|
||||
date_joined: dateJoined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function clickButtonByText(wrapper, text) {
|
||||
const button = wrapper.findAll('button').find((node) => node.text().trim() === text);
|
||||
if (!button) {
|
||||
throw new Error(`Cannot find button with text: ${text}`);
|
||||
}
|
||||
return button.trigger('click');
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe('FriendList.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.route.path = '/friend-list';
|
||||
mocks.friends.value = new Map();
|
||||
mocks.allFavoriteFriendIds.value = new Set();
|
||||
mocks.friendsListSearch.value = '';
|
||||
mocks.pagination.value = { pageIndex: 3, pageSize: 10 };
|
||||
mocks.sorting.value = [];
|
||||
|
||||
mocks.routerPush.mockReset();
|
||||
mocks.getAllUserStats.mockReset();
|
||||
mocks.getAllUserMutualCount.mockReset();
|
||||
mocks.showUserDialog.mockReset();
|
||||
mocks.modalConfirm.mockClear();
|
||||
mocks.modalAlert.mockReset();
|
||||
mocks.userGetUser.mockReset();
|
||||
mocks.friendDeleteFriend.mockReset();
|
||||
mocks.toastSuccess.mockReset();
|
||||
mocks.setOptions.mockReset();
|
||||
mocks.setPageIndex.mockReset();
|
||||
mocks.setSorting.mockReset();
|
||||
mocks.toggleBulkColumnVisibility.mockReset();
|
||||
});
|
||||
|
||||
test('filters friend list by search text and VIP toggle', async () => {
|
||||
mocks.friends.value = new Map([
|
||||
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })],
|
||||
['usr_2', makeFriendCtx({ id: 'usr_2', displayName: 'Bob' })]
|
||||
]);
|
||||
mocks.allFavoriteFriendIds.value = new Set(['usr_1']);
|
||||
mocks.friendsListSearch.value = 'alice';
|
||||
|
||||
const wrapper = mount(FriendList);
|
||||
await flushAsync();
|
||||
|
||||
wrapper.vm.friendsListSearchFilterVIP = true;
|
||||
wrapper.vm.friendsListSearchChange();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.friendsListDisplayData.map((item) => item.id)).toEqual(['usr_1']);
|
||||
expect(mocks.getAllUserStats).toHaveBeenCalled();
|
||||
expect(mocks.getAllUserMutualCount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opens charts tab from toolbar button', async () => {
|
||||
const wrapper = mount(FriendList);
|
||||
|
||||
await clickButtonByText(wrapper, 'view.friend_list.load_mutual_friends');
|
||||
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'charts' });
|
||||
});
|
||||
|
||||
test('loads missing user profiles and shows completion toast', async () => {
|
||||
mocks.friends.value = new Map([
|
||||
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice', dateJoined: null })],
|
||||
['usr_2', makeFriendCtx({ id: 'usr_2', displayName: 'Bob', dateJoined: '2020-01-01' })]
|
||||
]);
|
||||
const wrapper = mount(FriendList);
|
||||
await flushAsync();
|
||||
|
||||
await clickButtonByText(wrapper, 'view.friend_list.load');
|
||||
await flushAsync();
|
||||
|
||||
expect(mocks.userGetUser).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.userGetUser).toHaveBeenCalledWith({ userId: 'usr_1' });
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('view.friend_list.load_complete');
|
||||
});
|
||||
|
||||
test('select row emits lookup-user for id-less value and opens user dialog for id', () => {
|
||||
const wrapper = mount(FriendList);
|
||||
|
||||
wrapper.vm.selectFriendsListRow({ displayName: 'Unknown' });
|
||||
wrapper.vm.selectFriendsListRow({ id: 'usr_99', displayName: 'Known' });
|
||||
|
||||
expect(wrapper.emitted('lookup-user')?.[0]?.[0]).toEqual({ displayName: 'Unknown' });
|
||||
expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_99');
|
||||
});
|
||||
|
||||
test('toggles bulk mode column visibility and resets page size', async () => {
|
||||
const wrapper = mount(FriendList);
|
||||
mocks.toggleBulkColumnVisibility.mockReset();
|
||||
|
||||
await wrapper.get('[data-testid="bulk-switch"]').trigger('click');
|
||||
await nextTick();
|
||||
expect(mocks.toggleBulkColumnVisibility).toHaveBeenCalledWith(true);
|
||||
|
||||
await wrapper.get('[data-testid="set-page-size"]').trigger('click');
|
||||
expect(mocks.pagination.value).toEqual({
|
||||
pageIndex: 0,
|
||||
pageSize: 50
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/views/FriendLog/__tests__/FriendLog.test.js
Normal file
218
src/views/FriendLog/__tests__/FriendLog.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
makeRef: (value) => ({ value, __v_isRef: true }),
|
||||
hideUnfriends: null,
|
||||
friendLogTable: null,
|
||||
pagination: null,
|
||||
configSetString: vi.fn(),
|
||||
deleteFriendLogHistory: vi.fn(),
|
||||
removeFromArray: vi.fn((arr, row) => {
|
||||
const idx = arr.indexOf(row);
|
||||
if (idx !== -1) {
|
||||
arr.splice(idx, 1);
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
mocks.hideUnfriends = mocks.makeRef(false);
|
||||
mocks.friendLogTable = mocks.makeRef({
|
||||
loading: false,
|
||||
data: [],
|
||||
filters: [{ value: [] }, { value: '' }, { value: false }]
|
||||
});
|
||||
mocks.pagination = mocks.makeRef({
|
||||
pageIndex: 3,
|
||||
pageSize: 10
|
||||
});
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useAppearanceSettingsStore: () => ({
|
||||
tablePageSizes: [10, 25, 50],
|
||||
tablePageSize: 25,
|
||||
hideUnfriends: mocks.hideUnfriends
|
||||
}),
|
||||
useFriendStore: () => ({
|
||||
friendLogTable: mocks.friendLogTable
|
||||
}),
|
||||
useModalStore: () => ({
|
||||
confirm: vi.fn().mockResolvedValue({ ok: true })
|
||||
}),
|
||||
useVrcxStore: () => ({
|
||||
maxTableSize: 100
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../composables/useDataTableScrollHeight', () => ({
|
||||
useDataTableScrollHeight: () => ({
|
||||
tableStyle: {}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/table/useVrcxVueTable', () => ({
|
||||
useVrcxVueTable: (options) => ({
|
||||
table: {
|
||||
getFilteredRowModel: () => ({ rows: options.data })
|
||||
},
|
||||
pagination: mocks.pagination
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../columns.jsx', () => ({
|
||||
createColumns: () => []
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/data-table', () => ({
|
||||
DataTableLayout: {
|
||||
props: ['totalItems', 'onPageSizeChange'],
|
||||
template:
|
||||
'<div data-testid="friend-log-layout">' +
|
||||
'<slot name="toolbar" />' +
|
||||
'<button data-testid="page-size-50" @click="onPageSizeChange?.(50)">page-size</button>' +
|
||||
'<span data-testid="total-items">{{ totalItems }}</span>' +
|
||||
'</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/select', () => ({
|
||||
Select: {
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div><button data-testid="set-type-filter" @click="$emit(\'update:modelValue\', [\'Friend\'])">set-filter</button><slot /></div>'
|
||||
},
|
||||
SelectContent: { template: '<div><slot /></div>' },
|
||||
SelectGroup: { template: '<div><slot /></div>' },
|
||||
SelectItem: { template: '<div><slot /></div>' },
|
||||
SelectTrigger: { template: '<div><slot /></div>' },
|
||||
SelectValue: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/input-group', () => ({
|
||||
InputGroupField: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/config', () => ({
|
||||
default: {
|
||||
setString: (...args) => mocks.configSetString(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/database', () => ({
|
||||
database: {
|
||||
deleteFriendLogHistory: (...args) => mocks.deleteFriendLogHistory(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils', () => ({
|
||||
removeFromArray: (...args) => mocks.removeFromArray(...args)
|
||||
}));
|
||||
|
||||
import FriendLog from '../FriendLog.vue';
|
||||
|
||||
describe('FriendLog.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.hideUnfriends.value = false;
|
||||
mocks.friendLogTable.value = {
|
||||
loading: false,
|
||||
data: [],
|
||||
filters: [{ value: [] }, { value: '' }, { value: false }]
|
||||
};
|
||||
mocks.pagination.value = {
|
||||
pageIndex: 3,
|
||||
pageSize: 10
|
||||
};
|
||||
mocks.configSetString.mockReset();
|
||||
mocks.deleteFriendLogHistory.mockReset();
|
||||
mocks.removeFromArray.mockClear();
|
||||
});
|
||||
|
||||
test('syncs hideUnfriends setting to table filter via watcher', () => {
|
||||
mocks.hideUnfriends.value = true;
|
||||
mount(FriendLog);
|
||||
|
||||
expect(mocks.friendLogTable.value.filters[2].value).toBe(true);
|
||||
});
|
||||
|
||||
test('filters and sorts friend log data', () => {
|
||||
mocks.friendLogTable.value.data = [
|
||||
{
|
||||
rowId: 1,
|
||||
type: 'Friend',
|
||||
displayName: 'Alice',
|
||||
created_at: '2026-03-08T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
rowId: 2,
|
||||
type: 'Friend',
|
||||
displayName: 'Bob',
|
||||
created_at: '2026-03-09T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
rowId: 3,
|
||||
type: 'Unfriend',
|
||||
displayName: 'Alice2',
|
||||
created_at: '2026-03-10T00:00:00.000Z'
|
||||
}
|
||||
];
|
||||
mocks.friendLogTable.value.filters = [{ value: ['Friend'] }, { value: 'ali' }, { value: true }];
|
||||
const wrapper = mount(FriendLog);
|
||||
|
||||
expect(wrapper.vm.friendLogDisplayData).toEqual([
|
||||
{
|
||||
rowId: 1,
|
||||
type: 'Friend',
|
||||
displayName: 'Alice',
|
||||
created_at: '2026-03-08T00:00:00.000Z'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('updates type filter and persists filter selection', async () => {
|
||||
const wrapper = mount(FriendLog);
|
||||
|
||||
await wrapper.get('[data-testid="set-type-filter"]').trigger('click');
|
||||
|
||||
expect(mocks.friendLogTable.value.filters[0].value).toEqual(['Friend']);
|
||||
expect(mocks.configSetString).toHaveBeenCalledWith(
|
||||
'VRCX_friendLogTableFilters',
|
||||
JSON.stringify(['Friend'])
|
||||
);
|
||||
});
|
||||
|
||||
test('deletes friend log row and writes to database', () => {
|
||||
const row = { rowId: 55 };
|
||||
mocks.friendLogTable.value.data = [row];
|
||||
const wrapper = mount(FriendLog);
|
||||
|
||||
wrapper.vm.deleteFriendLog(row);
|
||||
|
||||
expect(mocks.removeFromArray).toHaveBeenCalledWith(mocks.friendLogTable.value.data, row);
|
||||
expect(mocks.deleteFriendLogHistory).toHaveBeenCalledWith(55);
|
||||
});
|
||||
|
||||
test('resets page index when page size changes', async () => {
|
||||
const wrapper = mount(FriendLog);
|
||||
|
||||
await wrapper.get('[data-testid="page-size-50"]').trigger('click');
|
||||
|
||||
expect(mocks.pagination.value).toEqual({
|
||||
pageIndex: 0,
|
||||
pageSize: 50
|
||||
});
|
||||
});
|
||||
});
|
||||
289
src/views/FriendsLocations/__tests__/FriendsLocations.test.js
Normal file
289
src/views/FriendsLocations/__tests__/FriendsLocations.test.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
makeRef: (value) => ({ value, __v_isRef: true }),
|
||||
onlineFriends: null,
|
||||
allFavoriteOnlineFriends: null,
|
||||
allFavoriteFriendIds: null,
|
||||
activeFriends: null,
|
||||
offlineFriends: null,
|
||||
friendsInSameInstance: null,
|
||||
isSidebarDivideByFriendGroup: null,
|
||||
sidebarFavoriteGroups: null,
|
||||
sidebarFavoriteGroupOrder: null,
|
||||
sidebarSortMethods: null,
|
||||
favoriteFriendGroups: null,
|
||||
groupedByGroupKeyFavoriteFriends: null,
|
||||
localFriendFavorites: null,
|
||||
configGetString: vi.fn(),
|
||||
configGetBool: vi.fn(),
|
||||
configSetString: vi.fn(),
|
||||
configSetBool: vi.fn(),
|
||||
virtualMeasure: vi.fn()
|
||||
}));
|
||||
|
||||
mocks.onlineFriends = mocks.makeRef([]);
|
||||
mocks.allFavoriteOnlineFriends = mocks.makeRef([]);
|
||||
mocks.allFavoriteFriendIds = mocks.makeRef(new Set());
|
||||
mocks.activeFriends = mocks.makeRef([]);
|
||||
mocks.offlineFriends = mocks.makeRef([]);
|
||||
mocks.friendsInSameInstance = mocks.makeRef([]);
|
||||
mocks.isSidebarDivideByFriendGroup = mocks.makeRef(false);
|
||||
mocks.sidebarFavoriteGroups = mocks.makeRef([]);
|
||||
mocks.sidebarFavoriteGroupOrder = mocks.makeRef([]);
|
||||
mocks.sidebarSortMethods = mocks.makeRef('status');
|
||||
mocks.favoriteFriendGroups = mocks.makeRef([]);
|
||||
mocks.groupedByGroupKeyFavoriteFriends = mocks.makeRef({});
|
||||
mocks.localFriendFavorites = mocks.makeRef({});
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useFriendStore: () => ({
|
||||
onlineFriends: mocks.onlineFriends,
|
||||
allFavoriteOnlineFriends: mocks.allFavoriteOnlineFriends,
|
||||
allFavoriteFriendIds: mocks.allFavoriteFriendIds,
|
||||
activeFriends: mocks.activeFriends,
|
||||
offlineFriends: mocks.offlineFriends,
|
||||
friendsInSameInstance: mocks.friendsInSameInstance
|
||||
}),
|
||||
useAppearanceSettingsStore: () => ({
|
||||
isSidebarDivideByFriendGroup: mocks.isSidebarDivideByFriendGroup,
|
||||
sidebarFavoriteGroups: mocks.sidebarFavoriteGroups,
|
||||
sidebarFavoriteGroupOrder: mocks.sidebarFavoriteGroupOrder,
|
||||
sidebarSortMethods: mocks.sidebarSortMethods
|
||||
}),
|
||||
useFavoriteStore: () => ({
|
||||
favoriteFriendGroups: mocks.favoriteFriendGroups,
|
||||
groupedByGroupKeyFavoriteFriends: mocks.groupedByGroupKeyFavoriteFriends,
|
||||
localFriendFavorites: mocks.localFriendFavorites
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/config.js', () => ({
|
||||
default: {
|
||||
getString: (...args) => mocks.configGetString(...args),
|
||||
getBool: (...args) => mocks.configGetBool(...args),
|
||||
setString: (...args) => mocks.configSetString(...args),
|
||||
setBool: (...args) => mocks.configSetBool(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils/location.js', () => ({
|
||||
getFriendsLocations: (friends) => friends?.[0]?.ref?.location ?? ''
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils', () => ({
|
||||
getFriendsSortFunction: () => (a, b) =>
|
||||
String(a?.displayName ?? '').localeCompare(String(b?.displayName ?? ''))
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/vue-virtual', () => ({
|
||||
useVirtualizer: (optionsRef) => ({
|
||||
value: {
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: optionsRef.value.count }, (_, index) => ({
|
||||
index,
|
||||
key: index,
|
||||
start: index * 64
|
||||
})),
|
||||
getTotalSize: () => optionsRef.value.count * 64,
|
||||
measure: (...args) => mocks.virtualMeasure(...args),
|
||||
measureElement: vi.fn()
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
ChevronDown: { template: '<span />' },
|
||||
Loader2: { template: '<span />' },
|
||||
Settings: { template: '<span />' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/field', () => ({
|
||||
Field: { template: '<div><slot /></div>' },
|
||||
FieldContent: { template: '<div><slot /></div>' },
|
||||
FieldLabel: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/tabs', () => ({
|
||||
Tabs: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div data-testid="tabs">' +
|
||||
'<button data-testid="segment-online" @click="$emit(\'update:modelValue\', \'online\')">online</button>' +
|
||||
'<button data-testid="segment-offline" @click="$emit(\'update:modelValue\', \'offline\')">offline</button>' +
|
||||
'<slot />' +
|
||||
'</div>'
|
||||
},
|
||||
TabsList: { template: '<div><slot /></div>' },
|
||||
TabsTrigger: {
|
||||
props: ['value'],
|
||||
template: '<button :data-value="value"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/data-table', () => ({
|
||||
DataTableEmpty: {
|
||||
props: ['type'],
|
||||
template: '<div data-testid="empty-state">{{ type }}</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/input-group', () => ({
|
||||
InputGroupSearch: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input data-testid="friend-locations-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/popover', () => ({
|
||||
Popover: { template: '<div><slot /></div>' },
|
||||
PopoverContent: { template: '<div><slot /></div>' },
|
||||
PopoverTrigger: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/slider', () => ({
|
||||
Slider: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="set-scale" @click="$emit(\'update:modelValue\', [0.8])">set-scale</button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/switch', () => ({
|
||||
Switch: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="toggle-same-instance" @click="$emit(\'update:modelValue\', !modelValue)">toggle</button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../components/FriendsLocationsCard.vue', () => ({
|
||||
default: {
|
||||
props: ['friend'],
|
||||
template: '<div data-testid="friend-card">{{ friend.displayName }}</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
import FriendsLocations from '../FriendsLocations.vue';
|
||||
|
||||
function makeFriend(id, displayName, location = 'wrld_1:instance') {
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
signature: '',
|
||||
worldName: '',
|
||||
ref: {
|
||||
location
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function flushSettings() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe('FriendsLocations.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.onlineFriends.value = [];
|
||||
mocks.allFavoriteOnlineFriends.value = [];
|
||||
mocks.allFavoriteFriendIds.value = new Set();
|
||||
mocks.activeFriends.value = [];
|
||||
mocks.offlineFriends.value = [];
|
||||
mocks.friendsInSameInstance.value = [];
|
||||
mocks.isSidebarDivideByFriendGroup.value = false;
|
||||
mocks.sidebarFavoriteGroups.value = [];
|
||||
mocks.sidebarFavoriteGroupOrder.value = [];
|
||||
mocks.sidebarSortMethods.value = 'status';
|
||||
mocks.favoriteFriendGroups.value = [];
|
||||
mocks.groupedByGroupKeyFavoriteFriends.value = {};
|
||||
mocks.localFriendFavorites.value = {};
|
||||
|
||||
mocks.configGetString.mockReset();
|
||||
mocks.configGetBool.mockReset();
|
||||
mocks.configSetString.mockReset();
|
||||
mocks.configSetBool.mockReset();
|
||||
mocks.virtualMeasure.mockReset();
|
||||
|
||||
mocks.configGetString.mockImplementation((_key, defaultValue) => Promise.resolve(defaultValue ?? '1'));
|
||||
mocks.configGetBool.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
test('renders online friend cards after initial settings load', async () => {
|
||||
mocks.onlineFriends.value = [makeFriend('usr_1', 'Alice'), makeFriend('usr_2', 'Bob')];
|
||||
const wrapper = mount(FriendsLocations);
|
||||
await flushSettings();
|
||||
|
||||
const cards = wrapper.findAll('[data-testid="friend-card"]');
|
||||
expect(cards.map((node) => node.text())).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
test('filters cards by search text in DOM', async () => {
|
||||
mocks.onlineFriends.value = [makeFriend('usr_1', 'Alice'), makeFriend('usr_2', 'Bob')];
|
||||
const wrapper = mount(FriendsLocations);
|
||||
await flushSettings();
|
||||
|
||||
await wrapper.get('[data-testid="friend-locations-search"]').setValue('bob');
|
||||
await flushSettings();
|
||||
|
||||
const cards = wrapper.findAll('[data-testid="friend-card"]');
|
||||
expect(cards.map((node) => node.text())).toEqual(['Bob']);
|
||||
});
|
||||
|
||||
test('switches to offline segment and renders offline cards', async () => {
|
||||
mocks.onlineFriends.value = [makeFriend('usr_1', 'Alice')];
|
||||
mocks.offlineFriends.value = [makeFriend('usr_3', 'Carol')];
|
||||
const wrapper = mount(FriendsLocations);
|
||||
await flushSettings();
|
||||
|
||||
await wrapper.get('[data-testid="segment-offline"]').trigger('click');
|
||||
await flushSettings();
|
||||
|
||||
const cards = wrapper.findAll('[data-testid="friend-card"]');
|
||||
expect(cards.map((node) => node.text())).toEqual(['Carol']);
|
||||
});
|
||||
|
||||
test('persists card scale and same-instance preferences', async () => {
|
||||
const wrapper = mount(FriendsLocations);
|
||||
await flushSettings();
|
||||
|
||||
await wrapper.get('[data-testid="set-scale"]').trigger('click');
|
||||
await wrapper.get('[data-testid="toggle-same-instance"]').trigger('click');
|
||||
|
||||
expect(mocks.configSetString).toHaveBeenCalledWith('VRCX_FriendLocationCardScale', '0.8');
|
||||
expect(mocks.configSetBool).toHaveBeenCalledWith('VRCX_FriendLocationShowSameInstance', true);
|
||||
});
|
||||
|
||||
test('renders empty state when no rows match', async () => {
|
||||
const wrapper = mount(FriendsLocations);
|
||||
await flushSettings();
|
||||
|
||||
expect(wrapper.get('[data-testid="empty-state"]').text()).toBe('nomatch');
|
||||
});
|
||||
});
|
||||
185
src/views/Login/Dialog/__tests__/LoginSettingsDialog.test.js
Normal file
185
src/views/Login/Dialog/__tests__/LoginSettingsDialog.test.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loginForm: null,
|
||||
enableCustomEndpoint: null,
|
||||
toggleCustomEndpoint: vi.fn(),
|
||||
restartVRCX: vi.fn(),
|
||||
setProxyServer: vi.fn(),
|
||||
authStore: null,
|
||||
vrcxStore: null
|
||||
}));
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../stores', () => ({
|
||||
useAuthStore: () => mocks.authStore,
|
||||
useVRCXUpdaterStore: () => ({
|
||||
restartVRCX: (...args) => mocks.restartVRCX(...args)
|
||||
}),
|
||||
useVrcxStore: () => mocks.vrcxStore
|
||||
}));
|
||||
|
||||
vi.mock('../../../../service/appConfig', () => ({
|
||||
AppDebug: {
|
||||
endpointDomainVrchat: 'api.vrchat.cloud',
|
||||
websocketDomainVrchat: 'pipeline.vrchat.cloud'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template:
|
||||
'<div data-testid="dialog-root">' +
|
||||
'<button data-testid="open-dialog" @click="$emit(\'update:open\', true)">open</button>' +
|
||||
'<slot />' +
|
||||
'</div>'
|
||||
},
|
||||
DialogTrigger: { template: '<div><slot /></div>' },
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<div><slot /></div>' },
|
||||
DialogFooter: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/field', () => ({
|
||||
Field: { template: '<div><slot /></div>' },
|
||||
FieldContent: { template: '<div><slot /></div>' },
|
||||
FieldGroup: { template: '<div><slot /></div>' },
|
||||
FieldLabel: { template: '<label><slot /></label>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/checkbox', () => ({
|
||||
Checkbox: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="toggle-custom-endpoint" @click="$emit(\'update:modelValue\', !modelValue)">toggle</button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/input-group', () => ({
|
||||
InputGroupField: {
|
||||
props: ['id', 'modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input :id="id" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
Settings: { template: '<span />' }
|
||||
}));
|
||||
|
||||
import LoginSettingsDialog from '../LoginSettingsDialog.vue';
|
||||
|
||||
function mountDialog() {
|
||||
return mount(LoginSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(wrapper, text) {
|
||||
const button = wrapper.findAll('button').find((node) => node.text().trim() === text);
|
||||
if (!button) {
|
||||
throw new Error(`Cannot find button with text: ${text}`);
|
||||
}
|
||||
return button.trigger('click');
|
||||
}
|
||||
|
||||
describe('LoginSettingsDialog.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.loginForm = ref({ endpoint: '', websocket: '' });
|
||||
mocks.enableCustomEndpoint = ref(false);
|
||||
mocks.toggleCustomEndpoint.mockReset();
|
||||
mocks.restartVRCX.mockReset();
|
||||
mocks.setProxyServer.mockReset();
|
||||
|
||||
mocks.vrcxStore = {
|
||||
proxyServer: 'http://proxy.local:7890',
|
||||
setProxyServer: (...args) => mocks.setProxyServer(...args)
|
||||
};
|
||||
mocks.setProxyServer.mockImplementation((value) => {
|
||||
mocks.vrcxStore.proxyServer = value;
|
||||
});
|
||||
mocks.authStore = {
|
||||
loginForm: mocks.loginForm,
|
||||
enableCustomEndpoint: mocks.enableCustomEndpoint,
|
||||
toggleCustomEndpoint: (...args) => mocks.toggleCustomEndpoint(...args)
|
||||
};
|
||||
|
||||
globalThis.VRCXStorage = {
|
||||
Set: vi.fn().mockResolvedValue(undefined),
|
||||
Save: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
});
|
||||
|
||||
test('loads proxy value when dialog opens', async () => {
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await wrapper.get('[data-testid="open-dialog"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.get('#login-settings-proxy').element.value).toBe('http://proxy.local:7890');
|
||||
});
|
||||
|
||||
test('toggles custom endpoint and shows endpoint inputs', async () => {
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await wrapper.get('[data-testid="toggle-custom-endpoint"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.toggleCustomEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('saves proxy and closes', async () => {
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await wrapper.get('[data-testid="open-dialog"]').trigger('click');
|
||||
await wrapper.get('#login-settings-proxy').setValue('http://127.0.0.1:8080');
|
||||
await clickButtonByText(wrapper, 'prompt.proxy_settings.close');
|
||||
|
||||
expect(mocks.setProxyServer).toHaveBeenCalledWith('http://127.0.0.1:8080');
|
||||
expect(globalThis.VRCXStorage.Set).toHaveBeenCalledWith(
|
||||
'VRCX_ProxyServer',
|
||||
'http://127.0.0.1:8080'
|
||||
);
|
||||
expect(globalThis.VRCXStorage.Save).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.restartVRCX).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves proxy and restarts app', async () => {
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await wrapper.get('[data-testid="open-dialog"]').trigger('click');
|
||||
await wrapper.get('#login-settings-proxy').setValue('http://192.168.0.2:3128');
|
||||
await clickButtonByText(wrapper, 'prompt.proxy_settings.restart');
|
||||
|
||||
expect(mocks.setProxyServer).toHaveBeenCalledWith('http://192.168.0.2:3128');
|
||||
expect(globalThis.VRCXStorage.Save).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.restartVRCX).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
263
src/views/Moderation/__tests__/Moderation.test.js
Normal file
263
src/views/Moderation/__tests__/Moderation.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
makeRef: (value) => ({ value, __v_isRef: true }),
|
||||
playerModerationTable: null,
|
||||
refreshPlayerModerations: vi.fn(),
|
||||
handlePlayerModerationDelete: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
configGetString: vi.fn(),
|
||||
configSetString: vi.fn(),
|
||||
deletePlayerModeration: vi.fn(),
|
||||
pagination: null,
|
||||
columnHandlers: null
|
||||
}));
|
||||
|
||||
mocks.playerModerationTable = mocks.makeRef({
|
||||
loading: false,
|
||||
data: [],
|
||||
filters: [{ value: [] }, { value: '' }]
|
||||
});
|
||||
mocks.pagination = mocks.makeRef({
|
||||
pageIndex: 2,
|
||||
pageSize: 10
|
||||
});
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useModerationStore: () => ({
|
||||
playerModerationTable: mocks.playerModerationTable,
|
||||
refreshPlayerModerations: (...args) => mocks.refreshPlayerModerations(...args),
|
||||
handlePlayerModerationDelete: (...args) => mocks.handlePlayerModerationDelete(...args)
|
||||
}),
|
||||
useAppearanceSettingsStore: () => ({
|
||||
tablePageSizes: [10, 25, 50],
|
||||
tablePageSize: 25
|
||||
}),
|
||||
useModalStore: () => ({
|
||||
confirm: (...args) => mocks.modalConfirm(...args)
|
||||
}),
|
||||
useVrcxStore: () => ({
|
||||
maxTableSize: 100
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/config.js', () => ({
|
||||
default: {
|
||||
getString: (...args) => mocks.configGetString(...args),
|
||||
setString: (...args) => mocks.configSetString(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
playerModerationRequest: {
|
||||
deletePlayerModeration: (...args) => mocks.deletePlayerModeration(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/constants', () => ({
|
||||
moderationTypes: ['block', 'mute', 'unmute']
|
||||
}));
|
||||
|
||||
vi.mock('../columns.jsx', () => ({
|
||||
createColumns: (handlers) => {
|
||||
mocks.columnHandlers = handlers;
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/table/useVrcxVueTable', () => ({
|
||||
useVrcxVueTable: (options) => ({
|
||||
table: {
|
||||
getFilteredRowModel: () => ({ rows: options.data })
|
||||
},
|
||||
pagination: mocks.pagination
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../composables/useDataTableScrollHeight', () => ({
|
||||
useDataTableScrollHeight: () => ({
|
||||
tableStyle: {}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/select', () => ({
|
||||
Select: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
SelectContent: { template: '<div><slot /></div>' },
|
||||
SelectGroup: { template: '<div><slot /></div>' },
|
||||
SelectItem: { template: '<div><slot /></div>' },
|
||||
SelectTrigger: { template: '<div><slot /></div>' },
|
||||
SelectValue: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template: '<button data-testid="moderation-button" @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/input-group', () => ({
|
||||
InputGroupField: {
|
||||
template: '<input />'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/spinner', () => ({
|
||||
Spinner: { template: '<span />' }
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
RefreshCw: { template: '<span />' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/data-table', () => ({
|
||||
DataTableLayout: {
|
||||
props: ['totalItems'],
|
||||
template:
|
||||
'<div data-testid="moderation-layout">' +
|
||||
'<span data-testid="total-items">{{ totalItems }}</span>' +
|
||||
'</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
import Moderation from '../Moderation.vue';
|
||||
|
||||
function mountModeration() {
|
||||
return mount(Moderation, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe('Moderation.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.playerModerationTable.value = {
|
||||
loading: false,
|
||||
data: [],
|
||||
filters: [{ value: [] }, { value: '' }]
|
||||
};
|
||||
mocks.pagination.value = { pageIndex: 2, pageSize: 10 };
|
||||
mocks.columnHandlers = null;
|
||||
|
||||
mocks.refreshPlayerModerations.mockReset();
|
||||
mocks.handlePlayerModerationDelete.mockReset();
|
||||
mocks.modalConfirm.mockReset();
|
||||
mocks.configGetString.mockReset();
|
||||
mocks.configSetString.mockReset();
|
||||
mocks.deletePlayerModeration.mockReset();
|
||||
|
||||
mocks.configGetString.mockResolvedValue('["block"]');
|
||||
mocks.modalConfirm.mockResolvedValue({ ok: true });
|
||||
mocks.deletePlayerModeration.mockResolvedValue({ ok: true, id: 'pm_1' });
|
||||
});
|
||||
|
||||
test('loads persisted moderation filter on init', async () => {
|
||||
mountModeration();
|
||||
await flushAsync();
|
||||
|
||||
expect(mocks.configGetString).toHaveBeenCalledWith('VRCX_playerModerationTableFilters', '[]');
|
||||
expect(mocks.playerModerationTable.value.filters[0].value).toEqual(['block']);
|
||||
});
|
||||
|
||||
test('updates moderation filter and persists value', async () => {
|
||||
mocks.configGetString.mockResolvedValueOnce('[]');
|
||||
const wrapper = mountModeration();
|
||||
await flushAsync();
|
||||
wrapper.vm.handleModerationFilterChange(['mute']);
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.playerModerationTable.value.filters[0].value).toEqual(['mute']);
|
||||
expect(mocks.configSetString).toHaveBeenCalledWith(
|
||||
'VRCX_playerModerationTableFilters',
|
||||
JSON.stringify(['mute'])
|
||||
);
|
||||
});
|
||||
|
||||
test('filters table rows by type and search text', async () => {
|
||||
mocks.playerModerationTable.value.data = [
|
||||
{ type: 'block', sourceDisplayName: 'Alpha', targetDisplayName: 'Beta' },
|
||||
{ type: 'mute', sourceDisplayName: 'Gamma', targetDisplayName: 'Delta' },
|
||||
{ type: 'mute', sourceDisplayName: 'X', targetDisplayName: 'Alice' }
|
||||
];
|
||||
mocks.playerModerationTable.value.filters = [{ value: ['mute'] }, { value: 'ali' }];
|
||||
|
||||
const wrapper = mountModeration();
|
||||
await flushAsync();
|
||||
|
||||
expect(wrapper.get('[data-testid="total-items"]').text()).toBe('1');
|
||||
});
|
||||
|
||||
test('refresh button triggers moderation refresh', async () => {
|
||||
const wrapper = mountModeration();
|
||||
|
||||
await wrapper.get('[data-testid="moderation-button"]').trigger('click');
|
||||
|
||||
expect(mocks.refreshPlayerModerations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('delete handler calls API then store handler', async () => {
|
||||
mountModeration();
|
||||
const row = { targetUserId: 'usr_1', type: 'mute' };
|
||||
|
||||
await mocks.columnHandlers.onDelete(row);
|
||||
|
||||
expect(mocks.deletePlayerModeration).toHaveBeenCalledWith({
|
||||
moderated: 'usr_1',
|
||||
type: 'mute'
|
||||
});
|
||||
expect(mocks.handlePlayerModerationDelete).toHaveBeenCalledWith({ ok: true, id: 'pm_1' });
|
||||
});
|
||||
|
||||
test('delete prompt confirms before deletion', async () => {
|
||||
mountModeration();
|
||||
const row = { targetUserId: 'usr_2', type: 'block' };
|
||||
|
||||
mocks.modalConfirm.mockResolvedValueOnce({ ok: true });
|
||||
await mocks.columnHandlers.onDeletePrompt(row);
|
||||
await flushAsync();
|
||||
|
||||
expect(mocks.modalConfirm).toHaveBeenCalled();
|
||||
expect(mocks.deletePlayerModeration).toHaveBeenCalledWith({
|
||||
moderated: 'usr_2',
|
||||
type: 'block'
|
||||
});
|
||||
});
|
||||
|
||||
test('resets page index when page size changes', async () => {
|
||||
const wrapper = mountModeration();
|
||||
wrapper.vm.handlePageSizeChange(50);
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.pagination.value).toEqual({
|
||||
pageIndex: 0,
|
||||
pageSize: 50
|
||||
});
|
||||
});
|
||||
});
|
||||
144
src/views/Moderation/__tests__/columns.test.js
Normal file
144
src/views/Moderation/__tests__/columns.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
shiftHeld: { value: false, __v_isRef: true },
|
||||
currentUser: { value: { id: 'usr_me' }, __v_isRef: true },
|
||||
showUserDialog: vi.fn(),
|
||||
t: vi.fn((key) => key),
|
||||
te: vi.fn((key) => key === 'view.moderation.filters.block')
|
||||
}));
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('../../../plugin', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: (...args) => mocks.t(...args),
|
||||
te: (...args) => mocks.te(...args)
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useUiStore: () => ({
|
||||
shiftHeld: mocks.shiftHeld
|
||||
}),
|
||||
useUserStore: () => ({
|
||||
currentUser: mocks.currentUser,
|
||||
showUserDialog: (...args) => mocks.showUserDialog(...args)
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils', () => ({
|
||||
formatDateFilter: (value, format) => `${format}:${value}`
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/badge', () => ({ Badge: 'Badge' }));
|
||||
vi.mock('../../../components/ui/button', () => ({ Button: 'Button' }));
|
||||
vi.mock('../../../components/ui/tooltip', () => ({
|
||||
Tooltip: 'Tooltip',
|
||||
TooltipContent: 'TooltipContent',
|
||||
TooltipTrigger: 'TooltipTrigger'
|
||||
}));
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
ArrowUpDown: 'ArrowUpDown',
|
||||
Trash2: 'Trash2',
|
||||
X: 'X'
|
||||
}));
|
||||
|
||||
import { createColumns } from '../columns.jsx';
|
||||
|
||||
function createElement(type, props, ...children) {
|
||||
return {
|
||||
type,
|
||||
props: props ?? {},
|
||||
children: children.flat()
|
||||
};
|
||||
}
|
||||
|
||||
function findNode(node, predicate) {
|
||||
if (!node) return null;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
const result = findNode(item, predicate);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (predicate(node)) return node;
|
||||
if (!node.children) return null;
|
||||
return findNode(node.children, predicate);
|
||||
}
|
||||
|
||||
describe('views/Moderation/columns.jsx', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.React = { createElement };
|
||||
mocks.shiftHeld.value = false;
|
||||
mocks.currentUser.value = { id: 'usr_me' };
|
||||
mocks.showUserDialog.mockReset();
|
||||
});
|
||||
|
||||
test('source and target cells open corresponding user dialog', () => {
|
||||
const cols = createColumns({ onDelete: vi.fn(), onDeletePrompt: vi.fn() });
|
||||
const sourceCol = cols.find((c) => c.accessorKey === 'sourceDisplayName');
|
||||
const targetCol = cols.find((c) => c.accessorKey === 'targetDisplayName');
|
||||
const row = {
|
||||
original: {
|
||||
sourceUserId: 'usr_source',
|
||||
sourceDisplayName: 'Source',
|
||||
targetUserId: 'usr_target',
|
||||
targetDisplayName: 'Target'
|
||||
},
|
||||
getValue: vi.fn()
|
||||
};
|
||||
|
||||
const sourceCell = sourceCol.cell({ row });
|
||||
const targetCell = targetCol.cell({ row });
|
||||
findNode(sourceCell, (n) => n.type === 'span' && typeof n.props?.onClick === 'function').props.onClick();
|
||||
findNode(targetCell, (n) => n.type === 'span' && typeof n.props?.onClick === 'function').props.onClick();
|
||||
|
||||
expect(mocks.showUserDialog).toHaveBeenNthCalledWith(1, 'usr_source');
|
||||
expect(mocks.showUserDialog).toHaveBeenNthCalledWith(2, 'usr_target');
|
||||
});
|
||||
|
||||
test('action cell hidden when source user is not current user', () => {
|
||||
const cols = createColumns({ onDelete: vi.fn(), onDeletePrompt: vi.fn() });
|
||||
const actionCol = cols.find((c) => c.id === 'action');
|
||||
const vnode = actionCol.cell({
|
||||
row: {
|
||||
original: {
|
||||
sourceUserId: 'usr_other'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(vnode).toBeNull();
|
||||
});
|
||||
|
||||
test('action cell routes to onDeletePrompt or onDelete based on shift', () => {
|
||||
const onDelete = vi.fn();
|
||||
const onDeletePrompt = vi.fn();
|
||||
const cols = createColumns({ onDelete, onDeletePrompt });
|
||||
const actionCol = cols.find((c) => c.id === 'action');
|
||||
const row = {
|
||||
original: {
|
||||
sourceUserId: 'usr_me',
|
||||
targetUserId: 'usr_target',
|
||||
type: 'block'
|
||||
}
|
||||
};
|
||||
|
||||
const promptCell = actionCol.cell({ row });
|
||||
findNode(promptCell, (n) => n.type === 'button').props.onClick();
|
||||
expect(onDeletePrompt).toHaveBeenCalledWith(row.original);
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
|
||||
mocks.shiftHeld.value = true;
|
||||
const directCell = actionCol.cell({ row });
|
||||
findNode(directCell, (n) => n.type === 'button').props.onClick();
|
||||
expect(onDelete).toHaveBeenCalledWith(row.original);
|
||||
});
|
||||
});
|
||||
269
src/views/PlayerList/__tests__/PlayerList.test.js
Normal file
269
src/views/PlayerList/__tests__/PlayerList.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
randomUserColours: null,
|
||||
photonLoggingEnabled: null,
|
||||
chatboxUserBlacklist: null,
|
||||
lastLocation: null,
|
||||
currentInstanceLocation: null,
|
||||
currentInstanceWorld: null,
|
||||
currentInstanceUsersData: null,
|
||||
currentUser: null,
|
||||
saveChatboxUserBlacklist: vi.fn(),
|
||||
showUserDialog: vi.fn(),
|
||||
lookupUser: vi.fn(),
|
||||
showWorldDialog: vi.fn(),
|
||||
showFullscreenImageDialog: vi.fn(),
|
||||
getCurrentInstanceUserList: vi.fn(),
|
||||
tableSetOptions: vi.fn(),
|
||||
photonColumnToggleVisibility: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => store
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores', () => ({
|
||||
useAppearanceSettingsStore: () => ({
|
||||
randomUserColours: mocks.randomUserColours
|
||||
}),
|
||||
usePhotonStore: () => ({
|
||||
photonLoggingEnabled: mocks.photonLoggingEnabled,
|
||||
chatboxUserBlacklist: mocks.chatboxUserBlacklist,
|
||||
saveChatboxUserBlacklist: (...args) => mocks.saveChatboxUserBlacklist(...args)
|
||||
}),
|
||||
useUserStore: () => ({
|
||||
currentUser: mocks.currentUser,
|
||||
showUserDialog: (...args) => mocks.showUserDialog(...args),
|
||||
lookupUser: (...args) => mocks.lookupUser(...args)
|
||||
}),
|
||||
useWorldStore: () => ({
|
||||
showWorldDialog: (...args) => mocks.showWorldDialog(...args)
|
||||
}),
|
||||
useLocationStore: () => ({
|
||||
lastLocation: mocks.lastLocation
|
||||
}),
|
||||
useInstanceStore: () => ({
|
||||
currentInstanceLocation: mocks.currentInstanceLocation,
|
||||
currentInstanceWorld: mocks.currentInstanceWorld,
|
||||
currentInstanceUsersData: mocks.currentInstanceUsersData,
|
||||
getCurrentInstanceUserList: (...args) => mocks.getCurrentInstanceUserList(...args)
|
||||
}),
|
||||
useGalleryStore: () => ({
|
||||
showFullscreenImageDialog: (...args) => mocks.showFullscreenImageDialog(...args)
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/table/useVrcxVueTable', () => ({
|
||||
useVrcxVueTable: () => ({
|
||||
table: {
|
||||
setOptions: (...args) => mocks.tableSetOptions(...args),
|
||||
getColumn: (id) =>
|
||||
id === 'photonId'
|
||||
? {
|
||||
toggleVisibility: (...args) => mocks.photonColumnToggleVisibility(...args)
|
||||
}
|
||||
: null,
|
||||
getRowModel: () => ({ rows: mocks.currentInstanceUsersData.value })
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../composables/useDataTableScrollHeight', () => ({
|
||||
useDataTableScrollHeight: () => ({
|
||||
tableStyle: {}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../columns.jsx', () => ({
|
||||
createColumns: () => [{ id: 'photonId' }]
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils', () => ({
|
||||
commaNumber: (value) => String(value ?? ''),
|
||||
formatDateFilter: (value) => String(value ?? '')
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/data-table', () => ({
|
||||
DataTableLayout: {
|
||||
props: ['onRowClick'],
|
||||
template:
|
||||
'<div>' +
|
||||
'<button data-testid="row-click-with-id" @click="onRowClick?.({ original: { ref: { id: \'usr_1\', displayName: \'Alice\' } } })">row-id</button>' +
|
||||
'<button data-testid="row-click-without-id" @click="onRowClick?.({ original: { ref: { displayName: \'Bob\' } } })">row-no-id</button>' +
|
||||
'</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/badge', () => ({
|
||||
Badge: { template: '<span><slot /></span>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/LocationWorld.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/Timer.vue', () => ({
|
||||
default: { template: '<span />' }
|
||||
}));
|
||||
|
||||
vi.mock('../dialogs/ChatboxBlacklistDialog.vue', () => ({
|
||||
default: {
|
||||
props: ['chatboxBlacklistDialog'],
|
||||
emits: ['delete-chatbox-user-blacklist'],
|
||||
template:
|
||||
'<div data-testid="chatbox-dialog" :data-visible="String(chatboxBlacklistDialog.visible)">' +
|
||||
'<button data-testid="emit-delete-chatbox" @click="$emit(\'delete-chatbox-user-blacklist\', \'usr_blocked\')">delete</button>' +
|
||||
'</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
Apple: { template: '<span />' },
|
||||
Home: { template: '<span />' },
|
||||
Monitor: { template: '<span />' },
|
||||
Smartphone: { template: '<span />' }
|
||||
}));
|
||||
|
||||
import PlayerList from '../PlayerList.vue';
|
||||
|
||||
describe('PlayerList.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.randomUserColours = ref(false);
|
||||
mocks.photonLoggingEnabled = ref(false);
|
||||
mocks.chatboxUserBlacklist = ref(new Map([['usr_blocked', 'Blocked User']]));
|
||||
mocks.lastLocation = ref({
|
||||
playerList: new Set(),
|
||||
friendList: new Set(),
|
||||
date: null
|
||||
});
|
||||
mocks.currentInstanceLocation = ref({});
|
||||
mocks.currentInstanceWorld = ref({
|
||||
ref: {
|
||||
id: '',
|
||||
thumbnailImageUrl: '',
|
||||
imageUrl: '',
|
||||
name: '',
|
||||
authorId: '',
|
||||
authorName: '',
|
||||
releaseStatus: 'public',
|
||||
description: ''
|
||||
},
|
||||
fileAnalysis: {},
|
||||
isPC: false,
|
||||
isQuest: false,
|
||||
isIos: false
|
||||
});
|
||||
mocks.currentInstanceUsersData = ref([]);
|
||||
mocks.currentUser = ref({
|
||||
id: 'usr_me',
|
||||
$homeLocation: null
|
||||
});
|
||||
|
||||
mocks.saveChatboxUserBlacklist.mockReset();
|
||||
mocks.showUserDialog.mockReset();
|
||||
mocks.lookupUser.mockReset();
|
||||
mocks.showWorldDialog.mockReset();
|
||||
mocks.showFullscreenImageDialog.mockReset();
|
||||
mocks.getCurrentInstanceUserList.mockReset();
|
||||
mocks.tableSetOptions.mockReset();
|
||||
mocks.photonColumnToggleVisibility.mockReset();
|
||||
|
||||
mocks.saveChatboxUserBlacklist.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('loads current instance user list on mount and wires table options', () => {
|
||||
mount(PlayerList, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' },
|
||||
LocationWorld: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(mocks.getCurrentInstanceUserList).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.tableSetOptions).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.photonColumnToggleVisibility).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('row click opens user dialog when id exists, otherwise lookups user', async () => {
|
||||
const wrapper = mount(PlayerList, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' },
|
||||
LocationWorld: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.get('[data-testid="row-click-with-id"]').trigger('click');
|
||||
await wrapper.get('[data-testid="row-click-without-id"]').trigger('click');
|
||||
|
||||
expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1');
|
||||
expect(mocks.lookupUser).toHaveBeenCalledWith({ displayName: 'Bob' });
|
||||
});
|
||||
|
||||
test('toggles photonId column visibility when photon logging changes', async () => {
|
||||
mount(PlayerList, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' },
|
||||
LocationWorld: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
mocks.photonColumnToggleVisibility.mockClear();
|
||||
|
||||
mocks.photonLoggingEnabled.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.photonColumnToggleVisibility).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('opens chatbox blacklist dialog from photon event table', async () => {
|
||||
const wrapper = mount(PlayerList, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' },
|
||||
LocationWorld: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-testid="chatbox-dialog"]').attributes('data-visible')).toBe('false');
|
||||
wrapper.vm.showChatboxBlacklistDialog();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.get('[data-testid="chatbox-dialog"]').attributes('data-visible')).toBe('true');
|
||||
});
|
||||
|
||||
test('deletes chatbox blacklist user and refreshes list', async () => {
|
||||
const wrapper = mount(PlayerList, {
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: { template: '<div><slot /></div>' },
|
||||
LocationWorld: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.get('[data-testid="emit-delete-chatbox"]').trigger('click');
|
||||
|
||||
expect(mocks.chatboxUserBlacklist.value.has('usr_blocked')).toBe(false);
|
||||
expect(mocks.saveChatboxUserBlacklist).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getCurrentInstanceUserList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
187
src/views/PlayerList/__tests__/columns.test.js
Normal file
187
src/views/PlayerList/__tests__/columns.test.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openExternalLink: vi.fn(),
|
||||
userImage: vi.fn(),
|
||||
getFaviconUrl: vi.fn((link) => `https://icon/${encodeURIComponent(link)}`),
|
||||
sortAlphabetically: vi.fn(() => 123),
|
||||
onBlockChatbox: vi.fn(),
|
||||
onUnblockChatbox: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../../plugin', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: (key) => key
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../shared/utils', () => ({
|
||||
openExternalLink: (...args) => mocks.openExternalLink(...args),
|
||||
userImage: (...args) => mocks.userImage(...args),
|
||||
getFaviconUrl: (...args) => mocks.getFaviconUrl(...args),
|
||||
statusClass: () => 'status-online',
|
||||
languageClass: (lang) => `lang-${lang}`
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/Timer.vue', () => ({ default: 'Timer' }));
|
||||
vi.mock('../../../components/ui/button', () => ({ Button: 'Button' }));
|
||||
vi.mock('../../../components/ui/tooltip', () => ({ TooltipWrapper: 'TooltipWrapper' }));
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
ArrowUpDown: 'ArrowUpDown',
|
||||
Monitor: 'Monitor',
|
||||
Smartphone: 'Smartphone',
|
||||
Apple: 'Apple',
|
||||
IdCard: 'IdCard'
|
||||
}));
|
||||
|
||||
import { createColumns } from '../columns.jsx';
|
||||
|
||||
function createElement(type, props, ...children) {
|
||||
return {
|
||||
type,
|
||||
props: props ?? {},
|
||||
children: children.flat()
|
||||
};
|
||||
}
|
||||
|
||||
function findNode(node, predicate) {
|
||||
if (!node) return null;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
const result = findNode(item, predicate);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (predicate(node)) return node;
|
||||
if (!node.children) return null;
|
||||
return findNode(node.children, predicate);
|
||||
}
|
||||
|
||||
function makeRow(overrides = {}) {
|
||||
const original = {
|
||||
displayName: 'Alice',
|
||||
photonId: 7,
|
||||
ref: {
|
||||
id: 'usr_1',
|
||||
displayName: 'Alice',
|
||||
$trustSortNum: 10,
|
||||
$trustLevel: 'Known',
|
||||
$trustClass: 'known',
|
||||
status: 'online',
|
||||
statusDescription: 'Online',
|
||||
$platform: 'standalonewindows',
|
||||
last_platform: 'standalonewindows',
|
||||
$languages: [],
|
||||
bioLinks: [],
|
||||
note: ''
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
return {
|
||||
original,
|
||||
getValue: (key) => original[key]
|
||||
};
|
||||
}
|
||||
|
||||
describe('views/PlayerList/columns.jsx', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.React = { createElement };
|
||||
mocks.openExternalLink.mockReset();
|
||||
mocks.userImage.mockReset();
|
||||
mocks.getFaviconUrl.mockClear();
|
||||
mocks.sortAlphabetically.mockClear();
|
||||
mocks.onBlockChatbox.mockReset();
|
||||
mocks.onUnblockChatbox.mockReset();
|
||||
});
|
||||
|
||||
test('displayName sorting uses injected sortAlphabetically helper', () => {
|
||||
const cols = createColumns({
|
||||
randomUserColours: { value: false, __v_isRef: true },
|
||||
chatboxUserBlacklist: { value: new Map(), __v_isRef: true },
|
||||
onBlockChatbox: mocks.onBlockChatbox,
|
||||
onUnblockChatbox: mocks.onUnblockChatbox,
|
||||
sortAlphabetically: mocks.sortAlphabetically
|
||||
});
|
||||
const displayNameCol = cols.find((c) => c.id === 'displayName');
|
||||
|
||||
const result = displayNameCol.sortingFn(
|
||||
{ original: { displayName: 'Alice' } },
|
||||
{ original: { displayName: 'Bob' } }
|
||||
);
|
||||
|
||||
expect(result).toBe(123);
|
||||
expect(mocks.sortAlphabetically).toHaveBeenCalledWith(
|
||||
{ displayName: 'Alice' },
|
||||
{ displayName: 'Bob' },
|
||||
'displayName'
|
||||
);
|
||||
});
|
||||
|
||||
test('photonId cell triggers block and unblock actions', () => {
|
||||
const row = makeRow();
|
||||
const cols = createColumns({
|
||||
randomUserColours: { value: false, __v_isRef: true },
|
||||
chatboxUserBlacklist: { value: new Map(), __v_isRef: true },
|
||||
onBlockChatbox: mocks.onBlockChatbox,
|
||||
onUnblockChatbox: mocks.onUnblockChatbox,
|
||||
sortAlphabetically: mocks.sortAlphabetically
|
||||
});
|
||||
const photonCol = cols.find((c) => c.id === 'photonId');
|
||||
const blockCell = photonCol.cell({ row });
|
||||
findNode(blockCell, (n) => n.type === 'button').props.onClick({ stopPropagation: vi.fn() });
|
||||
expect(mocks.onBlockChatbox).toHaveBeenCalledWith(row.original.ref);
|
||||
|
||||
const blockedMap = new Map([[row.original.ref.id, row.original.ref.displayName]]);
|
||||
const colsBlocked = createColumns({
|
||||
randomUserColours: { value: false, __v_isRef: true },
|
||||
chatboxUserBlacklist: { value: blockedMap, __v_isRef: true },
|
||||
onBlockChatbox: mocks.onBlockChatbox,
|
||||
onUnblockChatbox: mocks.onUnblockChatbox,
|
||||
sortAlphabetically: mocks.sortAlphabetically
|
||||
});
|
||||
const photonBlocked = colsBlocked.find((c) => c.id === 'photonId');
|
||||
const unblockCell = photonBlocked.cell({ row });
|
||||
findNode(unblockCell, (n) => n.type === 'button').props.onClick({ stopPropagation: vi.fn() });
|
||||
expect(mocks.onUnblockChatbox).toHaveBeenCalledWith('usr_1');
|
||||
});
|
||||
|
||||
test('icon sorting prefers higher weighted role flags', () => {
|
||||
const cols = createColumns({
|
||||
randomUserColours: { value: false, __v_isRef: true },
|
||||
chatboxUserBlacklist: { value: new Map(), __v_isRef: true },
|
||||
onBlockChatbox: mocks.onBlockChatbox,
|
||||
onUnblockChatbox: mocks.onUnblockChatbox,
|
||||
sortAlphabetically: mocks.sortAlphabetically
|
||||
});
|
||||
const iconCol = cols.find((c) => c.id === 'icon');
|
||||
|
||||
const master = { original: { isMaster: true } };
|
||||
const friend = { original: { isFriend: true } };
|
||||
|
||||
expect(iconCol.sortingFn(master, friend, 'icon')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('bioLink cell opens external link when favicon is clicked', () => {
|
||||
const row = makeRow({
|
||||
ref: {
|
||||
...makeRow().original.ref,
|
||||
bioLinks: ['https://example.com']
|
||||
}
|
||||
});
|
||||
const cols = createColumns({
|
||||
randomUserColours: { value: false, __v_isRef: true },
|
||||
chatboxUserBlacklist: { value: new Map(), __v_isRef: true },
|
||||
onBlockChatbox: mocks.onBlockChatbox,
|
||||
onUnblockChatbox: mocks.onUnblockChatbox,
|
||||
sortAlphabetically: mocks.sortAlphabetically
|
||||
});
|
||||
const bioLinkCol = cols.find((c) => c.id === 'bioLink');
|
||||
const cell = bioLinkCol.cell({ row });
|
||||
findNode(cell, (n) => n.type === 'img').props.onClick({ stopPropagation: vi.fn() });
|
||||
|
||||
expect(mocks.openExternalLink).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
});
|
||||
167
src/views/Settings/dialogs/__tests__/ChangelogDialog.test.js
Normal file
167
src/views/Settings/dialogs/__tests__/ChangelogDialog.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────
|
||||
|
||||
const changeLogDialog = ref({
|
||||
visible: true,
|
||||
buildName: 'VRCX 2025.1.0',
|
||||
changeLog: '## New Features\n- Feature A\n- Feature B'
|
||||
});
|
||||
|
||||
const openExternalLinkFn = vi.fn();
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: () => ({ changeLogDialog }),
|
||||
defineStore: (id, fn) => fn
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../stores', () => ({
|
||||
useVRCXUpdaterStore: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../shared/utils', () => ({
|
||||
openExternalLink: (...args) => openExternalLinkFn(...args)
|
||||
}));
|
||||
|
||||
// Stub VueShowdown since it's async and we don't need real markdown rendering
|
||||
vi.mock('vue-showdown', () => ({
|
||||
VueShowdown: {
|
||||
props: ['markdown', 'flavor', 'options'],
|
||||
template:
|
||||
'<div class="changelog-markdown" data-testid="showdown">{{ markdown }}</div>'
|
||||
}
|
||||
}));
|
||||
|
||||
import ChangelogDialog from '../ChangelogDialog.vue';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function mountComponent() {
|
||||
return mount(ChangelogDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template:
|
||||
'<div data-testid="dialog" v-if="open"><slot /></div>'
|
||||
},
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<h2><slot /></h2>' },
|
||||
DialogFooter: {
|
||||
template: '<div data-testid="footer"><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
props: ['variant'],
|
||||
template:
|
||||
'<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ChangelogDialog.vue', () => {
|
||||
beforeEach(() => {
|
||||
changeLogDialog.value = {
|
||||
visible: true,
|
||||
buildName: 'VRCX 2025.1.0',
|
||||
changeLog: '## New Features\n- Feature A\n- Feature B'
|
||||
};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders dialog title', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.change_log.header');
|
||||
});
|
||||
|
||||
test('renders build name', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('VRCX 2025.1.0');
|
||||
});
|
||||
|
||||
test('renders description text', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.change_log.description');
|
||||
});
|
||||
|
||||
test('renders donation links', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('Ko-fi');
|
||||
expect(wrapper.text()).toContain('Patreon');
|
||||
});
|
||||
|
||||
test('renders GitHub button', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.change_log.github');
|
||||
});
|
||||
|
||||
test('renders Close button', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.change_log.close');
|
||||
});
|
||||
|
||||
test('does not render when visible is false', () => {
|
||||
changeLogDialog.value.visible = false;
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
test('clicking Close button sets visible to false', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const buttons = wrapper.findAll('button');
|
||||
const closeBtn = buttons.find((b) =>
|
||||
b.text().includes('dialog.change_log.close')
|
||||
);
|
||||
expect(closeBtn).toBeTruthy();
|
||||
|
||||
await closeBtn.trigger('click');
|
||||
expect(changeLogDialog.value.visible).toBe(false);
|
||||
});
|
||||
|
||||
test('clicking GitHub button opens external link', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const buttons = wrapper.findAll('button');
|
||||
const githubBtn = buttons.find((b) =>
|
||||
b.text().includes('dialog.change_log.github')
|
||||
);
|
||||
expect(githubBtn).toBeTruthy();
|
||||
|
||||
await githubBtn.trigger('click');
|
||||
expect(openExternalLinkFn).toHaveBeenCalledWith(
|
||||
'https://github.com/vrcx-team/VRCX/releases'
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking Ko-fi link opens external link', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const links = wrapper.findAll('a');
|
||||
const kofiLink = links.find((l) => l.text().includes('Ko-fi'));
|
||||
expect(kofiLink).toBeTruthy();
|
||||
|
||||
await kofiLink.trigger('click');
|
||||
expect(openExternalLinkFn).toHaveBeenCalledWith(
|
||||
'https://ko-fi.com/map1en_'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
320
src/views/Settings/dialogs/__tests__/LaunchOptionsDialog.test.js
Normal file
320
src/views/Settings/dialogs/__tests__/LaunchOptionsDialog.test.js
Normal file
@@ -0,0 +1,320 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// ─── Hoisted mocks (accessible inside vi.mock factories) ─────────────
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
configRepository: {
|
||||
getString: vi.fn().mockResolvedValue(''),
|
||||
setString: vi.fn()
|
||||
},
|
||||
openExternalLink: vi.fn(),
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
const isLaunchOptionsDialogVisible = ref(true);
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: () => ({ isLaunchOptionsDialogVisible }),
|
||||
defineStore: (id, fn) => fn
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../stores', () => ({
|
||||
useLaunchStore: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../service/config', () => ({
|
||||
default: mocks.configRepository
|
||||
}));
|
||||
|
||||
vi.mock('../../../../shared/utils', () => ({
|
||||
openExternalLink: (...args) => mocks.openExternalLink(...args)
|
||||
}));
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: mocks.toast
|
||||
}));
|
||||
|
||||
import LaunchOptionsDialog from '../LaunchOptionsDialog.vue';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function mountComponent() {
|
||||
return mount(LaunchOptionsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template:
|
||||
'<div data-testid="dialog" v-if="open"><slot /></div>'
|
||||
},
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<h2><slot /></h2>' },
|
||||
DialogFooter: {
|
||||
template: '<div data-testid="footer"><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
props: ['variant', 'disabled'],
|
||||
template:
|
||||
'<button @click="$emit(\'click\')" :disabled="disabled"><slot /></button>'
|
||||
},
|
||||
InputGroupTextareaField: {
|
||||
props: [
|
||||
'modelValue',
|
||||
'placeholder',
|
||||
'rows',
|
||||
'autosize',
|
||||
'inputClass',
|
||||
'spellcheck'
|
||||
],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<textarea data-testid="textarea" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"></textarea>'
|
||||
},
|
||||
Badge: { template: '<span><slot /></span>' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('LaunchOptionsDialog.vue', () => {
|
||||
beforeEach(() => {
|
||||
isLaunchOptionsDialogVisible.value = true;
|
||||
mocks.configRepository.getString.mockResolvedValue('');
|
||||
vi.clearAllMocks();
|
||||
globalThis.LINUX = false;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders dialog title', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('dialog.launch_options.header');
|
||||
});
|
||||
|
||||
test('renders description and example args', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.launch_options.description'
|
||||
);
|
||||
expect(wrapper.text()).toContain('--fps=144');
|
||||
expect(wrapper.text()).toContain('--enable-debug-gui');
|
||||
});
|
||||
|
||||
test('renders save button', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('dialog.launch_options.save');
|
||||
});
|
||||
|
||||
test('renders VRChat docs and Unity manual buttons', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.launch_options.vrchat_docs'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.launch_options.unity_manual'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders path override section when not Linux', async () => {
|
||||
globalThis.LINUX = false;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.launch_options.path_override'
|
||||
);
|
||||
});
|
||||
|
||||
test('hides path override section on Linux', async () => {
|
||||
globalThis.LINUX = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'dialog.launch_options.path_override'
|
||||
);
|
||||
});
|
||||
|
||||
test('does not render when not visible', () => {
|
||||
isLaunchOptionsDialogVisible.value = false;
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
test('loads launch arguments from configRepository on mount', async () => {
|
||||
mocks.configRepository.getString.mockImplementation((key) => {
|
||||
if (key === 'launchArguments')
|
||||
return Promise.resolve('--fps=90');
|
||||
if (key === 'vrcLaunchPathOverride')
|
||||
return Promise.resolve('C:\\VRChat');
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.configRepository.getString).toHaveBeenCalledWith(
|
||||
'launchArguments'
|
||||
);
|
||||
expect(mocks.configRepository.getString).toHaveBeenCalledWith(
|
||||
'vrcLaunchPathOverride'
|
||||
);
|
||||
});
|
||||
|
||||
test('clears null/string-null vrcLaunchPathOverride values', async () => {
|
||||
mocks.configRepository.getString.mockImplementation((key) => {
|
||||
if (key === 'vrcLaunchPathOverride')
|
||||
return Promise.resolve('null');
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.configRepository.setString).toHaveBeenCalledWith(
|
||||
'vrcLaunchPathOverride',
|
||||
''
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save logic', () => {
|
||||
test('normalizes whitespace in launch arguments on save', async () => {
|
||||
mocks.configRepository.getString.mockImplementation((key) => {
|
||||
if (key === 'launchArguments')
|
||||
return Promise.resolve('--fps=90 --debug ');
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.launch_options.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
expect(mocks.configRepository.setString).toHaveBeenCalledWith(
|
||||
'launchArguments',
|
||||
'--fps=90 --debug'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows error toast for invalid .exe path', async () => {
|
||||
mocks.configRepository.getString.mockImplementation((key) => {
|
||||
if (key === 'launchArguments') return Promise.resolve('');
|
||||
if (key === 'vrcLaunchPathOverride')
|
||||
return Promise.resolve('C:\\VRChat\\VRChat.exe');
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.launch_options.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith(
|
||||
'message.launch.invalid_path'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid launch.exe path', async () => {
|
||||
mocks.configRepository.getString.mockImplementation((key) => {
|
||||
if (key === 'launchArguments') return Promise.resolve('');
|
||||
if (key === 'vrcLaunchPathOverride')
|
||||
return Promise.resolve('C:\\VRChat\\launch.exe');
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.launch_options.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
expect(mocks.toast.error).not.toHaveBeenCalled();
|
||||
expect(mocks.toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('closes dialog after successful save', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.launch_options.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
expect(isLaunchOptionsDialogVisible.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external links', () => {
|
||||
test('clicking VRChat docs button opens external link', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const docsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.launch_options.vrchat_docs')
|
||||
);
|
||||
await docsBtn.trigger('click');
|
||||
|
||||
expect(mocks.openExternalLink).toHaveBeenCalledWith(
|
||||
'https://docs.vrchat.com/docs/launch-options'
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking Unity manual button opens external link', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const unityBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.launch_options.unity_manual')
|
||||
);
|
||||
await unityBtn.trigger('click');
|
||||
|
||||
expect(mocks.openExternalLink).toHaveBeenCalledWith(
|
||||
'https://docs.unity3d.com/Manual/CommandLineArguments.html'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
// ─── Hoisted mocks ──────────────────────────────────────────────────
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openExternalLink: vi.fn(),
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn()
|
||||
},
|
||||
setBioLanguage: vi.fn(),
|
||||
translateText: vi.fn().mockResolvedValue('Bonjour le monde'),
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
setTranslationApiKey: vi.fn().mockResolvedValue(undefined),
|
||||
setTranslationApiType: vi.fn().mockResolvedValue(undefined),
|
||||
setTranslationApiEndpoint: vi.fn().mockResolvedValue(undefined),
|
||||
setTranslationApiModel: vi.fn().mockResolvedValue(undefined),
|
||||
setTranslationApiPrompt: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
const bioLanguage = ref('en');
|
||||
const translationApiKey = ref('');
|
||||
const translationApiType = ref('google');
|
||||
const translationApiEndpoint = ref('');
|
||||
const translationApiModel = ref('');
|
||||
const translationApiPrompt = ref('');
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: () => ({
|
||||
bioLanguage,
|
||||
translationApiKey,
|
||||
translationApiType,
|
||||
translationApiEndpoint,
|
||||
translationApiModel,
|
||||
translationApiPrompt
|
||||
}),
|
||||
defineStore: (id, fn) => fn
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../stores', () => ({
|
||||
useAdvancedSettingsStore: () => ({
|
||||
setBioLanguage: mocks.setBioLanguage,
|
||||
translateText: mocks.translateText,
|
||||
fetchAvailableModels: mocks.fetchAvailableModels,
|
||||
setTranslationApiKey: mocks.setTranslationApiKey,
|
||||
setTranslationApiType: mocks.setTranslationApiType,
|
||||
setTranslationApiEndpoint: mocks.setTranslationApiEndpoint,
|
||||
setTranslationApiModel: mocks.setTranslationApiModel,
|
||||
setTranslationApiPrompt: mocks.setTranslationApiPrompt
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../shared/utils', () => ({
|
||||
openExternalLink: (...args) => mocks.openExternalLink(...args)
|
||||
}));
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: mocks.toast
|
||||
}));
|
||||
|
||||
vi.mock('../../../../localization', () => ({
|
||||
getLanguageName: (code) => `Language_${code}`,
|
||||
languageCodes: ['en', 'ja', 'ko', 'zh-CN', 'fr']
|
||||
}));
|
||||
|
||||
import TranslationApiDialog from '../TranslationApiDialog.vue';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param propsOverrides
|
||||
*/
|
||||
function mountComponent(propsOverrides = {}) {
|
||||
return mount(TranslationApiDialog, {
|
||||
props: {
|
||||
isTranslationApiDialogVisible: true,
|
||||
...propsOverrides
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template:
|
||||
'<div data-testid="dialog" v-if="open"><slot /></div>'
|
||||
},
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<h2><slot /></h2>' },
|
||||
DialogFooter: {
|
||||
template: '<div data-testid="footer"><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
props: ['variant', 'disabled', 'size'],
|
||||
template:
|
||||
'<button @click="$emit(\'click\')" :disabled="disabled"><slot /></button>'
|
||||
},
|
||||
InputGroupField: {
|
||||
props: [
|
||||
'modelValue',
|
||||
'type',
|
||||
'showPassword',
|
||||
'placeholder',
|
||||
'clearable'
|
||||
],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input data-testid="input-field" :type="type" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
InputGroupTextareaField: {
|
||||
props: ['modelValue', 'rows', 'clearable'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<textarea data-testid="textarea" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"></textarea>'
|
||||
},
|
||||
Select: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<select data-testid="select" :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>'
|
||||
},
|
||||
SelectTrigger: {
|
||||
props: ['size'],
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
SelectValue: {
|
||||
props: ['placeholder', 'textValue'],
|
||||
template: '<span>{{ placeholder }}</span>'
|
||||
},
|
||||
SelectContent: { template: '<div><slot /></div>' },
|
||||
SelectGroup: { template: '<div><slot /></div>' },
|
||||
SelectItem: {
|
||||
props: ['value', 'textValue'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
},
|
||||
FieldGroup: {
|
||||
template: '<div data-testid="field-group"><slot /></div>'
|
||||
},
|
||||
Field: { template: '<div data-testid="field"><slot /></div>' },
|
||||
FieldLabel: { template: '<label><slot /></label>' },
|
||||
FieldContent: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranslationApiDialog.vue', () => {
|
||||
beforeEach(() => {
|
||||
bioLanguage.value = 'en';
|
||||
translationApiKey.value = '';
|
||||
translationApiType.value = 'google';
|
||||
translationApiEndpoint.value = '';
|
||||
translationApiModel.value = '';
|
||||
translationApiPrompt.value = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders dialog title', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.translation_api.header');
|
||||
});
|
||||
|
||||
test('renders bio language selector', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain(
|
||||
'view.settings.appearance.appearance.bio_language'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders language options', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('Language_en');
|
||||
expect(wrapper.text()).toContain('Language_ja');
|
||||
});
|
||||
|
||||
test('renders API type selector', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.translation_api.mode');
|
||||
});
|
||||
|
||||
test('renders google and openai options', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.mode_google'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.mode_openai'
|
||||
);
|
||||
});
|
||||
|
||||
test('does not render when not visible', () => {
|
||||
const wrapper = mountComponent({
|
||||
isTranslationApiDialogVisible: false
|
||||
});
|
||||
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders save button', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.translation_api.save');
|
||||
});
|
||||
});
|
||||
|
||||
describe('google mode', () => {
|
||||
test('shows API key field in google mode', () => {
|
||||
translationApiType.value = 'google';
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.description'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows guide button in google mode', () => {
|
||||
translationApiType.value = 'google';
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('dialog.translation_api.guide');
|
||||
});
|
||||
|
||||
test('clicking guide button opens external link', async () => {
|
||||
translationApiType.value = 'google';
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const guideBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.guide'));
|
||||
await guideBtn.trigger('click');
|
||||
|
||||
expect(mocks.openExternalLink).toHaveBeenCalledWith(
|
||||
'https://translatepress.com/docs/automatic-translation/generate-google-api-key/'
|
||||
);
|
||||
});
|
||||
|
||||
test('does not show openai-specific fields', () => {
|
||||
translationApiType.value = 'google';
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'dialog.translation_api.openai.endpoint'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'dialog.translation_api.openai.model'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openai mode', () => {
|
||||
test('shows endpoint, api key, model, and prompt fields', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.openai.endpoint'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.openai.api_key'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.openai.model'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.openai.prompt_optional'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows test button in openai mode', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('dialog.translation_api.test');
|
||||
});
|
||||
|
||||
test('does not show guide button in openai mode', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'dialog.translation_api.guide'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows fetch models button', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.fetch_models'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save logic', () => {
|
||||
test('saves all config values on save in google mode', async () => {
|
||||
translationApiType.value = 'google';
|
||||
translationApiKey.value = 'test-key';
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.save'));
|
||||
await saveBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.setTranslationApiType).toHaveBeenCalledWith('google');
|
||||
expect(mocks.setTranslationApiKey).toHaveBeenCalled();
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith(
|
||||
'dialog.translation_api.msg_settings_saved'
|
||||
);
|
||||
});
|
||||
|
||||
test('warns if openai endpoint/model are empty on save', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value = '';
|
||||
translationApiModel.value = '';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.save'));
|
||||
await saveBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith(
|
||||
'dialog.translation_api.msg_fill_endpoint_model'
|
||||
);
|
||||
expect(mocks.setTranslationApiType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('emits close event after successful save', async () => {
|
||||
translationApiType.value = 'google';
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.save'));
|
||||
await saveBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.emitted('update:isTranslationApiDialogVisible')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.emitted('update:isTranslationApiDialogVisible')[0]
|
||||
).toEqual([false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test translation', () => {
|
||||
test('calls translateText with test parameters in openai mode', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value =
|
||||
'https://api.openai.com/v1/chat/completions';
|
||||
translationApiModel.value = 'gpt-4';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
const testBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.test'));
|
||||
await testBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.translateText).toHaveBeenCalledWith(
|
||||
'Hello world',
|
||||
'fr',
|
||||
expect.objectContaining({
|
||||
type: 'openai'
|
||||
})
|
||||
);
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith(
|
||||
'dialog.translation_api.msg_test_success'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows error toast when test fails', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value =
|
||||
'https://api.openai.com/v1/chat/completions';
|
||||
translationApiModel.value = 'gpt-4';
|
||||
mocks.translateText.mockRejectedValue(new Error('fail'));
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
const testBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.test'));
|
||||
await testBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith(
|
||||
'dialog.translation_api.msg_test_failed'
|
||||
);
|
||||
});
|
||||
|
||||
test('warns when endpoint/model are missing before test', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value = '';
|
||||
translationApiModel.value = '';
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
const testBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.translation_api.test'));
|
||||
await testBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith(
|
||||
'dialog.translation_api.msg_fill_endpoint_model'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch models', () => {
|
||||
test('fetches models and shows success toast', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value =
|
||||
'https://api.openai.com/v1/chat/completions';
|
||||
mocks.fetchAvailableModels.mockResolvedValue([
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo'
|
||||
]);
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
const fetchBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.translation_api.fetch_models')
|
||||
);
|
||||
await fetchBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchAvailableModels).toHaveBeenCalled();
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'dialog.translation_api.msg_models_fetched'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('warns when no models found', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value =
|
||||
'https://api.openai.com/v1/chat/completions';
|
||||
mocks.fetchAvailableModels.mockResolvedValue([]);
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
const fetchBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.translation_api.fetch_models')
|
||||
);
|
||||
await fetchBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith(
|
||||
'dialog.translation_api.msg_no_models_found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('form loading', () => {
|
||||
test('loads form values from store when dialog opens', async () => {
|
||||
translationApiType.value = 'openai';
|
||||
translationApiEndpoint.value = 'https://custom.api/v1';
|
||||
translationApiModel.value = 'custom-model';
|
||||
translationApiKey.value = 'sk-test';
|
||||
translationApiPrompt.value = 'Translate precisely';
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await nextTick();
|
||||
|
||||
// openai mode fields should be visible
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.translation_api.openai.endpoint'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
482
src/views/Settings/dialogs/__tests__/VRChatConfigDialog.test.js
Normal file
482
src/views/Settings/dialogs/__tests__/VRChatConfigDialog.test.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
// ─── Hoisted mocks ──────────────────────────────────────────────────
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openExternalLink: vi.fn(),
|
||||
getVRChatResolution: vi.fn((res) => res),
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn()
|
||||
},
|
||||
appApi: {
|
||||
ReadConfigFileSafe: vi.fn().mockResolvedValue(null),
|
||||
WriteConfigFile: vi.fn()
|
||||
},
|
||||
assetBundleManager: {
|
||||
DeleteAllCache: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
sweepVRChatCache: vi.fn(),
|
||||
getVRChatCacheSize: vi.fn(),
|
||||
folderSelectorDialog: vi.fn().mockResolvedValue(null),
|
||||
confirm: vi.fn().mockResolvedValue({ ok: false })
|
||||
}));
|
||||
|
||||
const isVRChatConfigDialogVisible = ref(false);
|
||||
const VRChatUsedCacheSize = ref('5.2');
|
||||
const VRChatTotalCacheSize = ref('30');
|
||||
const VRChatCacheSizeLoading = ref(false);
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store) => {
|
||||
const result = {};
|
||||
for (const key in store) {
|
||||
if (
|
||||
store[key] &&
|
||||
typeof store[key] === 'object' &&
|
||||
'__v_isRef' in store[key]
|
||||
) {
|
||||
result[key] = store[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
defineStore: (id, fn) => fn
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../stores', () => ({
|
||||
useGameStore: () => ({
|
||||
VRChatUsedCacheSize,
|
||||
VRChatTotalCacheSize,
|
||||
VRChatCacheSizeLoading,
|
||||
sweepVRChatCache: mocks.sweepVRChatCache,
|
||||
getVRChatCacheSize: mocks.getVRChatCacheSize
|
||||
}),
|
||||
useAdvancedSettingsStore: () => ({
|
||||
isVRChatConfigDialogVisible,
|
||||
folderSelectorDialog: mocks.folderSelectorDialog
|
||||
}),
|
||||
useModalStore: () => ({
|
||||
confirm: mocks.confirm
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../../shared/utils', () => ({
|
||||
openExternalLink: (...args) => mocks.openExternalLink(...args),
|
||||
getVRChatResolution: (...args) => mocks.getVRChatResolution(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../../../shared/constants', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
VRChatCameraResolutions: [
|
||||
{ name: '1920x1080 (1080p)', width: 1920, height: 1080 },
|
||||
{ name: '3840x2160 (4K)', width: 3840, height: 2160 },
|
||||
{ name: 'Default', width: 0, height: 0 }
|
||||
],
|
||||
VRChatScreenshotResolutions: [
|
||||
{ name: '1920x1080 (1080p)', width: 1920, height: 1080 },
|
||||
{ name: '3840x2160 (4K)', width: 3840, height: 2160 },
|
||||
{ name: 'Default', width: 0, height: 0 }
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: mocks.toast
|
||||
}));
|
||||
|
||||
// Set global mocks for CefSharp-injected APIs
|
||||
globalThis.AppApi = mocks.appApi;
|
||||
globalThis.AssetBundleManager = mocks.assetBundleManager;
|
||||
|
||||
import VRChatConfigDialog from '../VRChatConfigDialog.vue';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function mountComponent() {
|
||||
return mount(VRChatConfigDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template:
|
||||
'<div data-testid="dialog" v-if="open"><slot /></div>'
|
||||
},
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<h2><slot /></h2>' },
|
||||
DialogFooter: {
|
||||
template: '<div data-testid="footer"><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
props: ['variant', 'disabled', 'size'],
|
||||
template:
|
||||
'<button @click="$emit(\'click\')" :disabled="disabled"><slot /></button>'
|
||||
},
|
||||
InputGroupAction: {
|
||||
props: [
|
||||
'modelValue',
|
||||
'placeholder',
|
||||
'size',
|
||||
'type',
|
||||
'min',
|
||||
'max'
|
||||
],
|
||||
emits: ['update:modelValue', 'input'],
|
||||
template:
|
||||
'<div data-testid="input-group"><input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value); $emit(\'input\')" /><slot name="actions" /></div>'
|
||||
},
|
||||
Select: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-testid="select"><slot /></div>'
|
||||
},
|
||||
SelectTrigger: {
|
||||
props: ['size'],
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
SelectValue: {
|
||||
props: ['placeholder'],
|
||||
template: '<span>{{ placeholder }}</span>'
|
||||
},
|
||||
SelectContent: { template: '<div><slot /></div>' },
|
||||
SelectGroup: { template: '<div><slot /></div>' },
|
||||
SelectItem: {
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
},
|
||||
Checkbox: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />'
|
||||
},
|
||||
TooltipWrapper: {
|
||||
props: ['side', 'content'],
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
Spinner: { template: '<span data-testid="spinner" />' },
|
||||
RefreshCw: { template: '<span />' },
|
||||
FolderOpen: { template: '<span />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('VRChatConfigDialog.vue', () => {
|
||||
beforeEach(() => {
|
||||
isVRChatConfigDialogVisible.value = false;
|
||||
VRChatUsedCacheSize.value = '5.2';
|
||||
VRChatTotalCacheSize.value = '30';
|
||||
VRChatCacheSizeLoading.value = false;
|
||||
mocks.appApi.ReadConfigFileSafe.mockResolvedValue(null);
|
||||
mocks.confirm.mockResolvedValue({ ok: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('does not render when not visible', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders dialog content when visible', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('dialog.config_json.header');
|
||||
});
|
||||
|
||||
test('renders descriptions', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('dialog.config_json.description1');
|
||||
expect(wrapper.text()).toContain('dialog.config_json.description2');
|
||||
});
|
||||
|
||||
test('renders cache size info', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('dialog.config_json.cache_size');
|
||||
expect(wrapper.text()).toContain('5.2');
|
||||
expect(wrapper.text()).toContain('GB');
|
||||
});
|
||||
|
||||
test('renders config items', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.max_cache_size'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.cache_expiry_delay'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.fpv_steadycam_fov'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders resolution selectors', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.camera_resolution'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.spout_resolution'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.screenshot_resolution'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders checkbox options', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.picture_sort_by_date'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.config_json.disable_discord_presence'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders footer buttons', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('dialog.config_json.vrchat_docs');
|
||||
expect(wrapper.text()).toContain('dialog.config_json.cancel');
|
||||
expect(wrapper.text()).toContain('dialog.config_json.save');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config loading', () => {
|
||||
test('reads config file when dialog opens', async () => {
|
||||
mocks.appApi.ReadConfigFileSafe.mockResolvedValue(
|
||||
JSON.stringify({ cache_size: 50 })
|
||||
);
|
||||
|
||||
mountComponent();
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.appApi.ReadConfigFileSafe).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('save logic', () => {
|
||||
test('calls AppApi.WriteConfigFile on save', async () => {
|
||||
mocks.appApi.ReadConfigFileSafe.mockResolvedValue(
|
||||
JSON.stringify({ cache_size: 50 })
|
||||
);
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.config_json.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
expect(mocks.appApi.WriteConfigFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('removes empty string values before saving', async () => {
|
||||
mocks.appApi.ReadConfigFileSafe.mockResolvedValue(
|
||||
JSON.stringify({ cache_directory: '', cache_size: 50 })
|
||||
);
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.config_json.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
const savedJson = JSON.parse(
|
||||
mocks.appApi.WriteConfigFile.mock.calls[0][0]
|
||||
);
|
||||
expect(savedJson).not.toHaveProperty('cache_directory');
|
||||
});
|
||||
|
||||
test('closes dialog after save', async () => {
|
||||
mocks.appApi.ReadConfigFileSafe.mockResolvedValue(
|
||||
JSON.stringify({})
|
||||
);
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const saveBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.config_json.save'));
|
||||
await saveBtn.trigger('click');
|
||||
|
||||
expect(isVRChatConfigDialogVisible.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache operations', () => {
|
||||
test('delete cache button triggers confirm dialog', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const deleteBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.config_json.delete_cache')
|
||||
);
|
||||
expect(deleteBtn).toBeTruthy();
|
||||
await deleteBtn.trigger('click');
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('confirming delete calls AssetBundleManager.DeleteAllCache', async () => {
|
||||
mocks.confirm.mockResolvedValue({ ok: true });
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const deleteBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.config_json.delete_cache')
|
||||
);
|
||||
await deleteBtn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.assetBundleManager.DeleteAllCache).toHaveBeenCalled();
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith(
|
||||
'message.cache.deleted'
|
||||
);
|
||||
});
|
||||
|
||||
test('sweep cache button calls sweepVRChatCache', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const sweepBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.config_json.sweep_cache')
|
||||
);
|
||||
expect(sweepBtn).toBeTruthy();
|
||||
await sweepBtn.trigger('click');
|
||||
|
||||
expect(mocks.sweepVRChatCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close behavior', () => {
|
||||
test('clicking cancel closes dialog', async () => {
|
||||
mocks.appApi.ReadConfigFileSafe.mockResolvedValue(
|
||||
JSON.stringify({})
|
||||
);
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const cancelBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('dialog.config_json.cancel'));
|
||||
await cancelBtn.trigger('click');
|
||||
|
||||
expect(isVRChatConfigDialogVisible.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external links', () => {
|
||||
test('clicking VRChat docs opens external link', async () => {
|
||||
isVRChatConfigDialogVisible.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const docsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) =>
|
||||
b.text().includes('dialog.config_json.vrchat_docs')
|
||||
);
|
||||
await docsBtn.trigger('click');
|
||||
|
||||
expect(mocks.openExternalLink).toHaveBeenCalledWith(
|
||||
'https://docs.vrchat.com/docs/configuration-file'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
170
src/views/Sidebar/components/__tests__/FriendItem.test.js
Normal file
170
src/views/Sidebar/components/__tests__/FriendItem.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
290
src/views/Sidebar/components/__tests__/FriendsSidebar.test.js
Normal file
290
src/views/Sidebar/components/__tests__/FriendsSidebar.test.js
Normal 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)');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
221
src/views/Sidebar/components/__tests__/NotificationItem.test.js
Normal file
221
src/views/Sidebar/components/__tests__/NotificationItem.test.js
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
127
src/views/Sidebar/components/__tests__/NotificationList.test.js
Normal file
127
src/views/Sidebar/components/__tests__/NotificationList.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user