diff --git a/src/stores/notification/index.js b/src/stores/notification/index.js
index 2c8b803c..3330f33d 100644
--- a/src/stores/notification/index.js
+++ b/src/stores/notification/index.js
@@ -437,7 +437,6 @@ export const useNotificationStore = defineStore('Notification', () => {
const seeQueue = [];
const seenIds = new Set();
let seeProcessing = false;
- const SEE_CONCURRENCY = 2;
/**
*
@@ -445,48 +444,43 @@ export const useNotificationStore = defineStore('Notification', () => {
async function processSeeQueue() {
if (seeProcessing) return;
seeProcessing = true;
- const worker = async () => {
- let item;
- while ((item = seeQueue.shift())) {
- const { id, version } = item;
- try {
- await executeWithBackoff(
- async () => {
- if (version >= 2) {
- const args =
- await notificationRequest.seeNotificationV2(
- { notificationId: id }
- );
- handleNotificationV2Update({
- params: { notificationId: id },
- json: { ...args.json, seen: true }
- });
- } else {
- await notificationRequest.seeNotification({
+ let item;
+ while ((item = seeQueue.shift())) {
+ const { id, version } = item;
+ try {
+ await executeWithBackoff(
+ async () => {
+ if (version >= 2) {
+ const args =
+ await notificationRequest.seeNotificationV2({
notificationId: id
});
- handleNotificationSee(id);
- }
- },
- {
- maxRetries: 3,
- baseDelay: 1000,
- shouldRetry: (err) =>
- err?.status === 429 ||
- (err?.message || '').includes('429')
+ handleNotificationV2Update({
+ params: { notificationId: id },
+ json: { ...args.json, seen: true }
+ });
+ } else {
+ await notificationRequest.seeNotification({
+ notificationId: id
+ });
+ handleNotificationSee(id);
}
- );
- } catch (err) {
- console.warn('Failed to mark notification as seen:', id);
- if (version >= 2) {
- handleNotificationV2Hide(id);
+ },
+ {
+ maxRetries: 3,
+ baseDelay: 1000,
+ shouldRetry: (err) =>
+ err?.status === 429 ||
+ (err?.message || '').includes('429')
}
+ );
+ } catch (err) {
+ console.warn('Failed to mark notification as seen:', id);
+ if (version >= 2) {
+ handleNotificationV2Hide(id);
}
}
- };
- await Promise.all(
- Array.from({ length: SEE_CONCURRENCY }, () => worker())
- );
+ }
seeProcessing = false;
}
diff --git a/src/views/Favorites/components/FavoritesFriendItem.vue b/src/views/Favorites/components/FavoritesFriendItem.vue
index 19dd70eb..a0d2f399 100644
--- a/src/views/Favorites/components/FavoritesFriendItem.vue
+++ b/src/views/Favorites/components/FavoritesFriendItem.vue
@@ -11,7 +11,7 @@
{{ displayName }}
-
+
{{ t('dialog.user.actions.request_invite') }}
-
+
{{ t('dialog.user.actions.invite') }}
@@ -115,7 +118,11 @@
{{ favorite.id }}
-
@@ -296,4 +303,3 @@
}
}
-
diff --git a/src/views/FriendsLocations/components/__tests__/FriendsLocationsCard.test.js b/src/views/FriendsLocations/components/__tests__/FriendsLocationsCard.test.js
index 6d723a01..eff27251 100644
--- a/src/views/FriendsLocations/components/__tests__/FriendsLocationsCard.test.js
+++ b/src/views/FriendsLocations/components/__tests__/FriendsLocationsCard.test.js
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createI18n } from 'vue-i18n';
import { createTestingPinia } from '@pinia/testing';
-import { mount } from '@vue/test-utils';
+import { flushPromises, mount } from '@vue/test-utils';
import { ref } from 'vue';
import FriendsLocationsCard from '../FriendsLocationsCard.vue';
@@ -94,12 +94,56 @@ const {
mockSendRequestInvite,
mockSendInvite,
mockSelfInvite,
- mockQueryFetch
+ mockQueryFetch,
+ mockShowUserDialog,
+ mockCheckCanInvite,
+ mockCheckCanInviteSelf,
+ mockUserStatusClass,
+ mockUserImage,
+ mockToastSuccess,
+ mockToastError,
+ mockToastDismiss
} = vi.hoisted(() => ({
mockSendRequestInvite: vi.fn().mockResolvedValue({}),
mockSendInvite: vi.fn().mockResolvedValue({}),
mockSelfInvite: vi.fn().mockResolvedValue({}),
- mockQueryFetch: vi.fn().mockResolvedValue({ ref: { name: 'Test World' } })
+ mockQueryFetch: vi.fn().mockResolvedValue({ ref: { name: 'Test World' } }),
+ mockShowUserDialog: vi.fn(),
+ mockCheckCanInvite: vi.fn().mockReturnValue(true),
+ mockCheckCanInviteSelf: vi.fn().mockReturnValue(true),
+ mockUserStatusClass: vi
+ .fn()
+ .mockReturnValue({ online: true, joinme: false, active: false }),
+ mockUserImage: vi.fn().mockReturnValue('https://example.com/avatar.png'),
+ mockToastSuccess: vi.fn(),
+ mockToastError: vi.fn(),
+ mockToastDismiss: vi.fn()
+}));
+
+vi.mock('vue-sonner', () => ({
+ toast: {
+ success: (...args) => mockToastSuccess(...args),
+ error: (...args) => mockToastError(...args),
+ dismiss: (...args) => mockToastDismiss(...args)
+ }
+}));
+
+vi.mock('../../../../coordinators/userCoordinator', () => ({
+ showUserDialog: (...args) => mockShowUserDialog(...args)
+}));
+
+vi.mock('../../../../composables/useInviteChecks', () => ({
+ useInviteChecks: () => ({
+ checkCanInvite: (...args) => mockCheckCanInvite(...args),
+ checkCanInviteSelf: (...args) => mockCheckCanInviteSelf(...args)
+ })
+}));
+
+vi.mock('../../../../composables/useUserDisplay', () => ({
+ useUserDisplay: () => ({
+ userImage: (...args) => mockUserImage(...args),
+ userStatusClass: (...args) => mockUserStatusClass(...args)
+ })
}));
vi.mock('../../../../api', () => {
@@ -172,8 +216,10 @@ const stubs = {
template: '
'
},
Card: {
- template: '
',
- props: ['class', 'style']
+ template:
+ '
',
+ props: ['class', 'style'],
+ emits: ['click']
},
Avatar: { template: '
', props: ['class', 'style'] },
AvatarImage: { template: '
', props: ['src'] },
@@ -271,9 +317,25 @@ function getMenuItemTexts(wrapper) {
return getMenuItems(wrapper).map((item) => item.text().trim());
}
+/**
+ *
+ * @param wrapper
+ * @param text
+ */
+function getMenuItemByText(wrapper, text) {
+ return getMenuItems(wrapper).find((item) => item.text().trim() === text);
+}
+
describe('FriendsLocationsCard.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockCheckCanInvite.mockReturnValue(true);
+ mockCheckCanInviteSelf.mockReturnValue(true);
+ mockUserStatusClass.mockReturnValue({
+ online: true,
+ joinme: false,
+ active: false
+ });
});
describe('basic rendering', () => {
@@ -294,6 +356,13 @@ describe('FriendsLocationsCard.vue', () => {
expect(wrapper.text()).toContain('A');
});
+ test('shows ? as avatar fallback when name is empty', () => {
+ const wrapper = mountCard({
+ friend: makeFriend({ name: undefined })
+ });
+ expect(wrapper.text()).toContain('?');
+ });
+
test('hides location when displayInstanceInfo is false', () => {
const wrapper = mountCard({ displayInstanceInfo: false });
expect(wrapper.find('.location-stub').exists()).toBe(false);
@@ -402,6 +471,27 @@ describe('FriendsLocationsCard.vue', () => {
wrapper.find('[data-testid="context-menu-separator"]').exists()
).toBe(false);
});
+
+ test('shows Invite but disabled when cannot invite to my location', () => {
+ mockCheckCanInvite.mockReturnValue(false);
+ const wrapper = mountCard({}, { isGameRunning: true });
+ const inviteItem = getMenuItemByText(wrapper, 'Invite');
+ expect(inviteItem?.attributes('data-disabled')).toBe('true');
+ });
+
+ test('shows Launch/Invite but disabled when cannot join friend instance', () => {
+ mockCheckCanInviteSelf.mockReturnValue(false);
+ const wrapper = mountCard({
+ friend: makeFriend({
+ state: 'online',
+ ref: { location: 'wrld_12345:67890~region(us)' }
+ })
+ });
+ const launchInviteItem = getMenuItemByText(wrapper, 'Launch/Invite');
+ const inviteYourselfItem = getMenuItemByText(wrapper, 'Invite Yourself');
+ expect(launchInviteItem?.attributes('data-disabled')).toBe('true');
+ expect(inviteYourselfItem?.attributes('data-disabled')).toBe('true');
+ });
});
describe('context menu disabled states', () => {
@@ -433,9 +523,7 @@ describe('FriendsLocationsCard.vue', () => {
const wrapper = mountCard({
friend: makeFriend({ state: 'online' })
});
- const requestInviteItem = getMenuItems(wrapper).find(
- (item) => item.text().trim() === 'Request Invite'
- );
+ const requestInviteItem = getMenuItemByText(wrapper, 'Request Invite');
await requestInviteItem.trigger('click');
expect(mockSendRequestInvite).toHaveBeenCalledWith(
{ platform: 'standalonewindows' },
@@ -443,6 +531,28 @@ describe('FriendsLocationsCard.vue', () => {
);
});
+ test('friendInvite resolves traveling location and calls sendInvite API', async () => {
+ const wrapper = mountCard(
+ {},
+ {
+ isGameRunning: true,
+ lastLocation: { location: 'traveling' },
+ lastLocationDestination: 'wrld_dest:inst~region(us)'
+ }
+ );
+ const inviteItem = getMenuItemByText(wrapper, 'Invite');
+ await inviteItem.trigger('click');
+ await flushPromises();
+ expect(mockQueryFetch).toHaveBeenCalledWith('world.location', {
+ worldId: 'wrld_dest'
+ });
+ expect(mockSendInvite).toHaveBeenCalledTimes(1);
+ const [payload, userId] = mockSendInvite.mock.calls[0];
+ expect(payload.instanceId).toBe(payload.worldId);
+ expect(payload.worldName).toBe('Test World');
+ expect(userId).toBe('usr_test123');
+ });
+
test('friendInviteSelf calls selfInvite API', async () => {
const wrapper = mountCard({
friend: makeFriend({
@@ -450,14 +560,46 @@ describe('FriendsLocationsCard.vue', () => {
ref: { location: 'wrld_12345:67890~region(us)' }
})
});
- const selfInviteItem = getMenuItems(wrapper).find(
- (item) => item.text().trim() === 'Invite Yourself'
- );
+ const selfInviteItem = getMenuItemByText(wrapper, 'Invite Yourself');
await selfInviteItem.trigger('click');
expect(mockSelfInvite).toHaveBeenCalledWith({
instanceId: '67890~region(us)',
worldId: 'wrld_12345'
});
});
+
+ test('clicking card opens user dialog', async () => {
+ const wrapper = mountCard();
+ await wrapper.find('[data-testid="card"]').trigger('click');
+ expect(mockShowUserDialog).toHaveBeenCalledWith('usr_test123');
+ });
+ });
+
+ describe('status dot classes', () => {
+ test('shows join status class when user status indicates join me', () => {
+ mockUserStatusClass.mockReturnValue({
+ joinme: true,
+ online: false,
+ active: false
+ });
+ const wrapper = mountCard();
+ expect(wrapper.find('.friend-card__status-dot').classes()).toContain(
+ 'friend-card__status-dot--join'
+ );
+ });
+
+ test('shows active busy status class when active + busy', () => {
+ mockUserStatusClass.mockReturnValue({
+ joinme: false,
+ online: false,
+ active: true
+ });
+ const wrapper = mountCard({
+ friend: makeFriend({ status: 'busy' })
+ });
+ expect(wrapper.find('.friend-card__status-dot').classes()).toContain(
+ 'friend-card__status-dot--active-busy'
+ );
+ });
});
});
diff --git a/src/views/MyAvatars/MyAvatars.vue b/src/views/MyAvatars/MyAvatars.vue
index 886a9cf9..a7429607 100644
--- a/src/views/MyAvatars/MyAvatars.vue
+++ b/src/views/MyAvatars/MyAvatars.vue
@@ -781,7 +781,7 @@
* @param row
*/
function handleRowClick(row) {
- handleWearAvatar(row.original.id);
+ handleShowAvatarDialog(row.original.id);
}
/**
diff --git a/src/views/MyAvatars/__tests__/ManageTagsDialog.test.js b/src/views/MyAvatars/__tests__/ManageTagsDialog.test.js
new file mode 100644
index 00000000..10a4062b
--- /dev/null
+++ b/src/views/MyAvatars/__tests__/ManageTagsDialog.test.js
@@ -0,0 +1,136 @@
+import { describe, expect, test, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('../../../shared/constants', () => ({
+ TAG_COLORS: [
+ {
+ name: 'blue',
+ label: 'Blue',
+ bg: 'hsl(210 100% 50% / 0.2)',
+ text: 'hsl(210 100% 40%)'
+ }
+ ],
+ getTagColor: () => ({
+ name: 'blue',
+ bg: 'hsl(210 100% 50% / 0.2)',
+ text: 'hsl(210 100% 40%)'
+ })
+}));
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: {
+ props: ['open'],
+ emits: ['update:open'],
+ template: '
'
+ },
+ DialogContent: { template: '
' },
+ DialogDescription: { template: '
' },
+ DialogFooter: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' }
+}));
+
+vi.mock('@/components/ui/popover', () => ({
+ Popover: { template: '
' },
+ PopoverContent: { template: '
' },
+ PopoverTrigger: { template: '
' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/tags-input', () => ({
+ TagsInput: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: '
'
+ },
+ TagsInputInput: {
+ template: ''
+ },
+ TagsInputItem: {
+ props: ['value'],
+ template: '{{ value }}'
+ },
+ TagsInputItemDelete: { template: 'x' },
+ TagsInputItemText: { template: '' }
+}));
+
+import ManageTagsDialog from '../ManageTagsDialog.vue';
+
+function mountDialog(props = {}) {
+ return mount(ManageTagsDialog, {
+ props: {
+ open: false,
+ avatarName: 'Test Avatar',
+ avatarId: 'avtr_1',
+ initialTags: [],
+ ...props
+ }
+ });
+}
+
+describe('ManageTagsDialog.vue', () => {
+ test('loads initial tags when dialog opens', async () => {
+ const wrapper = mountDialog({
+ initialTags: [{ tag: 'cute', color: null }]
+ });
+
+ await wrapper.setProps({ open: true });
+ await nextTick();
+
+ const tags = wrapper.findAll('[data-testid="tag-item"]');
+ expect(tags).toHaveLength(1);
+ expect(tags[0].text()).toContain('cute');
+ });
+
+ test('emits save payload and closes dialog', async () => {
+ const wrapper = mountDialog({
+ initialTags: [{ tag: 'cute', color: null }]
+ });
+
+ await wrapper.setProps({ open: true });
+ await nextTick();
+
+ const okButton = wrapper
+ .findAll('[data-testid="button"]')
+ .find((node) => node.text().includes('prompt.rename_avatar.ok'));
+
+ expect(okButton).toBeTruthy();
+ await okButton.trigger('click');
+
+ expect(wrapper.emitted('save')).toBeTruthy();
+ expect(wrapper.emitted('save')[0][0]).toEqual({
+ avatarId: 'avtr_1',
+ tags: [{ tag: 'cute', color: null }]
+ });
+ expect(wrapper.emitted('update:open')).toBeTruthy();
+ expect(wrapper.emitted('update:open').at(-1)).toEqual([false]);
+ });
+
+ test('cancel button closes dialog without save', async () => {
+ const wrapper = mountDialog();
+
+ const cancelButton = wrapper
+ .findAll('[data-testid="button"]')
+ .find((node) => node.text().includes('prompt.rename_avatar.cancel'));
+
+ expect(cancelButton).toBeTruthy();
+ await cancelButton.trigger('click');
+
+ expect(wrapper.emitted('save')).toBeFalsy();
+ expect(wrapper.emitted('update:open')).toBeTruthy();
+ expect(wrapper.emitted('update:open').at(-1)).toEqual([false]);
+ });
+});
diff --git a/src/views/MyAvatars/__tests__/MyAvatars.test.js b/src/views/MyAvatars/__tests__/MyAvatars.test.js
new file mode 100644
index 00000000..d220fc83
--- /dev/null
+++ b/src/views/MyAvatars/__tests__/MyAvatars.test.js
@@ -0,0 +1,341 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { nextTick, ref } from 'vue';
+import { flushPromises, mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ currentUser: { value: { currentAvatar: 'avtr_current', $previousAvatarSwapTime: 0 }, __v_isRef: true },
+ modalConfirm: vi.fn(),
+ configGetString: vi.fn(),
+ configSetString: vi.fn(),
+ processBulk: vi.fn(),
+ applyAvatar: vi.fn((json) => ({ ...json })),
+ selectAvatarWithoutConfirmation: vi.fn(),
+ showAvatarDialog: vi.fn(),
+ getAllAvatarTags: vi.fn(),
+ getAvatarTimeSpent: vi.fn(),
+ virtualMeasure: 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: vi.fn(),
+ error: vi.fn()
+ }
+}));
+
+vi.mock('../../../plugins/router', () => ({
+ router: {
+ push: vi.fn(),
+ replace: vi.fn(),
+ beforeEach: vi.fn(),
+ currentRoute: ref({ path: '/', name: '' }),
+ isReady: vi.fn().mockResolvedValue(true)
+ },
+ initRouter: vi.fn()
+}));
+
+vi.mock('@tanstack/vue-virtual', () => ({
+ useVirtualizer: () => ({
+ value: {
+ getVirtualItems: () => [{ key: 0, index: 0, start: 0 }],
+ getTotalSize: () => 100,
+ measure: (...args) => mocks.virtualMeasure(...args),
+ measureElement: vi.fn()
+ }
+ })
+}));
+
+vi.mock('../../../stores', () => ({
+ useAppearanceSettingsStore: () => ({
+ tablePageSizes: [10, 25, 50],
+ tablePageSize: 25
+ }),
+ useAvatarStore: () => ({}),
+ useModalStore: () => ({
+ confirm: (...args) => mocks.modalConfirm(...args),
+ prompt: vi.fn()
+ }),
+ useUserStore: () => ({
+ currentUser: mocks.currentUser
+ })
+}));
+
+vi.mock('../../../coordinators/avatarCoordinator', () => ({
+ applyAvatar: (...args) => mocks.applyAvatar(...args),
+ selectAvatarWithoutConfirmation: (...args) => mocks.selectAvatarWithoutConfirmation(...args),
+ showAvatarDialog: (...args) => mocks.showAvatarDialog(...args)
+}));
+
+vi.mock('../../../coordinators/imageUploadCoordinator', () => ({
+ handleImageUploadInput: () => ({ file: null, clearInput: vi.fn() }),
+ resizeImageToFitLimits: vi.fn(),
+ uploadImageLegacy: vi.fn()
+}));
+
+vi.mock('../../../shared/utils/imageUpload', () => ({
+ readFileAsBase64: vi.fn(),
+ withUploadTimeout: async (promise) => promise
+}));
+
+vi.mock('../../../api', () => ({
+ avatarRequest: {
+ getAvatars: vi.fn(),
+ saveAvatar: vi.fn(),
+ createImposter: vi.fn(),
+ uploadAvatarImage: vi.fn()
+ }
+}));
+
+vi.mock('../../../services/database', () => ({
+ database: {
+ getAllAvatarTags: (...args) => mocks.getAllAvatarTags(...args),
+ getAvatarTimeSpent: (...args) => mocks.getAvatarTimeSpent(...args),
+ addAvatarTag: vi.fn(),
+ removeAvatarTag: vi.fn(),
+ updateAvatarTagColor: vi.fn()
+ }
+}));
+
+vi.mock('../columns.jsx', () => ({
+ getColumns: () => []
+}));
+
+vi.mock('../../../shared/utils/avatar', () => ({
+ getPlatformInfo: () => ({})
+}));
+
+vi.mock('../../../shared/constants', () => ({
+ getTagColor: () => ({ bg: '#000', text: '#fff' })
+}));
+
+vi.mock('../../../services/request', () => ({
+ processBulk: (...args) => mocks.processBulk(...args)
+}));
+
+vi.mock('../composables/useAvatarCardGrid.js', () => ({
+ useAvatarCardGrid: () => ({
+ cardScale: ref(0.6),
+ cardSpacing: ref(1),
+ cardScalePercent: ref(60),
+ cardSpacingPercent: ref(100),
+ cardScaleValue: ref([0.6]),
+ cardSpacingValue: ref([1]),
+ scaleSlider: { min: 0.3, max: 0.9, step: 0.05 },
+ spacingSlider: { min: 0.5, max: 1.5, step: 0.05 },
+ gridContainerRef: ref(null),
+ gridStyle: ref(() => ({ '--avatar-grid-columns': '1' })),
+ chunkIntoRows: (items, prefix = 'row') =>
+ Array.isArray(items)
+ ? items.map((item, index) => ({ key: `${prefix}:${index}`, items: [item] }))
+ : [],
+ estimateRowHeight: () => 80,
+ updateContainerWidth: vi.fn()
+ })
+}));
+
+vi.mock('../../../composables/useDataTableScrollHeight', () => ({
+ useDataTableScrollHeight: () => ({
+ tableStyle: {}
+ })
+}));
+
+vi.mock('../../../lib/table/useVrcxVueTable', () => ({
+ useVrcxVueTable: () => ({
+ table: {},
+ pagination: ref({ pageIndex: 0, pageSize: 25 })
+ })
+}));
+
+vi.mock('../../../services/config.js', () => ({
+ default: {
+ getString: (...args) => mocks.configGetString(...args),
+ setString: (...args) => mocks.configSetString(...args)
+ }
+}));
+
+vi.mock('../../../components/ui/context-menu', () => ({
+ ContextMenuContent: { template: '
' },
+ ContextMenuItem: { template: '' },
+ ContextMenuSeparator: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/dropdown-menu', () => ({
+ DropdownMenu: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuTrigger: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/field', () => ({
+ Field: { template: '
' },
+ FieldContent: { template: '
' },
+ FieldLabel: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/popover', () => ({
+ Popover: { template: '
' },
+ PopoverContent: { template: '
' },
+ PopoverTrigger: { template: '
' }
+}));
+
+vi.mock('../../../components/ui/data-table', () => ({
+ DataTableEmpty: { template: 'empty
' },
+ DataTableLayout: { template: 'table
' }
+}));
+
+vi.mock('../../../components/ui/toggle-group', () => ({
+ ToggleGroup: {
+ emits: ['update:model-value'],
+ template:
+ '' +
+ 'table' +
+ '' +
+ '
'
+ },
+ ToggleGroupItem: { template: '' }
+}));
+
+vi.mock('../../../components/ui/badge', () => ({
+ Badge: { template: '' }
+}));
+
+vi.mock('../../../components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('../../../components/ui/input', () => ({
+ Input: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../components/ui/slider', () => ({
+ Slider: { template: '' }
+}));
+
+vi.mock('../../../components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '
' }
+}));
+
+vi.mock('../../../components/dialogs/ImageCropDialog.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../ManageTagsDialog.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../components/MyAvatarCard.vue', () => ({
+ default: {
+ props: ['avatar'],
+ emits: ['click', 'context-action'],
+ template: '{{ avatar.name }}'
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ Check: { template: '' },
+ Eye: { template: '' },
+ Image: { template: '' },
+ LayoutGrid: { template: '' },
+ List: { template: '' },
+ ListFilter: { template: '' },
+ Pencil: { template: '' },
+ RefreshCw: { template: '' },
+ Settings: { template: '' },
+ Tag: { template: '' },
+ User: { template: '' }
+}));
+
+import MyAvatars from '../MyAvatars.vue';
+
+async function flushAll() {
+ await flushPromises();
+ await nextTick();
+ await nextTick();
+}
+
+describe('MyAvatars.vue', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.currentUser.value = { currentAvatar: 'avtr_current', $previousAvatarSwapTime: 0 };
+ mocks.modalConfirm.mockResolvedValue({ ok: true });
+ mocks.configGetString.mockImplementation((key, defaultValue) => {
+ if (key === 'VRCX_MyAvatarsViewMode') {
+ return Promise.resolve('grid');
+ }
+ return Promise.resolve(defaultValue ?? '');
+ });
+ mocks.getAllAvatarTags.mockResolvedValue(new Map([['avtr_1', [{ tag: 'fun', color: null }]]]));
+ mocks.getAvatarTimeSpent.mockResolvedValue({ timeSpent: 1000 });
+ mocks.processBulk.mockImplementation(async ({ handle, done }) => {
+ handle({
+ json: [
+ {
+ id: 'avtr_1',
+ name: 'Avatar One',
+ releaseStatus: 'public',
+ unityPackages: [],
+ updated_at: '2025-01-01T00:00:00.000Z',
+ created_at: '2024-01-01T00:00:00.000Z'
+ }
+ ]
+ });
+ await done();
+ });
+ });
+
+ test('loads table view mode from config', async () => {
+ mocks.configGetString.mockImplementation((key, defaultValue) => {
+ if (key === 'VRCX_MyAvatarsViewMode') {
+ return Promise.resolve('table');
+ }
+ return Promise.resolve(defaultValue ?? '');
+ });
+
+ const wrapper = mount(MyAvatars);
+ await flushAll();
+
+ expect(wrapper.find('[data-testid="table-layout"]').exists()).toBe(true);
+ });
+
+ test('persists view mode when toggled', async () => {
+ const wrapper = mount(MyAvatars);
+ await flushAll();
+
+ await wrapper.get('[data-testid="set-table"]').trigger('click');
+
+ expect(mocks.configSetString).toHaveBeenCalledWith('VRCX_MyAvatarsViewMode', 'table');
+ });
+
+ test('confirms and selects avatar when grid card is clicked', async () => {
+ const wrapper = mount(MyAvatars);
+ await flushAll();
+
+ await wrapper.get('[data-testid="avatar-card"]').trigger('click');
+ await flushAll();
+
+ expect(mocks.modalConfirm).toHaveBeenCalled();
+ expect(mocks.selectAvatarWithoutConfirmation).toHaveBeenCalledWith('avtr_1');
+ });
+});
diff --git a/src/views/MyAvatars/columns.jsx b/src/views/MyAvatars/columns.jsx
index cb03cb3b..7d53665e 100644
--- a/src/views/MyAvatars/columns.jsx
+++ b/src/views/MyAvatars/columns.jsx
@@ -76,7 +76,7 @@ export function getColumns({
'h-4 w-4',
isActive
? 'text-primary'
- : 'text-muted-foreground/0 group-hover/row:text-muted-foreground'
+ : 'text-muted-foreground/0'
]}
/>
diff --git a/src/views/MyAvatars/components/__tests__/MyAvatarCard.test.js b/src/views/MyAvatars/components/__tests__/MyAvatarCard.test.js
new file mode 100644
index 00000000..ce99f59f
--- /dev/null
+++ b/src/views/MyAvatars/components/__tests__/MyAvatarCard.test.js
@@ -0,0 +1,150 @@
+import { describe, expect, test, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('../../../../shared/utils', () => ({
+ formatDateFilter: () => 'formatted-date',
+ getAvailablePlatforms: () => ({ isPC: true, isQuest: true, isIos: false }),
+ getPlatformInfo: () => ({
+ pc: { performanceRating: 'Good' },
+ android: { performanceRating: 'Medium' },
+ ios: { performanceRating: '' }
+ }),
+ timeToText: () => '1h'
+}));
+
+vi.mock('../../../../shared/constants', () => ({
+ getTagColor: () => ({
+ name: 'blue',
+ bg: 'hsl(210 100% 50% / 0.2)',
+ text: 'hsl(210 100% 40%)'
+ })
+}));
+
+vi.mock('@/components/ui/context-menu', () => ({
+ ContextMenu: { template: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuSeparator: { template: '
' },
+ ContextMenuItem: {
+ props: ['disabled'],
+ emits: ['click'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/hover-card', () => ({
+ HoverCard: {
+ props: ['open'],
+ emits: ['update:open'],
+ template: '
'
+ },
+ HoverCardTrigger: { template: '
' },
+ HoverCardContent: { template: '
' }
+}));
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: { template: '' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/card', () => ({
+ Card: { template: '
' }
+}));
+
+vi.mock('@/components/ui/separator', () => ({
+ Separator: { template: '
' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ Apple: { template: '' },
+ Check: { template: '' },
+ ExternalLink: { template: '' },
+ Eye: { template: '' },
+ Image: { template: '' },
+ Monitor: { template: '' },
+ Pencil: { template: '' },
+ RefreshCw: { template: '' },
+ Smartphone: { template: '' },
+ Tag: { template: '' },
+ User: { template: '' }
+}));
+
+import MyAvatarCard from '../MyAvatarCard.vue';
+
+function mountCard(props = {}) {
+ return mount(MyAvatarCard, {
+ props: {
+ avatar: {
+ id: 'avtr_1',
+ name: 'Avatar One',
+ thumbnailImageUrl: 'https://example.com/a.jpg',
+ releaseStatus: 'public',
+ unityPackages: [],
+ $tags: [{ tag: 'fun' }],
+ updated_at: '2025-01-01T00:00:00.000Z',
+ created_at: '2024-01-01T00:00:00.000Z',
+ version: 1,
+ ...props.avatar
+ },
+ currentAvatarId: '',
+ cardScale: 0.6,
+ ...props
+ }
+ });
+}
+
+describe('MyAvatarCard.vue', () => {
+ test('renders avatar name and tags', () => {
+ const wrapper = mountCard();
+
+ expect(wrapper.text()).toContain('Avatar One');
+ expect(wrapper.text()).toContain('fun');
+ });
+
+ test('emits click when card wrapper is clicked', async () => {
+ const wrapper = mountCard();
+
+ await wrapper.find('.avatar-card-wrapper').trigger('click');
+
+ expect(wrapper.emitted('click')).toBeTruthy();
+ expect(wrapper.emitted('click')).toHaveLength(1);
+ });
+
+ test('emits context action from menu item', async () => {
+ const wrapper = mountCard();
+ const detailsItem = wrapper
+ .findAll('[data-testid="ctx-item"]')
+ .find((node) => node.text().includes('dialog.avatar.actions.view_details'));
+
+ expect(detailsItem).toBeTruthy();
+ await detailsItem.trigger('click');
+
+ expect(wrapper.emitted('context-action')).toBeTruthy();
+ expect(wrapper.emitted('context-action')[0]).toEqual([
+ 'details',
+ expect.objectContaining({ id: 'avtr_1' })
+ ]);
+ });
+
+ test('disables wear action when avatar is active', () => {
+ const wrapper = mountCard({ currentAvatarId: 'avtr_1' });
+ const wearItem = wrapper
+ .findAll('[data-testid="ctx-item"]')
+ .find((node) => node.text().includes('view.favorite.select_avatar_tooltip'));
+
+ expect(wearItem.attributes('disabled')).toBeDefined();
+ });
+});
diff --git a/src/views/Tools/Tools.vue b/src/views/Tools/Tools.vue
index 3ac19e79..092f10a4 100644
--- a/src/views/Tools/Tools.vue
+++ b/src/views/Tools/Tools.vue
@@ -1,17 +1,19 @@