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(); + }); +});