diff --git a/src/components/GlobalSearchDialog.vue b/src/components/GlobalSearchDialog.vue index 351772d6..db8bcf47 100644 --- a/src/components/GlobalSearchDialog.vue +++ b/src/components/GlobalSearchDialog.vue @@ -6,10 +6,11 @@ import { useI18n } from 'vue-i18n'; import { useGlobalSearchStore } from '../stores/globalSearch'; - import { userImage } from '../shared/utils'; + import { useUserDisplay } from '../composables/useUserDisplay'; import GlobalSearchSync from './GlobalSearchSync.vue'; + const { userImage } = useUserDisplay(); const globalSearchStore = useGlobalSearchStore(); const { isOpen, diff --git a/src/components/InstanceActionBar.vue b/src/components/InstanceActionBar.vue index 4fdb3f00..7f6b5bc8 100644 --- a/src/components/InstanceActionBar.vue +++ b/src/components/InstanceActionBar.vue @@ -159,7 +159,8 @@ useModalStore, useUserStore } from '../stores'; - import { checkCanInviteSelf, formatDateFilter, hasGroupPermission, parseLocation } from '../shared/utils'; + import { formatDateFilter, hasGroupPermission, parseLocation } from '../shared/utils'; + import { useInviteChecks } from '../composables/useInviteChecks'; import { instanceRequest, miscRequest } from '../api'; import { showUserDialog } from '../coordinators/userCoordinator'; @@ -180,6 +181,7 @@ const { instanceJoinHistory } = storeToRefs(instanceStore); const { canOpenInstanceInGame } = storeToRefs(inviteStore); const { isOpeningInstance } = storeToRefs(launchStore); + const { checkCanInviteSelf } = useInviteChecks(); const props = defineProps({ location: { diff --git a/src/components/dialogs/AvatarDialog/AvatarDialog.vue b/src/components/dialogs/AvatarDialog/AvatarDialog.vue index bf4d73d2..640fdc3d 100644 --- a/src/components/dialogs/AvatarDialog/AvatarDialog.vue +++ b/src/components/dialogs/AvatarDialog/AvatarDialog.vue @@ -571,6 +571,7 @@ import { useAppearanceSettingsStore, + useAuthStore, useAvatarStore, useFavoriteStore, useGalleryStore, @@ -622,6 +623,7 @@ import { showUserDialog } from '../../../coordinators/userCoordinator'; const { isGameRunning } = storeToRefs(useGameStore()); const { showFullscreenImageDialog } = useGalleryStore(); const { isDarkMode } = storeToRefs(useAppearanceSettingsStore()); + const authStore = useAuthStore(); const modalStore = useModalStore(); const uiStore = useUiStore(); @@ -698,7 +700,7 @@ import { showUserDialog } from '../../../coordinators/userCoordinator'; // skip imposters continue; } - if (!compareUnityVersion(unityPackage.unitySortNumber)) { + if (!compareUnityVersion(unityPackage.unitySortNumber, authStore.cachedConfig.sdkUnityVersion)) { continue; } let platform = 'PC'; diff --git a/src/components/dialogs/GroupDialog/GroupDialogMembersTab.vue b/src/components/dialogs/GroupDialog/GroupDialogMembersTab.vue index 15dd038d..1f001be8 100644 --- a/src/components/dialogs/GroupDialog/GroupDialogMembersTab.vue +++ b/src/components/dialogs/GroupDialog/GroupDialogMembersTab.vue @@ -210,13 +210,15 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; - import { downloadAndSaveJson, hasGroupPermission, userImage } from '../../../shared/utils'; + import { downloadAndSaveJson, hasGroupPermission } from '../../../shared/utils'; + import { useUserDisplay } from '../../../composables/useUserDisplay'; import { useGroupStore, useUserStore } from '../../../stores'; import { applyGroupMember, handleGroupMember } from '../../../coordinators/groupCoordinator'; import { groupDialogSortingOptions } from '../../../shared/constants'; import { useGroupMembers } from './useGroupMembers'; import { showUserDialog } from '../../../coordinators/userCoordinator'; + const { userImage } = useUserDisplay(); const { t } = useI18n(); diff --git a/src/components/dialogs/GroupDialog/GroupMemberModerationDialog.vue b/src/components/dialogs/GroupDialog/GroupMemberModerationDialog.vue index b21be5e5..b3d173f3 100644 --- a/src/components/dialogs/GroupDialog/GroupMemberModerationDialog.vue +++ b/src/components/dialogs/GroupDialog/GroupMemberModerationDialog.vue @@ -123,7 +123,8 @@ import { useAppearanceSettingsStore, useGalleryStore, useGroupStore, useUserStore } from '../../../stores'; import { applyGroupMember, handleGroupMember, handleGroupMemberProps } from '../../../coordinators/groupCoordinator'; - import { hasGroupPermission, userImage, userImageFull } from '../../../shared/utils'; + import { hasGroupPermission } from '../../../shared/utils'; + import { useUserDisplay } from '../../../composables/useUserDisplay'; import { groupDialogFilterOptions, groupDialogSortingOptions } from '../../../shared/constants'; import { groupRequest, userRequest } from '../../../api'; import { resolveRoleNames } from './groupModerationUtils'; @@ -142,6 +143,7 @@ import { showUserDialog } from '../../../coordinators/userCoordinator'; // ── Stores ─────────────────────────────────────────────────── + const { userImage, userImageFull } = useUserDisplay(); const appearanceSettingsStore = useAppearanceSettingsStore(); const { randomUserColours } = storeToRefs(appearanceSettingsStore); diff --git a/src/components/dialogs/InviteDialog/InviteDialog.vue b/src/components/dialogs/InviteDialog/InviteDialog.vue index 1dcca53e..a9a4d6ba 100644 --- a/src/components/dialogs/InviteDialog/InviteDialog.vue +++ b/src/components/dialogs/InviteDialog/InviteDialog.vue @@ -106,12 +106,14 @@ import { useI18n } from 'vue-i18n'; import { useFriendStore, useGalleryStore, useInviteStore, useModalStore, useUserStore } from '../../../stores'; - import { parseLocation, userImage, userStatusClass } from '../../../shared/utils'; + import { parseLocation } from '../../../shared/utils'; + import { useUserDisplay } from '../../../composables/useUserDisplay'; import { instanceRequest, notificationRequest } from '../../../api'; import { VirtualCombobox } from '../../ui/virtual-combobox'; import SendInviteDialog from './SendInviteDialog.vue'; + const { userImage, userStatusClass } = useUserDisplay(); const { vipFriends, onlineFriends, activeFriends } = storeToRefs(useFriendStore()); const { refreshInviteMessageTableData } = useInviteStore(); const { currentUser } = storeToRefs(useUserStore()); diff --git a/src/components/dialogs/InviteGroupDialog.vue b/src/components/dialogs/InviteGroupDialog.vue index d8633b1b..7d9ce9f6 100644 --- a/src/components/dialogs/InviteGroupDialog.vue +++ b/src/components/dialogs/InviteGroupDialog.vue @@ -96,13 +96,15 @@ import { toast } from 'vue-sonner'; import { useI18n } from 'vue-i18n'; - import { hasGroupPermission, userImage, userStatusClass } from '../../shared/utils'; + import { hasGroupPermission } from '../../shared/utils'; + import { useUserDisplay } from '../../composables/useUserDisplay'; import { useFriendStore, useGroupStore, useModalStore } from '../../stores'; import { groupRequest, queryRequest } from '../../api'; import { VirtualCombobox } from '../ui/virtual-combobox'; import configRepository from '../../services/config'; + const { userImage, userStatusClass } = useUserDisplay(); const { vipFriends, onlineFriends, activeFriends, offlineFriends } = storeToRefs(useFriendStore()); const { currentUserGroups, inviteGroupDialog } = storeToRefs(useGroupStore()); const { applyGroup } = useGroupStore(); diff --git a/src/components/dialogs/LaunchDialog.vue b/src/components/dialogs/LaunchDialog.vue index 90e93237..99aea3b5 100644 --- a/src/components/dialogs/LaunchDialog.vue +++ b/src/components/dialogs/LaunchDialog.vue @@ -161,7 +161,8 @@ useLocationStore, useModalStore } from '../../stores'; - import { checkCanInvite, getLaunchURL, isRealInstance, parseLocation } from '../../shared/utils'; + import { getLaunchURL, isRealInstance, parseLocation } from '../../shared/utils'; + import { useInviteChecks } from '../../composables/useInviteChecks'; import { instanceRequest, queryRequest } from '../../api'; import InviteDialog from './InviteDialog/InviteDialog.vue'; @@ -178,6 +179,7 @@ const { canOpenInstanceInGame } = storeToRefs(useInviteStore()); const { isGameRunning } = storeToRefs(useGameStore()); + const { checkCanInvite } = useInviteChecks(); const launchModeLabel = computed(() => launchDialog.value.desktop ? t('dialog.launch.start_as_desktop') : t('dialog.launch.launch') diff --git a/src/components/dialogs/ModerateGroupDialog.vue b/src/components/dialogs/ModerateGroupDialog.vue index 6d18b77d..5169f99a 100644 --- a/src/components/dialogs/ModerateGroupDialog.vue +++ b/src/components/dialogs/ModerateGroupDialog.vue @@ -63,11 +63,13 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; - import { hasGroupModerationPermission, userImage } from '../../shared/utils'; + import { hasGroupModerationPermission } from '../../shared/utils'; + import { useUserDisplay } from '../../composables/useUserDisplay'; import { VirtualCombobox } from '../ui/virtual-combobox'; import { queryRequest } from '../../api'; import { useGroupStore } from '../../stores'; + const { userImage } = useUserDisplay(); const { currentUserGroups, moderateGroupDialog } = storeToRefs(useGroupStore()); const { showGroupMemberModerationDialog } = useGroupStore(); const { t } = useI18n(); diff --git a/src/components/dialogs/UserDialog/UserActionDropdown.vue b/src/components/dialogs/UserDialog/UserActionDropdown.vue index fd24ea17..1a28af24 100644 --- a/src/components/dialogs/UserDialog/UserActionDropdown.vue +++ b/src/components/dialogs/UserDialog/UserActionDropdown.vue @@ -251,7 +251,7 @@ DropdownMenuTrigger } from '../../ui/dropdown-menu'; import { useGameStore, useLocationStore, useUserStore } from '../../../stores'; - import { checkCanInvite } from '../../../shared/utils'; + import { useInviteChecks } from '../../../composables/useInviteChecks'; const props = defineProps({ userDialogCommand: { @@ -265,6 +265,7 @@ const { userDialog, currentUser } = storeToRefs(useUserStore()); const { isGameRunning } = storeToRefs(useGameStore()); const { lastLocation } = storeToRefs(useLocationStore()); + const { checkCanInvite } = useInviteChecks(); const hasRequest = computed(() => userDialog.value.incomingRequest || userDialog.value.outgoingRequest); const hasRisk = computed( diff --git a/src/components/dialogs/WorldDialog/WorldDialogInfoTab.vue b/src/components/dialogs/WorldDialog/WorldDialogInfoTab.vue index 48548a30..9af45596 100644 --- a/src/components/dialogs/WorldDialog/WorldDialogInfoTab.vue +++ b/src/components/dialogs/WorldDialog/WorldDialogInfoTab.vue @@ -269,13 +269,14 @@ import { useI18n } from 'vue-i18n'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../ui/dropdown-menu'; - import { useInstanceStore, useWorldStore } from '../../../stores'; + import { useAuthStore, useInstanceStore, useWorldStore } from '../../../stores'; import { openExternalLink } from '../../../shared/utils'; import { useWorldDialogInfo } from './useWorldDialogInfo'; const { t } = useI18n(); const { worldDialog } = storeToRefs(useWorldStore()); + const authStore = useAuthStore(); const { showPreviousInstancesListDialog: openPreviousInstancesListDialog } = useInstanceStore(); const { @@ -293,7 +294,7 @@ copyWorldName, commaNumber, formatDateFilter - } = useWorldDialogInfo(worldDialog, { t, toast }); + } = useWorldDialogInfo(worldDialog, { t, toast, sdkUnityVersion: authStore.cachedConfig.sdkUnityVersion }); /** * diff --git a/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue b/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue index 426cc337..1411a9ac 100644 --- a/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue +++ b/src/components/dialogs/WorldDialog/WorldDialogInstancesTab.vue @@ -108,7 +108,8 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; - import { refreshInstancePlayerCount, userImage, userStatusClass } from '../../../shared/utils'; + import { refreshInstancePlayerCount } from '../../../shared/utils'; + import { useUserDisplay } from '../../../composables/useUserDisplay'; import { useAppearanceSettingsStore, useInstanceStore, @@ -118,9 +119,10 @@ } from '../../../stores'; import InstanceActionBar from '../../InstanceActionBar.vue'; -import { showUserDialog } from '../../../coordinators/userCoordinator'; + import { showUserDialog } from '../../../coordinators/userCoordinator'; const { t } = useI18n(); + const { userImage, userStatusClass } = useUserDisplay(); const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore()); diff --git a/src/components/dialogs/WorldDialog/useWorldDialogInfo.js b/src/components/dialogs/WorldDialog/useWorldDialogInfo.js index fda5c3e7..8ce4b78c 100644 --- a/src/components/dialogs/WorldDialog/useWorldDialogInfo.js +++ b/src/components/dialogs/WorldDialog/useWorldDialogInfo.js @@ -16,7 +16,7 @@ import { database } from '../../../services/database'; * @param {Function} deps.toast - toast notification function * @returns {Object} info composable API */ -export function useWorldDialogInfo(worldDialog, { t, toast }) { +export function useWorldDialogInfo(worldDialog, { t, toast, sdkUnityVersion }) { const memo = computed({ get() { return worldDialog.value.memo; @@ -71,7 +71,7 @@ export function useWorldDialogInfo(worldDialog, { t, toast }) { const platforms = []; if (ref.unityPackages) { for (const unityPackage of ref.unityPackages) { - if (!compareUnityVersion(unityPackage.unitySortNumber)) { + if (!compareUnityVersion(unityPackage.unitySortNumber, sdkUnityVersion)) { continue; } let platform = 'PC'; diff --git a/src/composables/useInviteChecks.js b/src/composables/useInviteChecks.js new file mode 100644 index 00000000..9bafbbda --- /dev/null +++ b/src/composables/useInviteChecks.js @@ -0,0 +1,39 @@ +import { + useFriendStore, + useInstanceStore, + useLocationStore, + useUserStore +} from '../stores'; +import { + checkCanInvite as checkCanInvitePure, + checkCanInviteSelf as checkCanInviteSelfPure +} from '../shared/utils/invite'; + +/** + * Composable that provides store-aware invite check functions. + * Delegates to the pure utility functions after resolving store data. + */ +export function useInviteChecks() { + const userStore = useUserStore(); + const locationStore = useLocationStore(); + const instanceStore = useInstanceStore(); + const friendStore = useFriendStore(); + + function checkCanInvite(location) { + return checkCanInvitePure(location, { + currentUserId: userStore.currentUser.id, + lastLocationStr: locationStore.lastLocation.location, + cachedInstances: instanceStore.cachedInstances + }); + } + + function checkCanInviteSelf(location) { + return checkCanInviteSelfPure(location, { + currentUserId: userStore.currentUser.id, + cachedInstances: instanceStore.cachedInstances, + friends: friendStore.friends + }); + } + + return { checkCanInvite, checkCanInviteSelf }; +} diff --git a/src/composables/useUserDisplay.js b/src/composables/useUserDisplay.js new file mode 100644 index 00000000..d72abc1b --- /dev/null +++ b/src/composables/useUserDisplay.js @@ -0,0 +1,43 @@ +import { useAppearanceSettingsStore, useUserStore } from '../stores'; +import { + userImage as userImagePure, + userImageFull as userImageFullPure, + userStatusClass as userStatusClassPure +} from '../shared/utils/user'; + +/** + * Composable that provides store-aware user display functions. + * Delegates to the pure utility functions after resolving store data. + */ +export function useUserDisplay() { + const userStore = useUserStore(); + const appearanceStore = useAppearanceSettingsStore(); + + function userStatusClass(user, pendingOffline = false) { + return userStatusClassPure(user, pendingOffline, userStore.currentUser); + } + + function userImage( + user, + isIcon = false, + resolution = '128', + isUserDialogIcon = false + ) { + return userImagePure( + user, + isIcon, + resolution, + isUserDialogIcon, + appearanceStore.displayVRCPlusIconsAsAvatar + ); + } + + function userImageFull(user) { + return userImageFullPure( + user, + appearanceStore.displayVRCPlusIconsAsAvatar + ); + } + + return { userStatusClass, userImage, userImageFull }; +} diff --git a/src/coordinators/friendRelationshipCoordinator.js b/src/coordinators/friendRelationshipCoordinator.js index 46b0b0bc..65e90946 100644 --- a/src/coordinators/friendRelationshipCoordinator.js +++ b/src/coordinators/friendRelationshipCoordinator.js @@ -321,7 +321,7 @@ export function updateUserCurrentStatus(ref) { friendStore.updateOnlineFriendCounter(); if (appearanceSettingsStore.randomUserColours) { - getNameColour(userStore.currentUser.id).then((colour) => { + getNameColour(userStore.currentUser.id, appearanceSettingsStore.isDarkMode).then((colour) => { userStore.setCurrentUserColour(colour); }); } diff --git a/src/shared/utils/__tests__/invite.test.js b/src/shared/utils/__tests__/invite.test.js index 32a0a9ec..c0c7b35c 100644 --- a/src/shared/utils/__tests__/invite.test.js +++ b/src/shared/utils/__tests__/invite.test.js @@ -1,151 +1,154 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; - -// Mock stores -vi.mock('../../../stores', () => ({ - useFriendStore: vi.fn(), - useInstanceStore: vi.fn(), - useLocationStore: vi.fn(), - useUserStore: vi.fn() -})); - -// Mock transitive deps -vi.mock('../../../views/Feed/Feed.vue', () => ({ - default: { template: '
' } -})); -vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] })); -vi.mock('../../../plugins/router', () => ({ - default: { push: vi.fn(), currentRoute: { value: {} } } -})); - -import { - useFriendStore, - useInstanceStore, - useLocationStore, - useUserStore -} from '../../../stores'; import { checkCanInvite, checkCanInviteSelf } from '../invite'; +const storeMocks = vi.hoisted(() => ({ + useUserStore: vi.fn(() => ({ currentUser: { id: 'usr_me' } })), + useLocationStore: vi.fn(() => ({ + lastLocation: { location: '', friendList: new Set() } + })), + useInstanceStore: vi.fn(() => ({ cachedInstances: new Map() })), + useFriendStore: vi.fn(() => ({ friends: new Map() })) +})); + +vi.mock('../../../stores', () => storeMocks); + describe('Invite Utils', () => { beforeEach(() => { - useUserStore.mockReturnValue({ - currentUser: { id: 'usr_me' } - }); - useLocationStore.mockReturnValue({ - lastLocation: { location: 'wrld_last:12345' } - }); - useInstanceStore.mockReturnValue({ - cachedInstances: new Map() - }); - useFriendStore.mockReturnValue({ - friends: new Map() - }); + vi.clearAllMocks(); }); + const defaultInviteDeps = { + currentUserId: 'usr_me', + lastLocationStr: 'wrld_last:12345', + cachedInstances: new Map() + }; + + const defaultSelfDeps = { + currentUserId: 'usr_me', + cachedInstances: new Map(), + friends: new Map() + }; + describe('checkCanInvite', () => { + test('does not access stores when deps are provided (pure path)', () => { + checkCanInvite('wrld_123:instance', defaultInviteDeps); + expect(storeMocks.useUserStore).not.toHaveBeenCalled(); + expect(storeMocks.useLocationStore).not.toHaveBeenCalled(); + expect(storeMocks.useInstanceStore).not.toHaveBeenCalled(); + }); + test('returns false for empty location', () => { - expect(checkCanInvite('')).toBe(false); - expect(checkCanInvite(null)).toBe(false); + expect(checkCanInvite('', defaultInviteDeps)).toBe(false); + expect(checkCanInvite(null, defaultInviteDeps)).toBe(false); }); test('returns true for public instance', () => { - expect(checkCanInvite('wrld_123:instance')).toBe(true); + expect(checkCanInvite('wrld_123:instance', defaultInviteDeps)).toBe(true); }); test('returns true for group instance', () => { expect( checkCanInvite( - 'wrld_123:instance~group(grp_123)~groupAccessType(public)' + 'wrld_123:instance~group(grp_123)~groupAccessType(public)', + defaultInviteDeps ) ).toBe(true); }); test('returns true for own instance', () => { - expect(checkCanInvite('wrld_123:instance~private(usr_me)')).toBe( + expect(checkCanInvite('wrld_123:instance~private(usr_me)', defaultInviteDeps)).toBe( true ); }); test('returns false for invite-only instance owned by another', () => { - expect(checkCanInvite('wrld_123:instance~private(usr_other)')).toBe( + expect(checkCanInvite('wrld_123:instance~private(usr_other)', defaultInviteDeps)).toBe( false ); }); test('returns false for friends-only instance', () => { - expect(checkCanInvite('wrld_123:instance~friends(usr_other)')).toBe( + expect(checkCanInvite('wrld_123:instance~friends(usr_other)', defaultInviteDeps)).toBe( false ); }); test('returns true for friends+ instance if current location matches', () => { const location = 'wrld_123:instance~hidden(usr_other)'; - useLocationStore.mockReturnValue({ - lastLocation: { location } - }); - expect(checkCanInvite(location)).toBe(true); + expect(checkCanInvite(location, { + ...defaultInviteDeps, + lastLocationStr: location + })).toBe(true); }); test('returns false for friends+ instance if not in that location', () => { - expect(checkCanInvite('wrld_123:instance~hidden(usr_other)')).toBe( + expect(checkCanInvite('wrld_123:instance~hidden(usr_other)', defaultInviteDeps)).toBe( false ); }); test('returns false for closed instance', () => { const location = 'wrld_123:instance'; - useInstanceStore.mockReturnValue({ + expect(checkCanInvite(location, { + ...defaultInviteDeps, cachedInstances: new Map([ [location, { closedAt: '2024-01-01' }] ]) - }); - expect(checkCanInvite(location)).toBe(false); + })).toBe(false); }); }); describe('checkCanInviteSelf', () => { + test('does not access stores when deps are provided (pure path)', () => { + checkCanInviteSelf('wrld_123:instance', defaultSelfDeps); + expect(storeMocks.useUserStore).not.toHaveBeenCalled(); + expect(storeMocks.useInstanceStore).not.toHaveBeenCalled(); + expect(storeMocks.useFriendStore).not.toHaveBeenCalled(); + }); + test('returns false for empty location', () => { - expect(checkCanInviteSelf('')).toBe(false); - expect(checkCanInviteSelf(null)).toBe(false); + expect(checkCanInviteSelf('', defaultSelfDeps)).toBe(false); + expect(checkCanInviteSelf(null, defaultSelfDeps)).toBe(false); }); test('returns true for own instance', () => { expect( - checkCanInviteSelf('wrld_123:instance~private(usr_me)') + checkCanInviteSelf('wrld_123:instance~private(usr_me)', defaultSelfDeps) ).toBe(true); }); test('returns true for public instance', () => { - expect(checkCanInviteSelf('wrld_123:instance')).toBe(true); + expect(checkCanInviteSelf('wrld_123:instance', defaultSelfDeps)).toBe(true); }); test('returns true for friends-only instance if user is a friend', () => { - useFriendStore.mockReturnValue({ - friends: new Map([['usr_owner', {}]]) - }); expect( - checkCanInviteSelf('wrld_123:instance~friends(usr_owner)') + checkCanInviteSelf('wrld_123:instance~friends(usr_owner)', { + ...defaultSelfDeps, + friends: new Map([['usr_owner', {}]]) + }) ).toBe(true); }); test('returns false for friends-only instance if user is not a friend', () => { expect( - checkCanInviteSelf('wrld_123:instance~friends(usr_other)') + checkCanInviteSelf('wrld_123:instance~friends(usr_other)', defaultSelfDeps) ).toBe(false); }); test('returns false for closed instance', () => { const location = 'wrld_123:instance'; - useInstanceStore.mockReturnValue({ + expect(checkCanInviteSelf(location, { + ...defaultSelfDeps, cachedInstances: new Map([ [location, { closedAt: '2024-01-01' }] ]) - }); - expect(checkCanInviteSelf(location)).toBe(false); + })).toBe(false); }); test('returns true for invite instance (not owned, not closed)', () => { expect( - checkCanInviteSelf('wrld_123:instance~private(usr_other)') + checkCanInviteSelf('wrld_123:instance~private(usr_other)', defaultSelfDeps) ).toBe(true); }); }); diff --git a/src/shared/utils/__tests__/user.test.js b/src/shared/utils/__tests__/user.test.js index 4c164f7c..2edc0ab5 100644 --- a/src/shared/utils/__tests__/user.test.js +++ b/src/shared/utils/__tests__/user.test.js @@ -1,11 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -// Mock stores -vi.mock('../../../stores', () => ({ - useUserStore: vi.fn(), - useAppearanceSettingsStore: vi.fn() -})); - // Mock common.js vi.mock('../common', () => ({ convertFileUrlToImageUrl: vi.fn((url) => `converted:${url}`) @@ -21,16 +15,22 @@ vi.mock('../base/ui', () => ({ HueToHex: vi.fn((h) => `#hue${h}`) })); -// Mock transitive deps that get pulled in via stores -vi.mock('../../../views/Feed/Feed.vue', () => ({ - default: { template: '' } -})); -vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] })); -vi.mock('../../../plugins/router', () => ({ - default: { push: vi.fn(), currentRoute: { value: {} } } +const storeMocks = vi.hoisted(() => ({ + useUserStore: vi.fn(() => ({ + currentUser: { + id: 'usr_store', + presence: { platform: 'standalonewindows' }, + onlineFriends: [], + activeFriends: [] + } + })), + useAppearanceSettingsStore: vi.fn(() => ({ + displayVRCPlusIconsAsAvatar: false + })) })); -import { useAppearanceSettingsStore, useUserStore } from '../../../stores'; +vi.mock('../../../stores', () => storeMocks); + import { languageClass, parseUserUrl, @@ -209,28 +209,42 @@ describe('User Utils', () => { }); }); - describe('userStatusClass (with store mock)', () => { + describe('userStatusClass (explicit currentUser)', () => { + let currentUser; + beforeEach(() => { - useUserStore.mockReturnValue({ - currentUser: { - id: 'usr_me', - presence: { platform: 'standalonewindows' }, - onlineFriends: [], - activeFriends: [] - } - }); + vi.clearAllMocks(); + currentUser = { + id: 'usr_me', + presence: { platform: 'standalonewindows' }, + onlineFriends: [], + activeFriends: [] + }; + }); + + test('does not access stores when currentUser is passed (pure path)', () => { + userStatusClass( + { id: 'usr_me', status: 'active', isFriend: true }, + false, + currentUser + ); + expect(storeMocks.useUserStore).not.toHaveBeenCalled(); }); test('returns null for undefined user', () => { - expect(userStatusClass(undefined)).toBeNull(); + expect(userStatusClass(undefined, false, currentUser)).toBeNull(); }); test('returns current user style with status', () => { - const result = userStatusClass({ - id: 'usr_me', - status: 'active', - isFriend: true - }); + const result = userStatusClass( + { + id: 'usr_me', + status: 'active', + isFriend: true + }, + false, + currentUser + ); expect(result).toMatchObject({ 'status-icon': true, online: true, @@ -239,35 +253,37 @@ describe('User Utils', () => { }); test('returns mobile true for non-PC platform on current user', () => { - useUserStore.mockReturnValue({ - currentUser: { + currentUser.presence = { platform: 'android' }; + const result = userStatusClass( + { id: 'usr_me', - presence: { platform: 'android' }, - onlineFriends: [], - activeFriends: [] - } - }); - const result = userStatusClass({ - id: 'usr_me', - status: 'active' - }); + status: 'active' + }, + false, + currentUser + ); expect(result.mobile).toBe(true); }); test('returns null for non-friend users', () => { expect( - userStatusClass({ - id: 'usr_other', - status: 'active', - isFriend: false - }) + userStatusClass( + { + id: 'usr_other', + status: 'active', + isFriend: false + }, + false, + currentUser + ) ).toBeNull(); }); test('returns offline style for pending offline friend', () => { const result = userStatusClass( { id: 'usr_other', isFriend: true, status: 'active' }, - true + true, + currentUser ); expect(result).toMatchObject({ 'status-icon': true, @@ -309,7 +325,7 @@ describe('User Utils', () => { status, location, state - }); + }, false, currentUser); expect(result[expected]).toBe(true); } }); @@ -321,7 +337,7 @@ describe('User Utils', () => { status: 'active', location: 'offline', state: '' - }); + }, false, currentUser); expect(result.offline).toBe(true); }); @@ -332,7 +348,7 @@ describe('User Utils', () => { status: 'busy', location: 'private', state: 'active' - }); + }, false, currentUser); expect(result.active).toBe(true); }); @@ -344,7 +360,7 @@ describe('User Utils', () => { location: 'wrld_1', state: 'online', $platform: 'android' - }); + }, false, currentUser); expect(result.mobile).toBe(true); }); @@ -356,7 +372,7 @@ describe('User Utils', () => { location: 'wrld_1', state: 'online', $platform: 'standalonewindows' - }); + }, false, currentUser); expect(result.mobile).toBeUndefined(); }); @@ -364,7 +380,7 @@ describe('User Utils', () => { const result = userStatusClass({ userId: 'usr_me', status: 'busy' - }); + }, false, currentUser); expect(result).toMatchObject({ 'status-icon': true, busy: true, @@ -373,79 +389,78 @@ describe('User Utils', () => { }); test('handles private location with empty state (temp fix branch)', () => { - useUserStore.mockReturnValue({ - currentUser: { - id: 'usr_me', - onlineFriends: [], - activeFriends: ['usr_f'] - } - }); + currentUser.activeFriends = ['usr_f']; const result = userStatusClass({ id: 'usr_f', isFriend: true, status: 'busy', location: 'private', state: '' - }); + }, false, currentUser); // activeFriends includes usr_f → active expect(result.active).toBe(true); }); test('handles private location temp fix → offline branch', () => { - useUserStore.mockReturnValue({ - currentUser: { - id: 'usr_me', - onlineFriends: [], - activeFriends: [] - } - }); + currentUser.activeFriends = []; const result = userStatusClass({ id: 'usr_f', isFriend: true, status: 'busy', location: 'private', state: '' - }); + }, false, currentUser); expect(result.offline).toBe(true); }); }); - describe('userImage (with store mock)', () => { - beforeEach(() => { - useAppearanceSettingsStore.mockReturnValue({ - displayVRCPlusIconsAsAvatar: false - }); + describe('userImage (explicit settings)', () => { + test('does not access appearance store when setting is passed (pure path)', () => { + userImage( + { thumbnailUrl: 'https://img.com/thumb' }, + false, + '128', + false, + false + ); + expect(storeMocks.useAppearanceSettingsStore).not.toHaveBeenCalled(); }); test('returns empty string for falsy user', () => { - expect(userImage(null)).toBe(''); - expect(userImage(undefined)).toBe(''); + expect(userImage(null, false, '128', false, false)).toBe(''); + expect(userImage(undefined, false, '128', false, false)).toBe(''); }); test('returns profilePicOverrideThumbnail when available', () => { const user = { profilePicOverrideThumbnail: 'https://img.com/pic/256/thumb' }; - expect(userImage(user)).toBe('https://img.com/pic/256/thumb'); + expect(userImage(user, false, '128', false, false)).toBe( + 'https://img.com/pic/256/thumb' + ); }); test('replaces resolution for icon mode with profilePicOverrideThumbnail', () => { const user = { profilePicOverrideThumbnail: 'https://img.com/pic/256/thumb' }; - expect(userImage(user, true, '64')).toBe( + expect(userImage(user, true, '64', false, false)).toBe( 'https://img.com/pic/64/thumb' ); }); test('returns profilePicOverride when no thumbnail', () => { const user = { profilePicOverride: 'https://img.com/full' }; - expect(userImage(user)).toBe('https://img.com/full'); + expect(userImage(user, false, '128', false, false)).toBe( + 'https://img.com/full' + ); }); test('returns thumbnailUrl as fallback', () => { const user = { thumbnailUrl: 'https://img.com/thumb' }; - expect(userImage(user)).toBe('https://img.com/thumb'); + expect(userImage(user, false, '128', false, false)).toBe( + 'https://img.com/thumb' + ); }); test('returns currentAvatarThumbnailImageUrl as fallback', () => { @@ -453,7 +468,9 @@ describe('User Utils', () => { currentAvatarThumbnailImageUrl: 'https://img.com/avatar/256/thumb' }; - expect(userImage(user)).toBe('https://img.com/avatar/256/thumb'); + expect(userImage(user, false, '128', false, false)).toBe( + 'https://img.com/avatar/256/thumb' + ); }); test('replaces resolution for icon mode with currentAvatarThumbnailImageUrl', () => { @@ -461,7 +478,7 @@ describe('User Utils', () => { currentAvatarThumbnailImageUrl: 'https://img.com/avatar/256/thumb' }; - expect(userImage(user, true, '64')).toBe( + expect(userImage(user, true, '64', false, false)).toBe( 'https://img.com/avatar/64/thumb' ); }); @@ -470,39 +487,37 @@ describe('User Utils', () => { const user = { currentAvatarImageUrl: 'https://img.com/avatar/full' }; - expect(userImage(user)).toBe('https://img.com/avatar/full'); + expect(userImage(user, false, '128', false, false)).toBe( + 'https://img.com/avatar/full' + ); }); test('converts currentAvatarImageUrl for icon mode', () => { const user = { currentAvatarImageUrl: 'https://img.com/avatar/full' }; - expect(userImage(user, true)).toBe( + expect(userImage(user, true, '128', false, false)).toBe( 'converted:https://img.com/avatar/full' ); }); test('returns empty string when user has no image fields', () => { - expect(userImage({})).toBe(''); + expect(userImage({}, false, '128', false, false)).toBe(''); }); test('returns userIcon when displayVRCPlusIconsAsAvatar is true', () => { - useAppearanceSettingsStore.mockReturnValue({ - displayVRCPlusIconsAsAvatar: true - }); const user = { userIcon: 'https://img.com/icon', thumbnailUrl: 'https://img.com/thumb' }; - expect(userImage(user)).toBe('https://img.com/icon'); + expect(userImage(user, false, '128', false, true)).toBe( + 'https://img.com/icon' + ); }); test('converts userIcon for icon mode when VRCPlus setting enabled', () => { - useAppearanceSettingsStore.mockReturnValue({ - displayVRCPlusIconsAsAvatar: true - }); const user = { userIcon: 'https://img.com/icon' }; - expect(userImage(user, true)).toBe( + expect(userImage(user, true, '128', false, true)).toBe( 'converted:https://img.com/icon' ); }); @@ -512,21 +527,23 @@ describe('User Utils', () => { userIcon: 'https://img.com/icon', thumbnailUrl: 'https://img.com/thumb' }; - expect(userImage(user, false, '128', true)).toBe( + expect(userImage(user, false, '128', true, false)).toBe( 'https://img.com/icon' ); }); }); - describe('userImageFull (with store mock)', () => { - beforeEach(() => { - useAppearanceSettingsStore.mockReturnValue({ - displayVRCPlusIconsAsAvatar: false - }); + describe('userImageFull (explicit settings)', () => { + test('does not access appearance store when setting is passed (pure path)', () => { + userImageFull( + { currentAvatarImageUrl: 'https://img.com/avatar' }, + false + ); + expect(storeMocks.useAppearanceSettingsStore).not.toHaveBeenCalled(); }); test('returns empty string for falsy user', () => { - expect(userImageFull(null)).toBe(''); + expect(userImageFull(null, false)).toBe(''); }); test('returns profilePicOverride when available', () => { @@ -534,25 +551,22 @@ describe('User Utils', () => { profilePicOverride: 'https://img.com/full', currentAvatarImageUrl: 'https://img.com/avatar' }; - expect(userImageFull(user)).toBe('https://img.com/full'); + expect(userImageFull(user, false)).toBe('https://img.com/full'); }); test('returns currentAvatarImageUrl as fallback', () => { const user = { currentAvatarImageUrl: 'https://img.com/avatar' }; - expect(userImageFull(user)).toBe('https://img.com/avatar'); + expect(userImageFull(user, false)).toBe('https://img.com/avatar'); }); test('returns userIcon when VRCPlus setting enabled', () => { - useAppearanceSettingsStore.mockReturnValue({ - displayVRCPlusIconsAsAvatar: true - }); const user = { userIcon: 'https://img.com/icon', profilePicOverride: 'https://img.com/full' }; - expect(userImageFull(user)).toBe('https://img.com/icon'); + expect(userImageFull(user, true)).toBe('https://img.com/icon'); }); }); }); diff --git a/src/shared/utils/avatar.js b/src/shared/utils/avatar.js index f24bb76e..c2e6c87f 100644 --- a/src/shared/utils/avatar.js +++ b/src/shared/utils/avatar.js @@ -1,5 +1,4 @@ import { replaceBioSymbols } from './base/string'; -import { useAuthStore } from '../../stores'; /** * @@ -95,10 +94,9 @@ function getPlatformInfo(unityPackages) { * @param {string} unitySortNumber * @returns {boolean} */ -function compareUnityVersion(unitySortNumber) { - const authStore = useAuthStore(); - if (!authStore.cachedConfig.sdkUnityVersion) { - console.error('No cachedConfig.sdkUnityVersion'); +function compareUnityVersion(unitySortNumber, sdkUnityVersion) { + if (!sdkUnityVersion) { + console.error('No sdkUnityVersion provided'); return false; } @@ -106,9 +104,9 @@ function compareUnityVersion(unitySortNumber) { // 2019.4.31f1 2019 04 31 000 // 5.3.4p1 5 03 04 010 // 2019.4.31f1c1 is a thing - const array = authStore.cachedConfig.sdkUnityVersion.split('.'); + const array = sdkUnityVersion.split('.'); if (array.length < 3) { - console.error('Invalid cachedConfig.sdkUnityVersion'); + console.error('Invalid sdkUnityVersion'); return false; } let currentUnityVersion = array[0]; diff --git a/src/shared/utils/base/devtool.js b/src/shared/utils/base/devtool.js index f519e743..455fb88b 100644 --- a/src/shared/utils/base/devtool.js +++ b/src/shared/utils/base/devtool.js @@ -3,7 +3,7 @@ import { extractFileVersion, extractVariantVersion } from '../common'; -import { useAvatarStore, useWorldStore } from '../../../stores'; +import { useAuthStore, useAvatarStore, useWorldStore } from '../../../stores'; import { compareUnityVersion } from '../avatar'; /** @@ -12,6 +12,8 @@ import { compareUnityVersion } from '../avatar'; * @returns {Promise