diff --git a/eslint.config.mjs b/eslint.config.mjs
index 0586b65a..2325f807 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -33,7 +33,8 @@ export default defineConfig([
VERSION: 'readonly',
NIGHTLY: 'readonly',
webApiService: 'readonly',
- process: 'readonly'
+ process: 'readonly',
+ AppDebug: 'readonly'
}
}
},
@@ -42,7 +43,8 @@ export default defineConfig([
'**/webpack.*.js',
'**/jest.config.js',
'src-electron/*.js',
- 'src/localization/*.js'
+ 'src/localization/*.js',
+ 'src/shared/utils/localizationHelperCLI.js'
],
languageOptions: {
sourceType: 'commonjs',
@@ -59,7 +61,10 @@ export default defineConfig([
],
languageOptions: {
globals: {
- ...globals.jest
+ ...globals.jest,
+ ...globals.node,
+ vi: 'readonly',
+ vitest: 'readonly'
}
}
},
@@ -116,5 +121,12 @@ export default defineConfig([
'pretty-import/sort-import-names': 'warn'
}
},
- eslintPluginPrettierRecommended
+ {
+ ...eslintPluginPrettierRecommended,
+ ignores: [
+ '**/__tests__/**',
+ '**/*.spec.{js,mjs,cjs,vue}',
+ '**/*.test.{js,mjs,cjs,vue}'
+ ]
+ }
]);
diff --git a/src/shared/utils/localizationHelperCLI.js b/src/shared/utils/localizationHelperCLI.js
index 5fe908a2..984966a1 100644
--- a/src/shared/utils/localizationHelperCLI.js
+++ b/src/shared/utils/localizationHelperCLI.js
@@ -140,7 +140,7 @@ const Validate = function () {
let hasRemoved = false;
for (const [_, localeObj] of files) {
- toRemove = [];
+ let toRemove = [];
traverse(localeObj, (_, key, pathes) => {
let currObj = enObj;
for (const pathSegment of pathes) {
@@ -163,7 +163,7 @@ const Validate = function () {
}
}
- toAdd = [];
+ let toAdd = [];
traverse(enObj, (obj, key, pathes) => {
// Add above_key to the toAdd entry
if (
diff --git a/src/views/Feed/__tests__/Feed.test.js b/src/views/Feed/__tests__/Feed.test.js
new file mode 100644
index 00000000..c71c5436
--- /dev/null
+++ b/src/views/Feed/__tests__/Feed.test.js
@@ -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:
+ '
' +
+ '' +
+ '' +
+ '{{ totalItems }}' +
+ '
'
+ }
+}));
+
+vi.mock('../../../components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('../../../components/ui/popover', () => ({
+ Popover: {
+ props: ['open'],
+ emits: ['update:open'],
+ template: '
'
+ },
+ PopoverTrigger: { template: '
' },
+ PopoverContent: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/toggle-group', () => ({
+ ToggleGroup: {
+ emits: ['update:model-value'],
+ template:
+ '' +
+ '' +
+ '' +
+ '' +
+ '
'
+ },
+ ToggleGroupItem: {
+ props: ['value'],
+ template: ''
+ }
+}));
+
+vi.mock('../../../components/ui/toggle', () => ({
+ Toggle: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../components/ui/input-group', () => ({
+ InputGroupField: {
+ props: ['modelValue'],
+ emits: ['update:modelValue', 'change', 'keyup.enter'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../components/ui/range-calendar', () => ({
+ RangeCalendar: {
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../components/ui/badge', () => ({
+ Badge: { template: '' }
+}));
+
+vi.mock('../../../components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '
' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ ListFilter: { template: '' },
+ Star: { template: '' }
+}));
+
+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');
+ });
+});
diff --git a/src/views/FriendList/__tests__/FriendList.test.js b/src/views/FriendList/__tests__/FriendList.test.js
new file mode 100644
index 00000000..3c2e0338
--- /dev/null
+++ b/src/views/FriendList/__tests__/FriendList.test.js
@@ -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:
+ '' +
+ '' +
+ '' +
+ '' +
+ '{{ totalItems }}' +
+ '
'
+ }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/input-group', () => ({
+ InputGroupField: {
+ props: ['modelValue'],
+ emits: ['update:modelValue', 'change'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/progress', () => ({
+ Progress: { template: '' }
+}));
+
+vi.mock('@/components/ui/select', () => ({
+ Select: {
+ emits: ['update:modelValue'],
+ template:
+ '' +
+ '' +
+ '' +
+ '
'
+ },
+ SelectContent: { template: '
' },
+ SelectGroup: { template: '
' },
+ SelectItem: { template: '
' },
+ SelectTrigger: { template: '
' },
+ SelectValue: { template: '
' }
+}));
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: {
+ props: ['open'],
+ emits: ['update:open'],
+ template: '
'
+ },
+ DialogContent: { template: '
' },
+ DialogFooter: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' }
+}));
+
+vi.mock('@/components/ui/switch', () => ({
+ Switch: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/toggle', () => ({
+ Toggle: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '
' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ Star: { template: '' }
+}));
+
+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
+ });
+ });
+});
diff --git a/src/views/FriendLog/__tests__/FriendLog.test.js b/src/views/FriendLog/__tests__/FriendLog.test.js
new file mode 100644
index 00000000..fb511563
--- /dev/null
+++ b/src/views/FriendLog/__tests__/FriendLog.test.js
@@ -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:
+ '' +
+ '' +
+ '' +
+ '{{ totalItems }}' +
+ '
'
+ }
+}));
+
+vi.mock('../../../components/ui/select', () => ({
+ Select: {
+ emits: ['update:modelValue'],
+ template:
+ '
'
+ },
+ SelectContent: { template: '
' },
+ SelectGroup: { template: '
' },
+ SelectItem: { template: '
' },
+ SelectTrigger: { template: '
' },
+ SelectValue: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/input-group', () => ({
+ InputGroupField: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: ''
+ }
+}));
+
+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
+ });
+ });
+});
diff --git a/src/views/FriendsLocations/__tests__/FriendsLocations.test.js b/src/views/FriendsLocations/__tests__/FriendsLocations.test.js
new file mode 100644
index 00000000..f5ad3f40
--- /dev/null
+++ b/src/views/FriendsLocations/__tests__/FriendsLocations.test.js
@@ -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: '' },
+ Loader2: { template: '' },
+ Settings: { template: '' }
+}));
+
+vi.mock('@/components/ui/field', () => ({
+ Field: { template: '
' },
+ FieldContent: { template: '
' },
+ FieldLabel: { template: '
' }
+}));
+
+vi.mock('@/components/ui/tabs', () => ({
+ Tabs: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ '' +
+ '' +
+ '' +
+ '' +
+ '
'
+ },
+ TabsList: { template: '
' },
+ TabsTrigger: {
+ props: ['value'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/data-table', () => ({
+ DataTableEmpty: {
+ props: ['type'],
+ template: '{{ type }}
'
+ }
+}));
+
+vi.mock('@/components/ui/input-group', () => ({
+ InputGroupSearch: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../components/ui/popover', () => ({
+ Popover: { template: '
' },
+ PopoverContent: { template: '
' },
+ PopoverTrigger: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/slider', () => ({
+ Slider: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../components/ui/switch', () => ({
+ Switch: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../components/FriendsLocationsCard.vue', () => ({
+ default: {
+ props: ['friend'],
+ template: '{{ friend.displayName }}
'
+ }
+}));
+
+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');
+ });
+});
diff --git a/src/views/Login/Dialog/__tests__/LoginSettingsDialog.test.js b/src/views/Login/Dialog/__tests__/LoginSettingsDialog.test.js
new file mode 100644
index 00000000..75152ff7
--- /dev/null
+++ b/src/views/Login/Dialog/__tests__/LoginSettingsDialog.test.js
@@ -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:
+ '' +
+ '' +
+ '' +
+ '
'
+ },
+ DialogTrigger: { template: '
' },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogFooter: { template: '
' }
+}));
+
+vi.mock('@/components/ui/field', () => ({
+ Field: { template: '
' },
+ FieldContent: { template: '
' },
+ FieldGroup: { template: '
' },
+ FieldLabel: { template: '' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/checkbox', () => ({
+ Checkbox: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/input-group', () => ({
+ InputGroupField: {
+ props: ['id', 'modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ Settings: { template: '' }
+}));
+
+import LoginSettingsDialog from '../LoginSettingsDialog.vue';
+
+function mountDialog() {
+ return mount(LoginSettingsDialog, {
+ global: {
+ stubs: {
+ TooltipWrapper: { template: '
' }
+ }
+ }
+ });
+}
+
+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);
+ });
+});
diff --git a/src/views/Moderation/__tests__/Moderation.test.js b/src/views/Moderation/__tests__/Moderation.test.js
new file mode 100644
index 00000000..2246b954
--- /dev/null
+++ b/src/views/Moderation/__tests__/Moderation.test.js
@@ -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: '
'
+ },
+ SelectContent: { template: '
' },
+ SelectGroup: { template: '
' },
+ SelectItem: { template: '
' },
+ SelectTrigger: { template: '
' },
+ SelectValue: { template: '
' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/input-group', () => ({
+ InputGroupField: {
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/spinner', () => ({
+ Spinner: { template: '' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ RefreshCw: { template: '' }
+}));
+
+vi.mock('@/components/ui/data-table', () => ({
+ DataTableLayout: {
+ props: ['totalItems'],
+ template:
+ '' +
+ '{{ totalItems }}' +
+ '
'
+ }
+}));
+
+vi.mock('@/components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '
' }
+}));
+
+import Moderation from '../Moderation.vue';
+
+function mountModeration() {
+ return mount(Moderation, {
+ global: {
+ stubs: {
+ TooltipWrapper: { template: '
' }
+ }
+ }
+ });
+}
+
+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
+ });
+ });
+});
diff --git a/src/views/Moderation/__tests__/columns.test.js b/src/views/Moderation/__tests__/columns.test.js
new file mode 100644
index 00000000..685b3fd2
--- /dev/null
+++ b/src/views/Moderation/__tests__/columns.test.js
@@ -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);
+ });
+});
diff --git a/src/views/PlayerList/__tests__/PlayerList.test.js b/src/views/PlayerList/__tests__/PlayerList.test.js
new file mode 100644
index 00000000..654b8d38
--- /dev/null
+++ b/src/views/PlayerList/__tests__/PlayerList.test.js
@@ -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:
+ '' +
+ '' +
+ '' +
+ '
'
+ }
+}));
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: { template: '' }
+}));
+
+vi.mock('@/components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '
' }
+}));
+
+vi.mock('../../../components/LocationWorld.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../../../components/Timer.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../dialogs/ChatboxBlacklistDialog.vue', () => ({
+ default: {
+ props: ['chatboxBlacklistDialog'],
+ emits: ['delete-chatbox-user-blacklist'],
+ template:
+ '' +
+ '' +
+ '
'
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ Apple: { template: '' },
+ Home: { template: '' },
+ Monitor: { template: '' },
+ Smartphone: { template: '' }
+}));
+
+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: '
' },
+ LocationWorld: { template: '' }
+ }
+ }
+ });
+
+ 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: '
' },
+ LocationWorld: { template: '' }
+ }
+ }
+ });
+
+ 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: '
' },
+ LocationWorld: { template: '' }
+ }
+ }
+ });
+ 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: '
' },
+ LocationWorld: { template: '' }
+ }
+ }
+ });
+
+ 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: '
' },
+ LocationWorld: { template: '' }
+ }
+ }
+ });
+
+ 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);
+ });
+});
diff --git a/src/views/PlayerList/__tests__/columns.test.js b/src/views/PlayerList/__tests__/columns.test.js
new file mode 100644
index 00000000..e632824f
--- /dev/null
+++ b/src/views/PlayerList/__tests__/columns.test.js
@@ -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');
+ });
+});
diff --git a/src/views/Settings/dialogs/__tests__/ChangelogDialog.test.js b/src/views/Settings/dialogs/__tests__/ChangelogDialog.test.js
new file mode 100644
index 00000000..67525833
--- /dev/null
+++ b/src/views/Settings/dialogs/__tests__/ChangelogDialog.test.js
@@ -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:
+ '{{ markdown }}
'
+ }
+}));
+
+import ChangelogDialog from '../ChangelogDialog.vue';
+
+// ─── Helpers ─────────────────────────────────────────────────────────
+
+/**
+ *
+ */
+function mountComponent() {
+ return mount(ChangelogDialog, {
+ global: {
+ stubs: {
+ Dialog: {
+ props: ['open'],
+ emits: ['update:open'],
+ template:
+ '
'
+ },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogFooter: {
+ template: '
'
+ },
+ Button: {
+ emits: ['click'],
+ props: ['variant'],
+ template:
+ ''
+ }
+ }
+ }
+ });
+}
+
+// ─── 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_'
+ );
+ });
+ });
+});
diff --git a/src/views/Settings/dialogs/__tests__/LaunchOptionsDialog.test.js b/src/views/Settings/dialogs/__tests__/LaunchOptionsDialog.test.js
new file mode 100644
index 00000000..07b25017
--- /dev/null
+++ b/src/views/Settings/dialogs/__tests__/LaunchOptionsDialog.test.js
@@ -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:
+ '
'
+ },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogFooter: {
+ template: '
'
+ },
+ Button: {
+ emits: ['click'],
+ props: ['variant', 'disabled'],
+ template:
+ ''
+ },
+ InputGroupTextareaField: {
+ props: [
+ 'modelValue',
+ 'placeholder',
+ 'rows',
+ 'autosize',
+ 'inputClass',
+ 'spellcheck'
+ ],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ },
+ Badge: { template: '' }
+ }
+ }
+ });
+}
+
+// ─── 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'
+ );
+ });
+ });
+});
diff --git a/src/views/Settings/dialogs/__tests__/TranslationApiDialog.test.js b/src/views/Settings/dialogs/__tests__/TranslationApiDialog.test.js
new file mode 100644
index 00000000..a7c5c3da
--- /dev/null
+++ b/src/views/Settings/dialogs/__tests__/TranslationApiDialog.test.js
@@ -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:
+ '
'
+ },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogFooter: {
+ template: '
'
+ },
+ Button: {
+ emits: ['click'],
+ props: ['variant', 'disabled', 'size'],
+ template:
+ ''
+ },
+ InputGroupField: {
+ props: [
+ 'modelValue',
+ 'type',
+ 'showPassword',
+ 'placeholder',
+ 'clearable'
+ ],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ },
+ InputGroupTextareaField: {
+ props: ['modelValue', 'rows', 'clearable'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ },
+ Select: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ },
+ SelectTrigger: {
+ props: ['size'],
+ template: '
'
+ },
+ SelectValue: {
+ props: ['placeholder', 'textValue'],
+ template: '{{ placeholder }}'
+ },
+ SelectContent: { template: '
' },
+ SelectGroup: { template: '
' },
+ SelectItem: {
+ props: ['value', 'textValue'],
+ template: ''
+ },
+ FieldGroup: {
+ template: '
'
+ },
+ Field: { template: '
' },
+ FieldLabel: { template: '' },
+ FieldContent: { template: '
' }
+ }
+ }
+ });
+}
+
+// ─── 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'
+ );
+ });
+ });
+});
diff --git a/src/views/Settings/dialogs/__tests__/VRChatConfigDialog.test.js b/src/views/Settings/dialogs/__tests__/VRChatConfigDialog.test.js
new file mode 100644
index 00000000..c9c3681d
--- /dev/null
+++ b/src/views/Settings/dialogs/__tests__/VRChatConfigDialog.test.js
@@ -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:
+ '
'
+ },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogFooter: {
+ template: '
'
+ },
+ Button: {
+ emits: ['click'],
+ props: ['variant', 'disabled', 'size'],
+ template:
+ ''
+ },
+ InputGroupAction: {
+ props: [
+ 'modelValue',
+ 'placeholder',
+ 'size',
+ 'type',
+ 'min',
+ 'max'
+ ],
+ emits: ['update:modelValue', 'input'],
+ template:
+ '
'
+ },
+ Select: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: '
'
+ },
+ SelectTrigger: {
+ props: ['size'],
+ template: '
'
+ },
+ SelectValue: {
+ props: ['placeholder'],
+ template: '{{ placeholder }}'
+ },
+ SelectContent: { template: '
' },
+ SelectGroup: { template: '
' },
+ SelectItem: {
+ props: ['value'],
+ template: ''
+ },
+ Checkbox: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ },
+ TooltipWrapper: {
+ props: ['side', 'content'],
+ template: '
'
+ },
+ Spinner: { template: '' },
+ RefreshCw: { template: '' },
+ FolderOpen: { template: '' }
+ }
+ }
+ });
+}
+
+// ─── 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'
+ );
+ });
+ });
+});
diff --git a/src/views/Sidebar/components/__tests__/FriendItem.test.js b/src/views/Sidebar/components/__tests__/FriendItem.test.js
new file mode 100644
index 00000000..7ce18f0e
--- /dev/null
+++ b/src/views/Sidebar/components/__tests__/FriendItem.test.js
@@ -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: '
'
+ },
+ AvatarImage: {
+ props: ['src'],
+ template: '
'
+ },
+ AvatarFallback: {
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/spinner', () => ({
+ Spinner: {
+ template: ''
+ }
+}));
+
+vi.mock('@/components/Location.vue', () => ({
+ default: {
+ props: ['location', 'traveling', 'link'],
+ template:
+ '{{ location }}|{{ traveling }}'
+ }
+}));
+
+vi.mock('@/components/Timer.vue', () => ({
+ default: {
+ props: ['epoch'],
+ template: '{{ epoch }}'
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ User: {
+ template: ''
+ },
+ Trash2: {
+ template: ''
+ }
+}));
+
+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();
+ });
+});
diff --git a/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js b/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js
new file mode 100644
index 00000000..69739067
--- /dev/null
+++ b/src/views/Sidebar/components/__tests__/FriendsSidebar.test.js
@@ -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: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuItem: {
+ emits: ['click'],
+ props: ['disabled'],
+ template: ''
+ },
+ ContextMenuSeparator: { template: '
' },
+ ContextMenuSub: { template: '
' },
+ ContextMenuSubContent: { template: '
' },
+ ContextMenuSubTrigger: { template: '
' },
+ ContextMenuCheckboxItem: {
+ emits: ['click'],
+ props: ['modelValue'],
+ template: ''
+ }
+}));
+
+vi.mock('../../../../components/BackToTop.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../../../../components/Location.vue', () => ({
+ default: {
+ props: ['location', 'traveling', 'link'],
+ template: '{{ location }}'
+ }
+}));
+
+vi.mock('../FriendItem.vue', () => ({
+ default: {
+ props: ['friend'],
+ template: '{{ friend.id }}
'
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ ChevronDown: { template: '' }
+}));
+
+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)');
+ });
+});
diff --git a/src/views/Sidebar/components/__tests__/NotificationCenterSheet.test.js b/src/views/Sidebar/components/__tests__/NotificationCenterSheet.test.js
new file mode 100644
index 00000000..31f8385a
--- /dev/null
+++ b/src/views/Sidebar/components/__tests__/NotificationCenterSheet.test.js
@@ -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: '
'
+ },
+ SheetContent: {
+ template: '
'
+ },
+ SheetHeader: {
+ template: '
'
+ },
+ SheetTitle: {
+ template: '
'
+ }
+}));
+
+vi.mock('@/components/ui/tabs', () => ({
+ Tabs: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ '
'
+ },
+ TabsList: { template: '
' },
+ TabsTrigger: {
+ props: ['value'],
+ template:
+ ''
+ },
+ TabsContent: {
+ props: ['value'],
+ template:
+ '
'
+ }
+}));
+
+vi.mock('../NotificationList.vue', () => ({
+ default: {
+ props: ['notifications', 'recentNotifications'],
+ emits: [
+ 'show-invite-response',
+ 'show-invite-request-response',
+ 'navigate-to-table'
+ ],
+ template:
+ '' +
+ '' +
+ '' +
+ '' +
+ '
'
+ }
+}));
+
+vi.mock('../../../Notifications/dialogs/SendInviteResponseDialog.vue', () => ({
+ default: {
+ props: ['sendInviteResponseDialogVisible'],
+ template:
+ ''
+ }
+}));
+
+vi.mock(
+ '../../../Notifications/dialogs/SendInviteRequestResponseDialog.vue',
+ () => ({
+ default: {
+ props: ['sendInviteRequestResponseDialogVisible'],
+ template:
+ ''
+ }
+ })
+);
+
+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');
+ });
+});
diff --git a/src/views/Sidebar/components/__tests__/NotificationItem.test.js b/src/views/Sidebar/components/__tests__/NotificationItem.test.js
new file mode 100644
index 00000000..6137aab4
--- /dev/null
+++ b/src/views/Sidebar/components/__tests__/NotificationItem.test.js
@@ -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: '
' },
+ ItemMedia: { template: '
' },
+ ItemContent: { template: '
' },
+ ItemTitle: { template: '
' },
+ ItemDescription: {
+ template: '
'
+ }
+}));
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: { template: '
' },
+ AvatarImage: {
+ props: ['src'],
+ template: '
'
+ },
+ AvatarFallback: { template: '' }
+}));
+
+vi.mock('@/components/ui/hover-card', () => ({
+ HoverCard: { template: '
' },
+ HoverCardTrigger: { template: '
' },
+ HoverCardContent: { template: '
' }
+}));
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: { template: '' }
+}));
+
+vi.mock('@/components/ui/separator', () => ({
+ Separator: { template: '
' }
+}));
+
+vi.mock('@/components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '' }
+}));
+
+vi.mock('../../../../components/Location.vue', () => ({
+ default: {
+ props: ['location'],
+ template: '{{ location }}'
+ }
+}));
+
+vi.mock('lucide-vue-next', () => {
+ function icon(name) {
+ return { template: `` };
+ }
+ 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
+ );
+ });
+});
diff --git a/src/views/Sidebar/components/__tests__/NotificationList.test.js b/src/views/Sidebar/components/__tests__/NotificationList.test.js
new file mode 100644
index 00000000..99cf44fd
--- /dev/null
+++ b/src/views/Sidebar/components/__tests__/NotificationList.test.js
@@ -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:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/separator', () => ({
+ Separator: { template: '
' }
+}));
+
+vi.mock('../NotificationItem.vue', () => ({
+ default: {
+ props: ['notification', 'isUnseen'],
+ emits: ['show-invite-response', 'show-invite-request-response'],
+ template:
+ '' +
+ '{{ notification.id }}' +
+ '' +
+ '' +
+ '
'
+ }
+}));
+
+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();
+ });
+});