diff --git a/src/localization/en.json b/src/localization/en.json index 4550f830..8bf3d9e7 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -12,7 +12,8 @@ "open": "Open", "confirm": "Confirm", "clear": "Clear", - "reset": "Reset" + "reset": "Reset", + "view_details": "View Details" }, "time_units": { "y": "y", diff --git a/src/views/Favorites/FavoritesFriend.vue b/src/views/Favorites/FavoritesFriend.vue index efeacbef..958e3f78 100644 --- a/src/views/Favorites/FavoritesFriend.vue +++ b/src/views/Favorites/FavoritesFriend.vue @@ -246,8 +246,7 @@ :group="activeRemoteGroup" :selected="selectedFavoriteFriends.includes(favorite.id)" :edit-mode="friendEditMode" - @toggle-select="toggleFriendSelection(favorite.id, $event)" - @click="showUserDialog(favorite.id)" /> + @toggle-select="toggleFriendSelection(favorite.id, $event)" />
@@ -268,8 +267,7 @@ :group="{ key: activeLocalGroupName, type: 'local' }" :selected="selectedFavoriteFriends.includes(favorite.id)" :edit-mode="friendEditMode" - @toggle-select="toggleFriendSelection(favorite.id, $event)" - @click="showUserDialog(favorite.id)" /> + @toggle-select="toggleFriendSelection(favorite.id, $event)" />
diff --git a/src/views/Favorites/components/FavoritesFriendItem.vue b/src/views/Favorites/components/FavoritesFriendItem.vue index f270098e..19dd70eb 100644 --- a/src/views/Favorites/components/FavoritesFriendItem.vue +++ b/src/views/Favorites/components/FavoritesFriendItem.vue @@ -1,101 +1,162 @@ - diff --git a/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js b/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js new file mode 100644 index 00000000..19ad8a1b --- /dev/null +++ b/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; + +const mocks = vi.hoisted(() => ({ + showFavoriteDialog: vi.fn(), + showUserDialog: vi.fn(), + showSendBoopDialog: vi.fn(), + removeLocalFriendFavorite: vi.fn(), + deleteFavorite: vi.fn(), + sendRequestInvite: vi.fn(() => Promise.resolve()), + sendInvite: vi.fn(() => Promise.resolve()), + selfInvite: vi.fn(() => Promise.resolve()), + fetch: vi.fn(() => Promise.resolve({ ref: { name: 'World Name' } })), + showLaunchDialog: vi.fn(), + checkCanInvite: vi.fn(() => true), + checkCanInviteSelf: vi.fn(() => true), + isRealInstance: vi.fn(() => true), + currentUser: { isBoopingEnabled: true }, + isGameRunning: { value: true }, + lastLocation: { value: { location: 'wrld_here:123~private' } }, + lastLocationDestination: { value: 'wrld_destination:456~private' }, + toastSuccess: vi.fn() +})); + +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key) => key + }) +})); + +vi.mock('vue-sonner', () => ({ + toast: { + success: (...args) => mocks.toastSuccess(...args) + } +})); + +vi.mock('../../../../stores', () => ({ + useFavoriteStore: () => ({ + showFavoriteDialog: (...args) => mocks.showFavoriteDialog(...args) + }), + useUserStore: () => ({ + showSendBoopDialog: (...args) => mocks.showSendBoopDialog(...args), + currentUser: mocks.currentUser + }), + useGameStore: () => ({ + isGameRunning: mocks.isGameRunning + }), + useLocationStore: () => ({ + lastLocation: mocks.lastLocation, + lastLocationDestination: mocks.lastLocationDestination + }), + useLaunchStore: () => ({ + showLaunchDialog: (...args) => mocks.showLaunchDialog(...args) + }) +})); + +vi.mock('../../../../api', () => ({ + favoriteRequest: { + deleteFavorite: (...args) => mocks.deleteFavorite(...args) + }, + notificationRequest: { + sendRequestInvite: (...args) => mocks.sendRequestInvite(...args), + sendInvite: (...args) => mocks.sendInvite(...args) + }, + instanceRequest: { + selfInvite: (...args) => mocks.selfInvite(...args) + }, + queryRequest: { + fetch: (...args) => mocks.fetch(...args) + } +})); + +vi.mock('../../../../coordinators/favoriteCoordinator', () => ({ + removeLocalFriendFavorite: (...args) => mocks.removeLocalFriendFavorite(...args) +})); + +vi.mock('../../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args) +})); + +vi.mock('../../../../composables/useInviteChecks', () => ({ + useInviteChecks: () => ({ + checkCanInvite: (...args) => mocks.checkCanInvite(...args), + checkCanInviteSelf: (...args) => mocks.checkCanInviteSelf(...args) + }) +})); + +vi.mock('../../../../composables/useUserDisplay', () => ({ + useUserDisplay: () => ({ + userImage: () => 'https://example.com/avatar.png' + }) +})); + +vi.mock('../../../../shared/utils', () => ({ + parseLocation: () => ({ worldId: 'wrld_123', instanceId: '123', tag: '123~private' }), + isRealInstance: (...args) => mocks.isRealInstance(...args) +})); + +vi.mock('../../../../components/Location.vue', () => ({ + default: { + template: '' + } +})); + +vi.mock('@/components/ui/item', () => ({ + Item: { + emits: ['click'], + template: '
' + }, + ItemActions: { template: '
' }, + ItemMedia: { template: '
' }, + ItemContent: { template: '
' }, + ItemTitle: { template: '
' }, + ItemDescription: { template: '
' } +})); + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: { template: '
' }, + AvatarImage: { template: '' }, + AvatarFallback: { 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/context-menu', () => ({ + ContextMenu: { template: '
' }, + ContextMenuTrigger: { template: '
' }, + ContextMenuContent: { template: '
' }, + ContextMenuSeparator: { template: '
' }, + ContextMenuItem: { + emits: ['click'], + template: '' + } +})); + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: { template: '
' }, + DropdownMenuTrigger: { template: '
' }, + DropdownMenuContent: { template: '
' }, + DropdownMenuSeparator: { template: '
' }, + DropdownMenuItem: { + emits: ['click'], + template: '' + } +})); + +vi.mock('lucide-vue-next', () => ({ + MoreHorizontal: { template: '' }, + Trash2: { template: '' } +})); + +import FavoritesFriendItem from '../FavoritesFriendItem.vue'; + +/** + * + * @param {Record} props + */ +function mountItem(props = {}) { + return mount(FavoritesFriendItem, { + props: { + favorite: { + id: 'usr_1', + ref: { + id: 'usr_1', + displayName: 'Alice', + statusDescription: 'Hello', + location: 'wrld_aaa:1~private', + travelingToLocation: '', + state: 'online' + } + }, + group: { key: 'g1', type: 'remote' }, + editMode: false, + selected: false, + ...props + } + }); +} + +/** + * + * @param wrapper + * @param text + */ +async function clickMenuItem(wrapper, text) { + const buttons = wrapper.findAll('button'); + const target = buttons.find((btn) => btn.text().includes(text)); + expect(target, `menu item not found: ${text}`).toBeTruthy(); + await target.trigger('click'); +} + +describe('FavoritesFriendItem.vue', () => { + beforeEach(() => { + mocks.showFavoriteDialog.mockReset(); + mocks.showUserDialog.mockReset(); + mocks.showSendBoopDialog.mockReset(); + mocks.removeLocalFriendFavorite.mockReset(); + mocks.deleteFavorite.mockReset(); + mocks.sendRequestInvite.mockClear(); + mocks.sendInvite.mockClear(); + mocks.selfInvite.mockClear(); + mocks.fetch.mockClear(); + mocks.showLaunchDialog.mockReset(); + mocks.checkCanInvite.mockReturnValue(true); + mocks.checkCanInviteSelf.mockReturnValue(true); + mocks.isRealInstance.mockReturnValue(true); + mocks.currentUser.isBoopingEnabled = true; + mocks.isGameRunning.value = true; + mocks.lastLocation.value = { location: 'wrld_here:123~private' }; + mocks.lastLocationDestination.value = 'wrld_destination:456~private'; + }); + + it('opens user dialog when item is clicked', async () => { + const wrapper = mountItem(); + + await wrapper.get('[data-testid="item"]').trigger('click'); + + expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1'); + }); + + it('emits toggle-select in edit mode checkbox', async () => { + const wrapper = mountItem({ editMode: true }); + + await wrapper.get('[data-testid="checkbox"]').setValue(true); + + expect(wrapper.emitted('toggle-select')).toEqual([[true]]); + }); + + it('uses local delete flow for local favorites', async () => { + const wrapper = mountItem({ group: { key: 'Local', type: 'local' } }); + + await clickMenuItem(wrapper, 'view.favorite.delete_tooltip'); + + expect(mocks.removeLocalFriendFavorite).toHaveBeenCalledWith('usr_1', 'Local'); + expect(mocks.deleteFavorite).not.toHaveBeenCalled(); + }); + + it('uses remote delete flow for remote favorites', async () => { + const wrapper = mountItem({ group: { key: 'Remote', type: 'remote' } }); + + await clickMenuItem(wrapper, 'view.favorite.unfavorite_tooltip'); + + expect(mocks.deleteFavorite).toHaveBeenCalledWith({ objectId: 'usr_1' }); + expect(mocks.removeLocalFriendFavorite).not.toHaveBeenCalled(); + }); + + it('renders invite/join actions only when online with instance', () => { + const wrapper = mountItem(); + + expect(wrapper.text()).toContain('dialog.user.actions.request_invite'); + expect(wrapper.text()).toContain('dialog.user.actions.invite'); + expect(wrapper.text()).toContain('dialog.user.info.launch_invite_tooltip'); + expect(wrapper.text()).toContain('dialog.user.info.self_invite_tooltip'); + }); + + it('hides invite/join actions when offline', () => { + const wrapper = mountItem({ + favorite: { + id: 'usr_1', + ref: { + id: 'usr_1', + displayName: 'Alice', + statusDescription: 'Offline', + location: 'offline', + travelingToLocation: '', + state: 'offline' + } + } + }); + + expect(wrapper.text()).not.toContain('dialog.user.actions.request_invite'); + expect(wrapper.text()).not.toContain('dialog.user.info.launch_invite_tooltip'); + expect(wrapper.text()).not.toContain('dialog.user.info.self_invite_tooltip'); + }); + + it('triggers request invite action', async () => { + const wrapper = mountItem(); + + await clickMenuItem(wrapper, 'dialog.user.actions.request_invite'); + + expect(mocks.sendRequestInvite).toHaveBeenCalledWith({ platform: 'standalonewindows' }, 'usr_1'); + }); + + it('triggers join action', async () => { + const wrapper = mountItem(); + + await clickMenuItem(wrapper, 'dialog.user.info.launch_invite_tooltip'); + + expect(mocks.showLaunchDialog).toHaveBeenCalledWith('wrld_aaa:1~private'); + }); +});