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

View File

@@ -0,0 +1,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');
});
});

View 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
});
});
});

View 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
});
});
});

View 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');
});
});

View 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);
});
});

View 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
});
});
});

View 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);
});
});

View 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);
});
});

View 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');
});
});

View 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_'
);
});
});
});

View 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'
);
});
});
});

View File

@@ -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'
);
});
});
});

View 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'
);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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