mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 06:56:04 +02:00
fix style
This commit is contained in:
@@ -437,7 +437,6 @@ export const useNotificationStore = defineStore('Notification', () => {
|
|||||||
const seeQueue = [];
|
const seeQueue = [];
|
||||||
const seenIds = new Set();
|
const seenIds = new Set();
|
||||||
let seeProcessing = false;
|
let seeProcessing = false;
|
||||||
const SEE_CONCURRENCY = 2;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -445,48 +444,43 @@ export const useNotificationStore = defineStore('Notification', () => {
|
|||||||
async function processSeeQueue() {
|
async function processSeeQueue() {
|
||||||
if (seeProcessing) return;
|
if (seeProcessing) return;
|
||||||
seeProcessing = true;
|
seeProcessing = true;
|
||||||
const worker = async () => {
|
let item;
|
||||||
let item;
|
while ((item = seeQueue.shift())) {
|
||||||
while ((item = seeQueue.shift())) {
|
const { id, version } = item;
|
||||||
const { id, version } = item;
|
try {
|
||||||
try {
|
await executeWithBackoff(
|
||||||
await executeWithBackoff(
|
async () => {
|
||||||
async () => {
|
if (version >= 2) {
|
||||||
if (version >= 2) {
|
const args =
|
||||||
const args =
|
await notificationRequest.seeNotificationV2({
|
||||||
await notificationRequest.seeNotificationV2(
|
|
||||||
{ notificationId: id }
|
|
||||||
);
|
|
||||||
handleNotificationV2Update({
|
|
||||||
params: { notificationId: id },
|
|
||||||
json: { ...args.json, seen: true }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await notificationRequest.seeNotification({
|
|
||||||
notificationId: id
|
notificationId: id
|
||||||
});
|
});
|
||||||
handleNotificationSee(id);
|
handleNotificationV2Update({
|
||||||
}
|
params: { notificationId: id },
|
||||||
},
|
json: { ...args.json, seen: true }
|
||||||
{
|
});
|
||||||
maxRetries: 3,
|
} else {
|
||||||
baseDelay: 1000,
|
await notificationRequest.seeNotification({
|
||||||
shouldRetry: (err) =>
|
notificationId: id
|
||||||
err?.status === 429 ||
|
});
|
||||||
(err?.message || '').includes('429')
|
handleNotificationSee(id);
|
||||||
}
|
}
|
||||||
);
|
},
|
||||||
} catch (err) {
|
{
|
||||||
console.warn('Failed to mark notification as seen:', id);
|
maxRetries: 3,
|
||||||
if (version >= 2) {
|
baseDelay: 1000,
|
||||||
handleNotificationV2Hide(id);
|
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;
|
seeProcessing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent class="min-w-0">
|
<ItemContent class="min-w-0">
|
||||||
<ItemTitle class="truncate max-w-full" :style="displayNameStyle">{{ displayName }}</ItemTitle>
|
<ItemTitle class="truncate max-w-full" :style="displayNameStyle">{{ displayName }}</ItemTitle>
|
||||||
<ItemDescription class="truncate line-clamp-1">
|
<ItemDescription class="truncate line-clamp-1 text-xs!">
|
||||||
<template v-if="favorite.ref.location !== 'offline'">
|
<template v-if="favorite.ref.location !== 'offline'">
|
||||||
<Location
|
<Location
|
||||||
:location="favorite.ref.location"
|
:location="favorite.ref.location"
|
||||||
@@ -39,7 +39,10 @@
|
|||||||
<DropdownMenuItem v-if="favorite.ref.state === 'online'" @click="friendRequestInvite">
|
<DropdownMenuItem v-if="favorite.ref.state === 'online'" @click="friendRequestInvite">
|
||||||
{{ t('dialog.user.actions.request_invite') }}
|
{{ t('dialog.user.actions.request_invite') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem v-if="isGameRunning" :disabled="!canInviteToMyLocation" @click="friendInvite">
|
<DropdownMenuItem
|
||||||
|
v-if="isGameRunning"
|
||||||
|
:disabled="!canInviteToMyLocation"
|
||||||
|
@click="friendInvite">
|
||||||
{{ t('dialog.user.actions.invite') }}
|
{{ t('dialog.user.actions.invite') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem :disabled="!currentUser?.isBoopingEnabled" @click="friendSendBoop">
|
<DropdownMenuItem :disabled="!currentUser?.isBoopingEnabled" @click="friendSendBoop">
|
||||||
@@ -115,7 +118,11 @@
|
|||||||
<ItemDescription class="truncate line-clamp-1">{{ favorite.id }}</ItemDescription>
|
<ItemDescription class="truncate line-clamp-1">{{ favorite.id }}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Button class="rounded-full h-6 w-6" size="icon-sm" variant="outline" @click.stop="handleDeleteFavorite">
|
<Button
|
||||||
|
class="rounded-full h-6 w-6"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="outline"
|
||||||
|
@click.stop="handleDeleteFavorite">
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
@@ -296,4 +303,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { createI18n } from 'vue-i18n';
|
import { createI18n } from 'vue-i18n';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import FriendsLocationsCard from '../FriendsLocationsCard.vue';
|
import FriendsLocationsCard from '../FriendsLocationsCard.vue';
|
||||||
@@ -94,12 +94,56 @@ const {
|
|||||||
mockSendRequestInvite,
|
mockSendRequestInvite,
|
||||||
mockSendInvite,
|
mockSendInvite,
|
||||||
mockSelfInvite,
|
mockSelfInvite,
|
||||||
mockQueryFetch
|
mockQueryFetch,
|
||||||
|
mockShowUserDialog,
|
||||||
|
mockCheckCanInvite,
|
||||||
|
mockCheckCanInviteSelf,
|
||||||
|
mockUserStatusClass,
|
||||||
|
mockUserImage,
|
||||||
|
mockToastSuccess,
|
||||||
|
mockToastError,
|
||||||
|
mockToastDismiss
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockSendRequestInvite: vi.fn().mockResolvedValue({}),
|
mockSendRequestInvite: vi.fn().mockResolvedValue({}),
|
||||||
mockSendInvite: vi.fn().mockResolvedValue({}),
|
mockSendInvite: vi.fn().mockResolvedValue({}),
|
||||||
mockSelfInvite: 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', () => {
|
vi.mock('../../../../api', () => {
|
||||||
@@ -172,8 +216,10 @@ const stubs = {
|
|||||||
template: '<hr data-testid="context-menu-separator" />'
|
template: '<hr data-testid="context-menu-separator" />'
|
||||||
},
|
},
|
||||||
Card: {
|
Card: {
|
||||||
template: '<div data-testid="card"><slot /></div>',
|
template:
|
||||||
props: ['class', 'style']
|
'<div data-testid="card" v-bind="$attrs" @click="$emit(\'click\')"><slot /></div>',
|
||||||
|
props: ['class', 'style'],
|
||||||
|
emits: ['click']
|
||||||
},
|
},
|
||||||
Avatar: { template: '<div><slot /></div>', props: ['class', 'style'] },
|
Avatar: { template: '<div><slot /></div>', props: ['class', 'style'] },
|
||||||
AvatarImage: { template: '<img />', props: ['src'] },
|
AvatarImage: { template: '<img />', props: ['src'] },
|
||||||
@@ -271,9 +317,25 @@ function getMenuItemTexts(wrapper) {
|
|||||||
return getMenuItems(wrapper).map((item) => item.text().trim());
|
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', () => {
|
describe('FriendsLocationsCard.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockCheckCanInvite.mockReturnValue(true);
|
||||||
|
mockCheckCanInviteSelf.mockReturnValue(true);
|
||||||
|
mockUserStatusClass.mockReturnValue({
|
||||||
|
online: true,
|
||||||
|
joinme: false,
|
||||||
|
active: false
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('basic rendering', () => {
|
describe('basic rendering', () => {
|
||||||
@@ -294,6 +356,13 @@ describe('FriendsLocationsCard.vue', () => {
|
|||||||
expect(wrapper.text()).toContain('A');
|
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', () => {
|
test('hides location when displayInstanceInfo is false', () => {
|
||||||
const wrapper = mountCard({ displayInstanceInfo: false });
|
const wrapper = mountCard({ displayInstanceInfo: false });
|
||||||
expect(wrapper.find('.location-stub').exists()).toBe(false);
|
expect(wrapper.find('.location-stub').exists()).toBe(false);
|
||||||
@@ -402,6 +471,27 @@ describe('FriendsLocationsCard.vue', () => {
|
|||||||
wrapper.find('[data-testid="context-menu-separator"]').exists()
|
wrapper.find('[data-testid="context-menu-separator"]').exists()
|
||||||
).toBe(false);
|
).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', () => {
|
describe('context menu disabled states', () => {
|
||||||
@@ -433,9 +523,7 @@ describe('FriendsLocationsCard.vue', () => {
|
|||||||
const wrapper = mountCard({
|
const wrapper = mountCard({
|
||||||
friend: makeFriend({ state: 'online' })
|
friend: makeFriend({ state: 'online' })
|
||||||
});
|
});
|
||||||
const requestInviteItem = getMenuItems(wrapper).find(
|
const requestInviteItem = getMenuItemByText(wrapper, 'Request Invite');
|
||||||
(item) => item.text().trim() === 'Request Invite'
|
|
||||||
);
|
|
||||||
await requestInviteItem.trigger('click');
|
await requestInviteItem.trigger('click');
|
||||||
expect(mockSendRequestInvite).toHaveBeenCalledWith(
|
expect(mockSendRequestInvite).toHaveBeenCalledWith(
|
||||||
{ platform: 'standalonewindows' },
|
{ 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 () => {
|
test('friendInviteSelf calls selfInvite API', async () => {
|
||||||
const wrapper = mountCard({
|
const wrapper = mountCard({
|
||||||
friend: makeFriend({
|
friend: makeFriend({
|
||||||
@@ -450,14 +560,46 @@ describe('FriendsLocationsCard.vue', () => {
|
|||||||
ref: { location: 'wrld_12345:67890~region(us)' }
|
ref: { location: 'wrld_12345:67890~region(us)' }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const selfInviteItem = getMenuItems(wrapper).find(
|
const selfInviteItem = getMenuItemByText(wrapper, 'Invite Yourself');
|
||||||
(item) => item.text().trim() === 'Invite Yourself'
|
|
||||||
);
|
|
||||||
await selfInviteItem.trigger('click');
|
await selfInviteItem.trigger('click');
|
||||||
expect(mockSelfInvite).toHaveBeenCalledWith({
|
expect(mockSelfInvite).toHaveBeenCalledWith({
|
||||||
instanceId: '67890~region(us)',
|
instanceId: '67890~region(us)',
|
||||||
worldId: 'wrld_12345'
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -781,7 +781,7 @@
|
|||||||
* @param row
|
* @param row
|
||||||
*/
|
*/
|
||||||
function handleRowClick(row) {
|
function handleRowClick(row) {
|
||||||
handleWearAvatar(row.original.id);
|
handleShowAvatarDialog(row.original.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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: '<div><slot /></div>'
|
||||||
|
},
|
||||||
|
DialogContent: { template: '<div><slot /></div>' },
|
||||||
|
DialogDescription: { template: '<div><slot /></div>' },
|
||||||
|
DialogFooter: { template: '<div><slot /></div>' },
|
||||||
|
DialogHeader: { template: '<div><slot /></div>' },
|
||||||
|
DialogTitle: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/popover', () => ({
|
||||||
|
Popover: { template: '<div><slot /></div>' },
|
||||||
|
PopoverContent: { template: '<div><slot /></div>' },
|
||||||
|
PopoverTrigger: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/button', () => ({
|
||||||
|
Button: {
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button data-testid="button" @click="$emit(\'click\')"><slot /></button>'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tags-input', () => ({
|
||||||
|
TagsInput: {
|
||||||
|
props: ['modelValue'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: '<div><slot /></div>'
|
||||||
|
},
|
||||||
|
TagsInputInput: {
|
||||||
|
template: '<input data-testid="tags-input" />'
|
||||||
|
},
|
||||||
|
TagsInputItem: {
|
||||||
|
props: ['value'],
|
||||||
|
template: '<span data-testid="tag-item"><slot />{{ value }}</span>'
|
||||||
|
},
|
||||||
|
TagsInputItemDelete: { template: '<span>x</span>' },
|
||||||
|
TagsInputItemText: { template: '<span />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '<div><slot /></div>' },
|
||||||
|
ContextMenuItem: { template: '<button><slot /></button>' },
|
||||||
|
ContextMenuSeparator: { template: '<hr />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/dropdown-menu', () => ({
|
||||||
|
DropdownMenu: { template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuTrigger: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/field', () => ({
|
||||||
|
Field: { template: '<div><slot /></div>' },
|
||||||
|
FieldContent: { template: '<div><slot /></div>' },
|
||||||
|
FieldLabel: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/popover', () => ({
|
||||||
|
Popover: { template: '<div><slot /></div>' },
|
||||||
|
PopoverContent: { template: '<div><slot /></div>' },
|
||||||
|
PopoverTrigger: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/data-table', () => ({
|
||||||
|
DataTableEmpty: { template: '<div data-testid="empty">empty</div>' },
|
||||||
|
DataTableLayout: { template: '<div data-testid="table-layout">table</div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/toggle-group', () => ({
|
||||||
|
ToggleGroup: {
|
||||||
|
emits: ['update:model-value'],
|
||||||
|
template:
|
||||||
|
'<div data-testid="toggle-group">' +
|
||||||
|
'<button data-testid="set-table" @click="$emit(\'update:model-value\', \'table\')">table</button>' +
|
||||||
|
'<slot />' +
|
||||||
|
'</div>'
|
||||||
|
},
|
||||||
|
ToggleGroupItem: { template: '<button><slot /></button>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/badge', () => ({
|
||||||
|
Badge: { template: '<span><slot /></span>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/button', () => ({
|
||||||
|
Button: {
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button data-testid="button" @click="$emit(\'click\')"><slot /></button>'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/input', () => ({
|
||||||
|
Input: {
|
||||||
|
props: ['modelValue'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template:
|
||||||
|
'<input data-testid="search-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/slider', () => ({
|
||||||
|
Slider: { template: '<div />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/ui/tooltip', () => ({
|
||||||
|
TooltipWrapper: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../components/dialogs/ImageCropDialog.vue', () => ({
|
||||||
|
default: { template: '<div />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ManageTagsDialog.vue', () => ({
|
||||||
|
default: { template: '<div />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/MyAvatarCard.vue', () => ({
|
||||||
|
default: {
|
||||||
|
props: ['avatar'],
|
||||||
|
emits: ['click', 'context-action'],
|
||||||
|
template: '<button data-testid="avatar-card" @click="$emit(\'click\')">{{ avatar.name }}</button>'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('lucide-vue-next', () => ({
|
||||||
|
Check: { template: '<i />' },
|
||||||
|
Eye: { template: '<i />' },
|
||||||
|
Image: { template: '<i />' },
|
||||||
|
LayoutGrid: { template: '<i />' },
|
||||||
|
List: { template: '<i />' },
|
||||||
|
ListFilter: { template: '<i />' },
|
||||||
|
Pencil: { template: '<i />' },
|
||||||
|
RefreshCw: { template: '<i />' },
|
||||||
|
Settings: { template: '<i />' },
|
||||||
|
Tag: { template: '<i />' },
|
||||||
|
User: { template: '<i />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,7 +76,7 @@ export function getColumns({
|
|||||||
'h-4 w-4',
|
'h-4 w-4',
|
||||||
isActive
|
isActive
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-muted-foreground/0 group-hover/row:text-muted-foreground'
|
: 'text-muted-foreground/0'
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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: '<div><slot /></div>' },
|
||||||
|
ContextMenuTrigger: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuContent: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuSeparator: { template: '<hr />' },
|
||||||
|
ContextMenuItem: {
|
||||||
|
props: ['disabled'],
|
||||||
|
emits: ['click'],
|
||||||
|
template:
|
||||||
|
'<button data-testid="ctx-item" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/hover-card', () => ({
|
||||||
|
HoverCard: {
|
||||||
|
props: ['open'],
|
||||||
|
emits: ['update:open'],
|
||||||
|
template: '<div><slot /></div>'
|
||||||
|
},
|
||||||
|
HoverCardTrigger: { template: '<div><slot /></div>' },
|
||||||
|
HoverCardContent: { template: '<div><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/badge', () => ({
|
||||||
|
Badge: { template: '<span data-testid="badge"><slot /></span>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/button', () => ({
|
||||||
|
Button: {
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button data-testid="button" @click="$emit(\'click\')"><slot /></button>'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/card', () => ({
|
||||||
|
Card: { template: '<div data-testid="card"><slot /></div>' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/separator', () => ({
|
||||||
|
Separator: { template: '<hr />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('lucide-vue-next', () => ({
|
||||||
|
Apple: { template: '<i />' },
|
||||||
|
Check: { template: '<i />' },
|
||||||
|
ExternalLink: { template: '<i />' },
|
||||||
|
Eye: { template: '<i />' },
|
||||||
|
Image: { template: '<i />' },
|
||||||
|
Monitor: { template: '<i />' },
|
||||||
|
Pencil: { template: '<i />' },
|
||||||
|
RefreshCw: { template: '<i />' },
|
||||||
|
Smartphone: { template: '<i />' },
|
||||||
|
Tag: { template: '<i />' },
|
||||||
|
User: { template: '<i />' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-87
@@ -1,17 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="chart" class="x-container">
|
<div id="chart" class="x-container">
|
||||||
<div class="options-container mt-0">
|
<div class="options-container">
|
||||||
<span class="header">{{ t('view.tools.header') }}</span>
|
<span class="header">{{ t('view.tools.header') }}</span>
|
||||||
|
|
||||||
<div class="tool-categories">
|
<div class="mt-5 px-5">
|
||||||
<div class="tool-category">
|
<div class="mb-6">
|
||||||
<div class="category-header text-2xl" @click="toggleCategory('image')">
|
<div
|
||||||
|
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
|
||||||
|
@click="toggleCategory('image')">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="rotation-transition"
|
class="text-sm mr-2 transition-transform duration-300"
|
||||||
:class="{ 'is-rotated': categoryCollapsed['image'] }" />
|
:class="{ '-rotate-90': categoryCollapsed['image'] }" />
|
||||||
<span class="category-title">{{ t('view.tools.pictures.header') }}</span>
|
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.pictures.header') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tools-grid" v-show="!categoryCollapsed['image']">
|
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['image']">
|
||||||
<ToolItem
|
<ToolItem
|
||||||
:icon="Camera"
|
:icon="Camera"
|
||||||
:title="t('view.tools.pictures.screenshot')"
|
:title="t('view.tools.pictures.screenshot')"
|
||||||
@@ -25,14 +27,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-category">
|
<div class="mb-6">
|
||||||
<div class="category-header" @click="toggleCategory('shortcuts')">
|
<div
|
||||||
|
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
|
||||||
|
@click="toggleCategory('shortcuts')">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="rotation-transition"
|
class="text-sm mr-2 transition-transform duration-300"
|
||||||
:class="{ 'is-rotated': categoryCollapsed['shortcuts'] }" />
|
:class="{ '-rotate-90': categoryCollapsed['shortcuts'] }" />
|
||||||
<span class="category-title">{{ t('view.tools.shortcuts.header') }}</span>
|
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.shortcuts.header') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tools-grid" v-show="!categoryCollapsed['shortcuts']">
|
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['shortcuts']">
|
||||||
<ToolItem
|
<ToolItem
|
||||||
:icon="Folder"
|
:icon="Folder"
|
||||||
:title="t('view.tools.pictures.pictures.vrc_photos')"
|
:title="t('view.tools.pictures.pictures.vrc_photos')"
|
||||||
@@ -61,14 +65,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-category">
|
<div class="mb-6">
|
||||||
<div class="category-header" @click="toggleCategory('system')">
|
<div
|
||||||
|
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
|
||||||
|
@click="toggleCategory('system')">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="rotation-transition"
|
class="text-sm mr-2 transition-transform duration-300"
|
||||||
:class="{ 'is-rotated': categoryCollapsed['system'] }" />
|
:class="{ '-rotate-90': categoryCollapsed['system'] }" />
|
||||||
<span class="category-title">{{ t('view.tools.system_tools.header') }}</span>
|
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.system_tools.header') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tools-grid" v-show="!categoryCollapsed['system']">
|
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['system']">
|
||||||
<ToolItem
|
<ToolItem
|
||||||
:icon="Settings"
|
:icon="Settings"
|
||||||
:title="t('view.tools.system_tools.vrchat_config')"
|
:title="t('view.tools.system_tools.vrchat_config')"
|
||||||
@@ -92,14 +98,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-category">
|
<div class="mb-6">
|
||||||
<div class="category-header" @click="toggleCategory('group')">
|
<div
|
||||||
|
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
|
||||||
|
@click="toggleCategory('group')">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="rotation-transition"
|
class="text-sm mr-2 transition-transform duration-300"
|
||||||
:class="{ 'is-rotated': categoryCollapsed['group'] }" />
|
:class="{ '-rotate-90': categoryCollapsed['group'] }" />
|
||||||
<span class="category-title">{{ t('view.tools.group.header') }}</span>
|
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.group.header') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tools-grid" v-show="!categoryCollapsed['group']">
|
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['group']">
|
||||||
<ToolItem
|
<ToolItem
|
||||||
:icon="CalendarDays"
|
:icon="CalendarDays"
|
||||||
:title="t('view.tools.group.calendar')"
|
:title="t('view.tools.group.calendar')"
|
||||||
@@ -108,13 +116,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-category">
|
<div class="mb-6">
|
||||||
<div class="category-header text-2xl" @click="toggleCategory('user')">
|
<div
|
||||||
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': categoryCollapsed['user'] }" />
|
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
|
||||||
<span class="category-title">{{ t('view.tools.export.header') }}</span>
|
@click="toggleCategory('user')">
|
||||||
|
<ChevronDown
|
||||||
|
class="text-sm mr-2 transition-transform duration-300"
|
||||||
|
:class="{ '-rotate-90': categoryCollapsed['user'] }" />
|
||||||
|
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.export.header') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tools-grid" v-show="!categoryCollapsed['user']">
|
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['user']">
|
||||||
<ToolItem
|
<ToolItem
|
||||||
:icon="FolderInput"
|
:icon="FolderInput"
|
||||||
:title="t('view.tools.export.discord_names')"
|
:title="t('view.tools.export.discord_names')"
|
||||||
@@ -138,14 +150,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-category">
|
<div class="mb-6">
|
||||||
<div class="category-header" @click="toggleCategory('other')">
|
<div
|
||||||
|
class="cursor-pointer flex items-center p-2 px-3 rounded-lg mb-3 transition-all duration-200 ease-in-out"
|
||||||
|
@click="toggleCategory('other')">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="rotation-transition"
|
class="text-sm mr-2 transition-transform duration-300"
|
||||||
:class="{ 'is-rotated': categoryCollapsed['other'] }" />
|
:class="{ '-rotate-90': categoryCollapsed['other'] }" />
|
||||||
<span class="category-title">{{ t('view.tools.other.header') }}</span>
|
<span class="ml-1.5 text-base font-semibold">{{ t('view.tools.other.header') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tools-grid" v-show="!categoryCollapsed['other']">
|
<div class="grid grid-cols-2 gap-4 ml-4" v-show="!categoryCollapsed['other']">
|
||||||
<ToolItem
|
<ToolItem
|
||||||
:icon="SquarePen"
|
:icon="SquarePen"
|
||||||
:title="t('view.tools.other.edit_invite_message')"
|
:title="t('view.tools.other.edit_invite_message')"
|
||||||
@@ -367,53 +381,3 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tool-categories {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-category {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.category-header {
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
.rotation-transition {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-right: 8px;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-title {
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tools-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.is-rotated {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotation-transition {
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ const showRegistryBackupDialog = vi.fn();
|
|||||||
const getString = vi.fn();
|
const getString = vi.fn();
|
||||||
const setString = vi.fn();
|
const setString = vi.fn();
|
||||||
const friends = ref([]);
|
const friends = ref([]);
|
||||||
|
let routeName = 'not-tools';
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRouter: () => ({ push }),
|
useRouter: () => ({ push }),
|
||||||
useRoute: () => ({ name: 'not-tools' })
|
useRoute: () => ({
|
||||||
|
get name() {
|
||||||
|
return routeName;
|
||||||
|
}
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
@@ -58,9 +63,22 @@ vi.mock('../dialogs/AutoChangeStatusDialog.vue', () => ({
|
|||||||
|
|
||||||
import Tools from '../Tools.vue';
|
import Tools from '../Tools.vue';
|
||||||
|
|
||||||
|
function findToolItemByTitle(wrapper, titleKey) {
|
||||||
|
return wrapper
|
||||||
|
.findAllComponents({ name: 'ToolItem' })
|
||||||
|
.find((component) => component.text().includes(titleKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCategoryHeaderByTitle(wrapper, titleKey) {
|
||||||
|
return wrapper
|
||||||
|
.findAll('div.cursor-pointer')
|
||||||
|
.find((node) => node.text().includes(titleKey));
|
||||||
|
}
|
||||||
|
|
||||||
describe('Tools.vue', () => {
|
describe('Tools.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
routeName = 'not-tools';
|
||||||
getString.mockResolvedValue('{}');
|
getString.mockResolvedValue('{}');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +86,12 @@ describe('Tools.vue', () => {
|
|||||||
const wrapper = mount(Tools);
|
const wrapper = mount(Tools);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const screenshotItem = wrapper.findAllComponents({ name: 'ToolItem' })[0];
|
const screenshotItem = findToolItemByTitle(
|
||||||
|
wrapper,
|
||||||
|
'view.tools.pictures.screenshot'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screenshotItem).toBeTruthy();
|
||||||
await screenshotItem.trigger('click');
|
await screenshotItem.trigger('click');
|
||||||
|
|
||||||
expect(push).toHaveBeenCalledWith({ name: 'screenshot-metadata' });
|
expect(push).toHaveBeenCalledWith({ name: 'screenshot-metadata' });
|
||||||
@@ -78,7 +101,12 @@ describe('Tools.vue', () => {
|
|||||||
const wrapper = mount(Tools);
|
const wrapper = mount(Tools);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const inventoryItem = wrapper.findAllComponents({ name: 'ToolItem' })[1];
|
const inventoryItem = findToolItemByTitle(
|
||||||
|
wrapper,
|
||||||
|
'view.tools.pictures.inventory'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inventoryItem).toBeTruthy();
|
||||||
await inventoryItem.trigger('click');
|
await inventoryItem.trigger('click');
|
||||||
|
|
||||||
expect(showGalleryPage).toHaveBeenCalled();
|
expect(showGalleryPage).toHaveBeenCalled();
|
||||||
@@ -88,12 +116,37 @@ describe('Tools.vue', () => {
|
|||||||
const wrapper = mount(Tools);
|
const wrapper = mount(Tools);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const firstCategoryHeader = wrapper.find('.category-header');
|
const imageCategoryHeader = findCategoryHeaderByTitle(
|
||||||
await firstCategoryHeader.trigger('click');
|
wrapper,
|
||||||
|
'view.tools.pictures.header'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imageCategoryHeader).toBeTruthy();
|
||||||
|
await imageCategoryHeader.trigger('click');
|
||||||
|
|
||||||
expect(setString).toHaveBeenCalledWith(
|
expect(setString).toHaveBeenCalledWith(
|
||||||
'VRCX_toolsCategoryCollapsed',
|
'VRCX_toolsCategoryCollapsed',
|
||||||
expect.any(String)
|
expect.stringContaining('"image":true')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads stored collapsed state before toggling category', async () => {
|
||||||
|
getString.mockResolvedValue('{"image":true}');
|
||||||
|
|
||||||
|
const wrapper = mount(Tools);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const imageCategoryHeader = findCategoryHeaderByTitle(
|
||||||
|
wrapper,
|
||||||
|
'view.tools.pictures.header'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imageCategoryHeader).toBeTruthy();
|
||||||
|
await imageCategoryHeader.trigger('click');
|
||||||
|
|
||||||
|
expect(setString).toHaveBeenCalledWith(
|
||||||
|
'VRCX_toolsCategoryCollapsed',
|
||||||
|
expect.stringContaining('"image":false')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Item variant="outline" class="cursor-pointer hover:bg-accent/50">
|
<Item variant="outline" class="cursor-pointer hover:bg-accent/50">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon" class="bg-transparent border-0">
|
||||||
<component :is="icon" />
|
<component :is="icon" class="text-2xl" />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{{ title }}</ItemTitle>
|
<ItemTitle>{{ title }}</ItemTitle>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { defineComponent, markRaw } from 'vue';
|
import { defineComponent, markRaw } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
@@ -22,4 +22,23 @@ describe('ToolItem.vue', () => {
|
|||||||
expect(wrapper.text()).toContain('Test title');
|
expect(wrapper.text()).toContain('Test title');
|
||||||
expect(wrapper.text()).toContain('Test description');
|
expect(wrapper.text()).toContain('Test description');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forwards click handler from parent', async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
|
||||||
|
const wrapper = mount(ToolItem, {
|
||||||
|
props: {
|
||||||
|
icon: markRaw(defineComponent({ template: '<svg />' })),
|
||||||
|
title: 'Clickable title',
|
||||||
|
description: 'Clickable description'
|
||||||
|
},
|
||||||
|
attrs: {
|
||||||
|
onClick
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.trigger('click');
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user