This commit is contained in:
pa
2026-03-16 16:01:07 +09:00
parent 4c6f80277e
commit fadead9c80
24 changed files with 183 additions and 92 deletions

View File

@@ -211,6 +211,7 @@
:step="1" :step="1"
:format-options="{ maximumFractionDigits: 0 }" :format-options="{ maximumFractionDigits: 0 }"
class="w-20" class="w-20"
@click.stop
@update:modelValue="setZoomLevel"> @update:modelValue="setZoomLevel">
<NumberFieldContent> <NumberFieldContent>
<NumberFieldDecrement /> <NumberFieldDecrement />

View File

@@ -236,7 +236,7 @@ describe('StatusBar.vue - Servers indicator', () => {
expect(wrapper.text()).toContain('Servers'); expect(wrapper.text()).toContain('Servers');
const serversDots = wrapper.findAll('.bg-status-online'); const serversDots = wrapper.findAll('.bg-status-online');
expect(serversDots.length).toBeGreaterThan(0); expect(serversDots.length).toBeGreaterThan(0);
expect(wrapper.find('.bg-\\[\\#e6a23c\\]').exists()).toBe(false); expect(wrapper.find('.bg-status-askme').exists()).toBe(false);
}); });
test('shows Servers indicator with yellow dot when there is an issue', () => { test('shows Servers indicator with yellow dot when there is an issue', () => {
@@ -246,7 +246,7 @@ describe('StatusBar.vue - Servers indicator', () => {
} }
}); });
expect(wrapper.text()).toContain('Servers'); expect(wrapper.text()).toContain('Servers');
expect(wrapper.find('.bg-\\[\\#e6a23c\\]').exists()).toBe(true); expect(wrapper.find('.bg-status-askme').exists()).toBe(true);
}); });
test('shows HoverCard content with status text when there is an issue', () => { test('shows HoverCard content with status text when there is an issue', () => {

View File

@@ -1,30 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick, ref } from 'vue';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
timeToText: vi.fn((ms) => `${ms}ms`) timeToText: vi.fn((ms) => `${ms}ms`),
nowRef: null
})); }));
vi.mock('../../shared/utils', () => ({ vi.mock('../../shared/utils', () => ({
timeToText: (...args) => mocks.timeToText(...args) timeToText: (...args) => mocks.timeToText(...args)
})); }));
vi.mock('@vueuse/core', () => ({
useNow: () => mocks.nowRef
}));
import Timer from '../Timer.vue'; import Timer from '../Timer.vue';
describe('Timer.vue', () => { describe('Timer.vue', () => {
let intervalCallback;
beforeEach(() => { beforeEach(() => {
intervalCallback = null;
mocks.timeToText.mockClear(); mocks.timeToText.mockClear();
mocks.nowRef = ref(10000);
vi.spyOn(globalThis, 'setInterval').mockImplementation((cb) => {
intervalCallback = cb;
return 99;
});
vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {});
vi.spyOn(Date, 'now').mockReturnValue(10000);
}); });
afterEach(() => { afterEach(() => {
@@ -42,15 +38,14 @@ describe('Timer.vue', () => {
expect(mocks.timeToText).toHaveBeenCalledWith(6000); expect(mocks.timeToText).toHaveBeenCalledWith(6000);
}); });
it('updates text when interval callback runs', async () => { it('updates text when now value changes', async () => {
const wrapper = mount(Timer, { const wrapper = mount(Timer, {
props: { props: {
epoch: 4000 epoch: 4000
} }
}); });
vi.mocked(Date.now).mockReturnValue(13000); mocks.nowRef.value = 13000;
intervalCallback?.();
await nextTick(); await nextTick();
expect(wrapper.text()).toBe('9000ms'); expect(wrapper.text()).toBe('9000ms');
@@ -66,15 +61,15 @@ describe('Timer.vue', () => {
expect(wrapper.text()).toBe('-'); expect(wrapper.text()).toBe('-');
}); });
it('clears interval on unmount', () => { it('computes correct elapsed time', () => {
mocks.nowRef.value = 20000;
const wrapper = mount(Timer, { const wrapper = mount(Timer, {
props: { props: {
epoch: 1 epoch: 5000
} }
}); });
wrapper.unmount(); expect(wrapper.text()).toBe('15000ms');
expect(mocks.timeToText).toHaveBeenCalledWith(15000);
expect(clearInterval).toHaveBeenCalledWith(99);
}); });
}); });

View File

@@ -38,6 +38,10 @@ vi.mock('../../../../coordinators/imageUploadCoordinator', () => ({
uploadImageLegacy: vi.fn() uploadImageLegacy: vi.fn()
})); }));
vi.mock('../../../../coordinators/avatarCoordinator', () => ({
removeAvatarFromCache: vi.fn()
}));
const { copyToClipboard, openExternalLink } = const { copyToClipboard, openExternalLink } =
await import('../../../../shared/utils'); await import('../../../../shared/utils');
const { favoriteRequest, avatarRequest, avatarModerationRequest } = const { favoriteRequest, avatarRequest, avatarModerationRequest } =

View File

@@ -27,11 +27,15 @@ vi.mock('../../../../stores', () => ({
vi.mock('../../../../composables/useInviteChecks', () => ({ vi.mock('../../../../composables/useInviteChecks', () => ({
useInviteChecks: () => ({ checkCanInvite: () => true }) useInviteChecks: () => ({ checkCanInvite: () => true })
})); }));
vi.mock('../../../../composables/useRecentActions', () => ({
isActionRecent: () => false
}));
vi.mock('../../../ui/dropdown-menu', () => ({ vi.mock('../../../ui/dropdown-menu', () => ({
DropdownMenu: { template: '<div><slot /></div>' }, DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' }, DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' }, DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<hr />' }, DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuShortcut: { template: '<span><slot /></span>' },
DropdownMenuItem: { DropdownMenuItem: {
emits: ['click'], emits: ['click'],
template: template:
@@ -51,6 +55,7 @@ vi.mock('../../../ui/tooltip', () => ({
vi.mock('lucide-vue-next', () => ({ vi.mock('lucide-vue-next', () => ({
Check: { template: '<i />' }, Check: { template: '<i />' },
CheckCircle: { template: '<i />' }, CheckCircle: { template: '<i />' },
Clock: { template: '<i />' },
Flag: { template: '<i />' }, Flag: { template: '<i />' },
LineChart: { template: '<i />' }, LineChart: { template: '<i />' },
Mail: { template: '<i />' }, Mail: { template: '<i />' },

View File

@@ -223,13 +223,13 @@ describe('UserDialogAvatarsTab.vue', () => {
expect(input.exists()).toBe(true); expect(input.exists()).toBe(true);
}); });
test('does not render search input for other users', () => { test('renders search input for other users too', () => {
const wrapper = mountComponent({ const wrapper = mountComponent({
id: 'usr_other', id: 'usr_other',
ref: { id: 'usr_other' } ref: { id: 'usr_other' }
}); });
const input = wrapper.find('input'); const input = wrapper.find('input');
expect(input.exists()).toBe(false); expect(input.exists()).toBe(true);
}); });
test('filters avatars by search query', async () => { test('filters avatars by search query', async () => {

View File

@@ -39,6 +39,13 @@ vi.mock('../../../../services/database', () => ({
} }
})); }));
vi.mock('../../../../composables/useRecentActions', () => ({
recordRecentAction: vi.fn(),
useRecentActions: () => ({
isRecentAction: vi.fn(() => false)
})
}));
// Import mocks after vi.mock // Import mocks after vi.mock
const { copyToClipboard } = await import('../../../../shared/utils'); const { copyToClipboard } = await import('../../../../shared/utils');
const { const {

View File

@@ -36,6 +36,10 @@ vi.mock('../../../../coordinators/imageUploadCoordinator', () => ({
uploadImageLegacy: vi.fn() uploadImageLegacy: vi.fn()
})); }));
vi.mock('../../../../coordinators/worldCoordinator', () => ({
removeWorldFromCache: vi.fn()
}));
const { favoriteRequest, miscRequest, userRequest, worldRequest } = const { favoriteRequest, miscRequest, userRequest, worldRequest } =
await import('../../../../api'); await import('../../../../api');
const { openExternalLink } = await import('../../../../shared/utils'); const { openExternalLink } = await import('../../../../shared/utils');

View File

@@ -84,7 +84,8 @@ describe('NavMenuFolderItem', () => {
hasNotifications: false, hasNotifications: false,
isEntryNotified: () => false, isEntryNotified: () => false,
isNavItemNotified: () => false, isNavItemNotified: () => false,
isDashboardItem: () => false isDashboardItem: () => false,
isToolItem: () => false
} }
}); });
@@ -103,7 +104,8 @@ describe('NavMenuFolderItem', () => {
hasNotifications: false, hasNotifications: false,
isEntryNotified: () => false, isEntryNotified: () => false,
isNavItemNotified: () => false, isNavItemNotified: () => false,
isDashboardItem: () => false isDashboardItem: () => false,
isToolItem: () => false
} }
}); });

View File

@@ -92,7 +92,9 @@ describe('useNavLayout', () => {
await applyCustomNavLayout(layout, []); await applyCustomNavLayout(layout, []);
await nextTick(); await nextTick();
expect(navLayout.value).toEqual(layout); expect(navLayout.value).toEqual(
expect.arrayContaining(layout)
);
expect(mocks.setString).toHaveBeenCalled(); expect(mocks.setString).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { computed, reactive, ref, shallowReactive, watch } from 'vue'; import { computed, reactive, ref, shallowReactive, watch } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { import {
compareByCreatedAt, compareByCreatedAt,
@@ -33,7 +33,7 @@ export const useUserStore = defineStore('User', () => {
const locationStore = useLocationStore(); const locationStore = useLocationStore();
const instanceStore = useInstanceStore(); const instanceStore = useInstanceStore();
const uiStore = useUiStore(); const uiStore = useUiStore();
const { t } = useI18n();
const currentUser = ref({ const currentUser = ref({
acceptedPrivacyVersion: 0, acceptedPrivacyVersion: 0,

View File

@@ -119,6 +119,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
vi.mock('lucide-vue-next', () => ({ vi.mock('lucide-vue-next', () => ({
AlertTriangle: { template: '<i />' }, AlertTriangle: { template: '<i />' },
Image: { template: '<i />' },
Lock: { template: '<i />' }, Lock: { template: '<i />' },
MoreHorizontal: { template: '<i />' }, MoreHorizontal: { template: '<i />' },
Trash2: { template: '<i />' } Trash2: { template: '<i />' }
@@ -207,7 +208,7 @@ describe('FavoritesAvatarItem.vue', () => {
).toContain('rounded-sm'); ).toContain('rounded-sm');
}); });
it('shows fallback text when thumbnail is missing', () => { it('shows fallback icon when thumbnail is missing', () => {
const wrapper = mountItem({ const wrapper = mountItem({
favorite: { favorite: {
id: 'avtr_no_thumb', id: 'avtr_no_thumb',
@@ -223,9 +224,9 @@ describe('FavoritesAvatarItem.vue', () => {
expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe( expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe(
false false
); );
expect(wrapper.get('[data-testid="avatar-fallback"]').text()).toContain( expect(
'B' wrapper.get('[data-testid="avatar-fallback"]').find('i').exists()
); ).toBe(true);
}); });
it('uses local delete flow for local favorites', async () => { it('uses local delete flow for local favorites', async () => {

View File

@@ -96,6 +96,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
})); }));
vi.mock('lucide-vue-next', () => ({ vi.mock('lucide-vue-next', () => ({
Image: { template: '<i />' },
MoreHorizontal: { template: '<i />' } MoreHorizontal: { template: '<i />' }
})); }));
@@ -173,7 +174,7 @@ describe('FavoritesAvatarLocalHistoryItem.vue', () => {
).toContain('rounded-sm'); ).toContain('rounded-sm');
}); });
it('shows fallback text when thumbnail is missing', () => { it('shows fallback icon when thumbnail is missing', () => {
const wrapper = mountItem({ const wrapper = mountItem({
favorite: { favorite: {
id: 'avtr_hist_no_thumb', id: 'avtr_hist_no_thumb',
@@ -185,9 +186,9 @@ describe('FavoritesAvatarLocalHistoryItem.vue', () => {
expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe( expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe(
false false
); );
expect(wrapper.get('[data-testid="avatar-fallback"]').text()).toContain( expect(
'C' wrapper.get('[data-testid="avatar-fallback"]').find('i').exists()
); ).toBe(true);
}); });
it('runs select-avatar action from menu', async () => { it('runs select-avatar action from menu', async () => {

View File

@@ -177,7 +177,8 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
vi.mock('lucide-vue-next', () => ({ vi.mock('lucide-vue-next', () => ({
MoreHorizontal: { template: '<i />' }, MoreHorizontal: { template: '<i />' },
Trash2: { template: '<i />' } Trash2: { template: '<i />' },
User: { template: '<i />' }
})); }));
import FavoritesFriendItem from '../FavoritesFriendItem.vue'; import FavoritesFriendItem from '../FavoritesFriendItem.vue';

View File

@@ -109,6 +109,7 @@ vi.mock('@/components/ui/checkbox', () => ({
vi.mock('lucide-vue-next', () => ({ vi.mock('lucide-vue-next', () => ({
AlertTriangle: { template: '<i />' }, AlertTriangle: { template: '<i />' },
Image: { template: '<i />' },
Lock: { template: '<i />' }, Lock: { template: '<i />' },
MoreHorizontal: { template: '<i />' } MoreHorizontal: { template: '<i />' }
})); }));

View File

@@ -196,6 +196,11 @@ const i18n = createI18n({
messages: { en } messages: { en }
}); });
vi.mock('lucide-vue-next', () => ({
Pencil: { template: '<span class="pencil-icon" />' },
User: { template: '<span class="user-icon" />' }
}));
// Stub all complex UI components — render slots transparently // Stub all complex UI components — render slots transparently
const stubs = { const stubs = {
ContextMenu: { template: '<div data-testid="context-menu"><slot /></div>' }, ContextMenu: { template: '<div data-testid="context-menu"><slot /></div>' },
@@ -229,6 +234,7 @@ const stubs = {
props: ['location', 'traveling', 'link', 'class'] props: ['location', 'traveling', 'link', 'class']
}, },
Pencil: { template: '<span class="pencil-icon" />', props: ['class'] }, Pencil: { template: '<span class="pencil-icon" />', props: ['class'] },
User: { template: '<span class="user-icon" />', props: ['class', 'size'] },
TooltipWrapper: { TooltipWrapper: {
template: '<span><slot /></span>', template: '<span><slot /></span>',
props: ['content', 'disabled', 'delayDuration', 'side'] props: ['content', 'disabled', 'delayDuration', 'side']
@@ -356,11 +362,11 @@ describe('FriendsLocationsCard.vue', () => {
expect(wrapper.text()).toContain('A'); expect(wrapper.text()).toContain('A');
}); });
test('shows ? as avatar fallback when name is empty', () => { test('shows user icon as avatar fallback when name is empty', () => {
const wrapper = mountCard({ const wrapper = mountCard({
friend: makeFriend({ name: undefined }) friend: makeFriend({ name: undefined })
}); });
expect(wrapper.text()).toContain('?'); expect(wrapper.find('.user-icon').exists()).toBe(true);
}); });
test('hides location when displayInstanceInfo is false', () => { test('hides location when displayInstanceInfo is false', () => {

View File

@@ -9,9 +9,13 @@ const mocks = vi.hoisted(() => ({
})); }));
vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s })); vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
vi.mock('vue-router', () => ({ vi.mock('vue-router', async (importOriginal) => {
useRouter: () => ({ replace: (...a) => mocks.replace(...a) }) const actual = await importOriginal();
})); return {
...actual,
useRouter: () => ({ replace: (...a) => mocks.replace(...a) })
};
});
vi.mock('../../../services/watchState', () => ({ vi.mock('../../../services/watchState', () => ({
watchState: { isLoggedIn: false } watchState: { isLoggedIn: false }
})); }));

View File

@@ -44,7 +44,14 @@ vi.mock('../../../stores', () => ({
photonLoggingEnabled: mocks.photonLoggingEnabled, photonLoggingEnabled: mocks.photonLoggingEnabled,
chatboxUserBlacklist: mocks.chatboxUserBlacklist, chatboxUserBlacklist: mocks.chatboxUserBlacklist,
saveChatboxUserBlacklist: (...args) => saveChatboxUserBlacklist: (...args) =>
mocks.saveChatboxUserBlacklist(...args) mocks.saveChatboxUserBlacklist(...args),
photonEventTable: ref({ data: [], pageSize: 10 }),
photonEventTablePrevious: ref({ data: [], pageSize: 10 }),
photonEventTableTypeFilter: ref([]),
photonEventTableFilter: ref(''),
photonEventIcon: ref(false),
photonEventTableFilterChange: vi.fn(),
showUserFromPhotonId: vi.fn()
}), }),
useUserStore: () => ({ useUserStore: () => ({
currentUser: mocks.currentUser currentUser: mocks.currentUser
@@ -65,6 +72,18 @@ vi.mock('../../../stores', () => ({
useGalleryStore: () => ({ useGalleryStore: () => ({
showFullscreenImageDialog: (...args) => showFullscreenImageDialog: (...args) =>
mocks.showFullscreenImageDialog(...args) mocks.showFullscreenImageDialog(...args)
}),
useSearchStore: () => ({
stringComparer: { value: (a, b) => a.localeCompare(b) }
}),
useAvatarStore: () => ({
showAvatarDialog: vi.fn()
}),
useGroupStore: () => ({
showGroupDialog: vi.fn()
}),
useVrcxStore: () => ({
ipcEnabled: ref(false)
}) })
})); }));
@@ -142,12 +161,14 @@ vi.mock('../dialogs/ChatboxBlacklistDialog.vue', () => ({
} }
})); }));
vi.mock('lucide-vue-next', () => ({ vi.mock('lucide-vue-next', async (importOriginal) => {
Apple: { template: '<span />' }, const actual = await importOriginal();
Home: { template: '<span />' }, const stubs = {};
Monitor: { template: '<span />' }, for (const key of Object.keys(actual)) {
Smartphone: { template: '<span />' } stubs[key] = { template: '<span />' };
})); }
return stubs;
});
import PlayerList from '../PlayerList.vue'; import PlayerList from '../PlayerList.vue';

View File

@@ -33,12 +33,23 @@ vi.mock('../../../../../stores', () => ({
}) })
})); }));
vi.mock('../../SimpleSwitch.vue', () => ({ vi.mock('@/components/ui/switch', () => ({
default: { Switch: {
props: ['label', 'disabled'], props: ['modelValue', 'disabled'],
emits: ['change'], emits: ['update:modelValue'],
template: template:
'<div data-testid="simple-switch" :data-label="label" :data-disabled="disabled"><button class="emit-change" @click="$emit(\'change\', true)" /></div>' '<button data-testid="switch" :data-disabled="disabled || undefined" @click="$emit(\'update:modelValue\', !modelValue)"><slot /></button>'
}
}));
vi.mock('../../SettingsGroup.vue', () => ({
default: { template: '<div><slot /><slot name="description" /></div>' }
}));
vi.mock('../../SettingsItem.vue', () => ({
default: {
props: ['label', 'description'],
template: '<div :data-label="label"><slot /></div>'
} }
})); }));
@@ -57,7 +68,7 @@ describe('DiscordPresenceTab.vue', () => {
const wrapper = mount(DiscordPresenceTab); const wrapper = mount(DiscordPresenceTab);
const tooltipRow = wrapper const tooltipRow = wrapper
.findAll('div.options-container-item') .findAll('p')
.find((node) => .find((node) =>
node node
.text() .text()
@@ -65,11 +76,13 @@ describe('DiscordPresenceTab.vue', () => {
'view.settings.discord_presence.discord_presence.enable_tooltip' 'view.settings.discord_presence.discord_presence.enable_tooltip'
) )
); );
expect(tooltipRow).toBeTruthy();
await tooltipRow.trigger('click'); await tooltipRow.trigger('click');
expect(mocks.showVRChatConfig).toHaveBeenCalledTimes(1); expect(mocks.showVRChatConfig).toHaveBeenCalledTimes(1);
await wrapper.findAll('.emit-change')[0].trigger('click'); const switches = wrapper.findAll('[data-testid="switch"]');
await switches[0].trigger('click');
expect(mocks.discordStore.setDiscordActive).toHaveBeenCalledTimes(1); expect(mocks.discordStore.setDiscordActive).toHaveBeenCalledTimes(1);
expect(mocks.discordStore.saveDiscordOption).toHaveBeenCalled(); expect(mocks.discordStore.saveDiscordOption).toHaveBeenCalled();
}); });
@@ -78,12 +91,9 @@ describe('DiscordPresenceTab.vue', () => {
mocks.discordStore.discordActive.value = false; mocks.discordStore.discordActive.value = false;
const wrapper = mount(DiscordPresenceTab); const wrapper = mount(DiscordPresenceTab);
const worldIntegration = wrapper const switches = wrapper.findAll('[data-testid="switch"]');
.findAll('[data-testid="simple-switch"]') const worldIntegrationSwitch = switches[1];
.find((node) =>
node.attributes('data-label')?.includes('world_integration')
);
expect(worldIntegration?.attributes('data-disabled')).toBe('true'); expect(worldIntegrationSwitch?.attributes('data-disabled')).toBe('true');
}); });
}); });

View File

@@ -53,6 +53,15 @@ vi.mock('@/components/ui/button', () => ({
} }
})); }));
vi.mock('@/components/ui/switch', () => ({
Switch: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template:
'<button data-testid="switch" :data-disabled="disabled || undefined" @click="$emit(\'update:modelValue\', !modelValue)"><slot /></button>'
}
}));
vi.mock('../../../../components/ui/radio-group', () => ({ vi.mock('../../../../components/ui/radio-group', () => ({
RadioGroup: { RadioGroup: {
props: ['modelValue', 'disabled'], props: ['modelValue', 'disabled'],
@@ -72,13 +81,12 @@ vi.mock('../../../../components/ui/toggle-group', () => ({
ToggleGroupItem: { template: '<div><slot /></div>' } ToggleGroupItem: { template: '<div><slot /></div>' }
})); }));
vi.mock('../SimpleSwitch.vue', () => ({ vi.mock('../SettingsGroup.vue', () => ({
default: { default: { template: '<div><slot /><slot name="description" /></div>' }
props: ['label'], }));
emits: ['change'],
template: vi.mock('../SettingsItem.vue', () => ({
'<div data-testid="simple-switch" :data-label="label"><button class="emit-change" @click="$emit(\'change\', true)" /></div>' default: { template: '<div><slot /></div>' }
}
})); }));
import WristOverlaySettings from '../WristOverlaySettings.vue'; import WristOverlaySettings from '../WristOverlaySettings.vue';
@@ -102,7 +110,8 @@ describe('WristOverlaySettings.vue', () => {
await wrapper.get('[data-testid="filters-btn"]').trigger('click'); await wrapper.get('[data-testid="filters-btn"]').trigger('click');
expect(wrapper.emitted('open-feed-filters')).toBeTruthy(); expect(wrapper.emitted('open-feed-filters')).toBeTruthy();
await wrapper.findAll('.emit-change')[0].trigger('click'); const switches = wrapper.findAll('[data-testid="switch"]');
await switches[0].trigger('click');
expect(mocks.notificationsStore.setOpenVR).toHaveBeenCalledTimes(1); expect(mocks.notificationsStore.setOpenVR).toHaveBeenCalledTimes(1);
expect(mocks.saveOpenVROption).toHaveBeenCalled(); expect(mocks.saveOpenVROption).toHaveBeenCalled();

View File

@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
appearanceStore: { appearanceStore: {
isSidebarGroupByInstance: { value: false }, isSidebarGroupByInstance: { value: false },
isHideFriendsInSameInstance: { value: false }, isHideFriendsInSameInstance: { value: false },
isSameInstanceAboveFavorites: { value: false },
isSidebarDivideByFriendGroup: { value: false }, isSidebarDivideByFriendGroup: { value: false },
sidebarFavoriteGroups: { value: [] }, sidebarFavoriteGroups: { value: [] },
sidebarFavoriteGroupOrder: { value: [] }, sidebarFavoriteGroupOrder: { value: [] },

View File

@@ -39,21 +39,22 @@ vi.mock('pinia', async (importOriginal) => {
vi.mock('../../../stores', () => ({ vi.mock('../../../stores', () => ({
useFriendStore: () => ({ friends }), useFriendStore: () => ({ friends }),
useGalleryStore: () => ({ showGalleryPage }) useGalleryStore: () => ({ showGalleryPage }),
})); useToolsStore: () => ({ openDialog: vi.fn() }),
useAdvancedSettingsStore: () => ({ showVRChatConfig }),
vi.mock('../../../stores/settings/advanced', () => ({ useLaunchStore: () => ({ showLaunchOptions }),
useAdvancedSettingsStore: () => ({ showVRChatConfig })
}));
vi.mock('../../../stores/launch', () => ({
useLaunchStore: () => ({ showLaunchOptions })
}));
vi.mock('../../../stores/vrcx', () => ({
useVrcxStore: () => ({ showRegistryBackupDialog }) useVrcxStore: () => ({ showRegistryBackupDialog })
})); }));
vi.mock('../../../composables/useToolNavPinning', () => ({
useToolNavPinning: () => ({
pinToolToNav: vi.fn(),
pinnedToolKeys: new Set(),
refreshPinnedState: vi.fn().mockResolvedValue(undefined),
unpinToolFromNav: vi.fn()
})
}));
vi.mock('../../../services/config.js', () => ({ vi.mock('../../../services/config.js', () => ({
default: { default: {
getString: (...args) => getString(...args), getString: (...args) => getString(...args),
@@ -65,6 +66,13 @@ vi.mock('../dialogs/AutoChangeStatusDialog.vue', () => ({
default: { template: '<div />' } default: { template: '<div />' }
})); }));
vi.mock('../../../components/ui/tooltip', () => ({
TooltipWrapper: {
template: '<div><slot /></div>',
props: ['content', 'disabled', 'side']
}
}));
import Tools from '../Tools.vue'; import Tools from '../Tools.vue';
function findToolItemByTitle(wrapper, titleKey) { function findToolItemByTitle(wrapper, titleKey) {
@@ -113,7 +121,7 @@ describe('Tools.vue', () => {
expect(galleryItem).toBeTruthy(); expect(galleryItem).toBeTruthy();
await galleryItem.trigger('click'); await galleryItem.trigger('click');
expect(showGalleryPage).toHaveBeenCalled(); expect(push).toHaveBeenCalledWith({ name: 'gallery' });
}); });
test('toggle category persists collapsed state', async () => { test('toggle category persists collapsed state', async () => {

View File

@@ -1,24 +1,19 @@
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { defineComponent, markRaw } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ToolItem from '../ToolItem.vue'; import ToolItem from '../ToolItem.vue';
describe('ToolItem.vue', () => { describe('ToolItem.vue', () => {
test('renders icon, title and description', () => { test('renders icon, title and description', () => {
const MockIcon = defineComponent({
template: '<svg data-test="mock-icon" />'
});
const wrapper = mount(ToolItem, { const wrapper = mount(ToolItem, {
props: { props: {
icon: markRaw(MockIcon), icon: 'ri-screenshot-line',
title: 'Test title', title: 'Test title',
description: 'Test description' description: 'Test description'
} }
}); });
expect(wrapper.find('[data-test="mock-icon"]').exists()).toBe(true); expect(wrapper.find('i.ri-screenshot-line').exists()).toBe(true);
expect(wrapper.text()).toContain('Test title'); expect(wrapper.text()).toContain('Test title');
expect(wrapper.text()).toContain('Test description'); expect(wrapper.text()).toContain('Test description');
}); });
@@ -28,7 +23,7 @@ describe('ToolItem.vue', () => {
const wrapper = mount(ToolItem, { const wrapper = mount(ToolItem, {
props: { props: {
icon: markRaw(defineComponent({ template: '<svg />' })), icon: 'ri-screenshot-line',
title: 'Clickable title', title: 'Clickable title',
description: 'Clickable description' description: 'Clickable description'
}, },

View File

@@ -49,6 +49,19 @@ Object.defineProperty(window, 'matchMedia', {
})) }))
}); });
// localStorage polyfill (jsdom may not provide a full implementation)
if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.clear !== 'function') {
const store = new Map();
globalThis.localStorage = {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
get length() { return store.size; },
key: (index) => [...store.keys()][index] ?? null
};
}
// Notification API stub // Notification API stub
globalThis.Notification = class { globalThis.Notification = class {
static permission = 'denied'; static permission = 'denied';