diff --git a/src/components/__tests__/BackToTop.test.js b/src/components/__tests__/BackToTop.test.js
new file mode 100644
index 00000000..5f88b058
--- /dev/null
+++ b/src/components/__tests__/BackToTop.test.js
@@ -0,0 +1,108 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: { template: '
' },
+ TooltipTrigger: { template: '
' },
+ TooltipContent: { template: '
' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ ArrowUp: { template: '' }
+}));
+
+import BackToTop from '../BackToTop.vue';
+
+function setScrollY(value) {
+ Object.defineProperty(window, 'scrollY', {
+ configurable: true,
+ value
+ });
+}
+
+describe('BackToTop.vue', () => {
+ beforeEach(() => {
+ setScrollY(0);
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('shows button after scroll threshold and scrolls window to top', async () => {
+ const wrapper = mount(BackToTop, {
+ props: {
+ visibilityHeight: 100,
+ teleport: false,
+ tooltip: false
+ }
+ });
+
+ expect(wrapper.find('[data-testid="back-btn"]').exists()).toBe(false);
+
+ setScrollY(120);
+ window.dispatchEvent(new Event('scroll'));
+ await nextTick();
+
+ const btn = wrapper.find('[data-testid="back-btn"]');
+ expect(btn.exists()).toBe(true);
+
+ await btn.trigger('click');
+
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
+
+ it('uses virtualizer scrollToIndex when provided', async () => {
+ const scrollToIndex = vi.fn();
+ const wrapper = mount(BackToTop, {
+ props: {
+ visibilityHeight: 0,
+ teleport: false,
+ tooltip: false,
+ virtualizer: { scrollToIndex }
+ }
+ });
+
+ window.dispatchEvent(new Event('scroll'));
+ await nextTick();
+
+ const btn = wrapper.get('[data-testid="back-btn"]');
+ await btn.trigger('click');
+
+ expect(scrollToIndex).toHaveBeenCalledWith(0, { align: 'start', behavior: 'auto' });
+ expect(window.scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('scrolls target element to top with auto behavior', async () => {
+ const target = document.createElement('div');
+ target.scrollTop = 200;
+ target.scrollTo = vi.fn();
+
+ const wrapper = mount(BackToTop, {
+ props: {
+ target,
+ behavior: 'auto',
+ visibilityHeight: 100,
+ teleport: false,
+ tooltip: false
+ }
+ });
+
+ target.dispatchEvent(new Event('scroll'));
+ await nextTick();
+
+ const btn = wrapper.get('[data-testid="back-btn"]');
+ await btn.trigger('click');
+
+ expect(target.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' });
+ });
+});
diff --git a/src/components/__tests__/CountdownTimer.test.js b/src/components/__tests__/CountdownTimer.test.js
new file mode 100644
index 00000000..d759d215
--- /dev/null
+++ b/src/components/__tests__/CountdownTimer.test.js
@@ -0,0 +1,76 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ setInterval: vi.fn(() => 42),
+ clearInterval: vi.fn(),
+ timeToText: vi.fn((ms) => `${Math.floor(ms / 1000)}s`)
+}));
+
+vi.mock('worker-timers', () => ({
+ setInterval: (...args) => mocks.setInterval(...args),
+ clearInterval: (...args) => mocks.clearInterval(...args)
+}));
+
+vi.mock('../../shared/utils', () => ({
+ timeToText: (...args) => mocks.timeToText(...args)
+}));
+
+import CountdownTimer from '../CountdownTimer.vue';
+
+describe('CountdownTimer.vue', () => {
+ beforeEach(() => {
+ mocks.setInterval.mockClear();
+ mocks.clearInterval.mockClear();
+ mocks.timeToText.mockClear();
+ vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-01-01T00:00:00.000Z').getTime());
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders remaining time on mount', async () => {
+ const wrapper = mount(CountdownTimer, {
+ props: {
+ datetime: '2025-12-31T23:30:00.000Z',
+ hours: 1
+ }
+ });
+ await nextTick();
+
+ expect(mocks.timeToText).toHaveBeenCalled();
+ expect(wrapper.text()).toContain('1800s');
+ });
+
+ it('renders dash when countdown expired', async () => {
+ const wrapper = mount(CountdownTimer, {
+ props: {
+ datetime: '2025-12-31T22:00:00.000Z',
+ hours: 1
+ }
+ });
+ await nextTick();
+
+ expect(wrapper.text()).toBe('-');
+
+ await wrapper.setProps({ datetime: '2025-12-31T23:59:30.000Z', hours: 0 });
+ await nextTick();
+ expect(wrapper.text()).toBe('-');
+ });
+
+ it('clears interval on unmount', () => {
+ const wrapper = mount(CountdownTimer, {
+ props: {
+ datetime: '2025-12-31T23:30:00.000Z',
+ hours: 1
+ }
+ });
+
+ wrapper.unmount();
+
+ expect(mocks.setInterval).toHaveBeenCalled();
+ expect(mocks.clearInterval).toHaveBeenCalledWith(42);
+ });
+});
diff --git a/src/components/__tests__/DeprecationAlert.test.js b/src/components/__tests__/DeprecationAlert.test.js
new file mode 100644
index 00000000..ff78ba82
--- /dev/null
+++ b/src/components/__tests__/DeprecationAlert.test.js
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ MessageSquareWarning: { template: '' }
+}));
+
+import DeprecationAlert from '../DeprecationAlert.vue';
+
+describe('DeprecationAlert.vue', () => {
+ it('renders relocated title and feature name', () => {
+ const wrapper = mount(DeprecationAlert, {
+ props: {
+ featureName: 'InstanceActionBar'
+ },
+ global: {
+ stubs: {
+ i18nT: {
+ template: ''
+ }
+ }
+ }
+ });
+
+ expect(wrapper.text()).toContain('common.feature_relocated.title');
+ expect(wrapper.text()).toContain('InstanceActionBar');
+ expect(wrapper.find('[data-testid="warn-icon"]').exists()).toBe(true);
+ });
+});
diff --git a/src/components/__tests__/DisplayName.test.js b/src/components/__tests__/DisplayName.test.js
new file mode 100644
index 00000000..17921daf
--- /dev/null
+++ b/src/components/__tests__/DisplayName.test.js
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ fetch: vi.fn(() => Promise.resolve({ json: { displayName: 'Fetched User' } })),
+ showUserDialog: vi.fn()
+}));
+
+vi.mock('../../api', () => ({
+ queryRequest: {
+ fetch: (...args) => mocks.fetch(...args)
+ }
+}));
+
+vi.mock('../../coordinators/userCoordinator', () => ({
+ showUserDialog: (...args) => mocks.showUserDialog(...args)
+}));
+
+import DisplayName from '../DisplayName.vue';
+
+async function flush() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('DisplayName.vue', () => {
+ beforeEach(() => {
+ mocks.fetch.mockClear();
+ mocks.showUserDialog.mockClear();
+ });
+
+ it('uses hint directly and skips user query', async () => {
+ const wrapper = mount(DisplayName, {
+ props: {
+ userid: 'usr_1',
+ hint: 'Hint Name'
+ }
+ });
+
+ await flush();
+
+ expect(wrapper.text()).toBe('Hint Name');
+ expect(mocks.fetch).not.toHaveBeenCalled();
+ });
+
+ it('fetches and renders display name when hint is missing', async () => {
+ const wrapper = mount(DisplayName, {
+ props: {
+ userid: 'usr_2'
+ }
+ });
+
+ await flush();
+
+ expect(mocks.fetch).toHaveBeenCalledWith('user.dialog', { userId: 'usr_2' });
+ expect(wrapper.text()).toBe('Fetched User');
+ });
+
+ it('opens user dialog when clicked', async () => {
+ const wrapper = mount(DisplayName, {
+ props: {
+ userid: 'usr_3',
+ hint: 'Clickable User'
+ }
+ });
+
+ await wrapper.trigger('click');
+
+ expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_3');
+ });
+});
diff --git a/src/components/__tests__/Emoji.test.js b/src/components/__tests__/Emoji.test.js
new file mode 100644
index 00000000..4ac3f474
--- /dev/null
+++ b/src/components/__tests__/Emoji.test.js
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ emojiTable: [],
+ getCachedEmoji: vi.fn(async () => ({
+ frames: null,
+ framesOverTime: null,
+ loopStyle: null,
+ versions: []
+ })),
+ extractFileId: vi.fn(() => 'file_1'),
+ generateEmojiStyle: vi.fn(() => 'background: red;')
+}));
+
+vi.mock('../../stores', () => ({
+ useGalleryStore: () => ({
+ getCachedEmoji: (...args) => mocks.getCachedEmoji(...args),
+ emojiTable: mocks.emojiTable
+ })
+}));
+
+vi.mock('../../shared/utils', () => ({
+ extractFileId: (...args) => mocks.extractFileId(...args),
+ generateEmojiStyle: (...args) => mocks.generateEmojiStyle(...args)
+}));
+
+vi.mock('../ui/avatar', () => ({
+ Avatar: { template: '
' },
+ AvatarImage: { props: ['src'], template: '
' },
+ AvatarFallback: { template: '' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ ImageOff: { template: '' }
+}));
+
+import Emoji from '../Emoji.vue';
+
+async function flush() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('Emoji.vue', () => {
+ beforeEach(() => {
+ mocks.emojiTable.length = 0;
+ mocks.getCachedEmoji.mockClear();
+ mocks.extractFileId.mockReturnValue('file_1');
+ mocks.generateEmojiStyle.mockClear();
+ });
+
+ it('renders animated div when emoji has frames in table', async () => {
+ mocks.emojiTable.push({
+ id: 'file_1',
+ frames: 4,
+ framesOverTime: 1,
+ loopStyle: 0,
+ versions: []
+ });
+
+ const wrapper = mount(Emoji, {
+ props: {
+ imageUrl: 'https://example.com/file_1.png',
+ size: 64
+ }
+ });
+
+ await flush();
+
+ const animated = wrapper.find('.avatar');
+ expect(animated.exists()).toBe(true);
+ expect(mocks.generateEmojiStyle).toHaveBeenCalled();
+ expect(animated.attributes('style')).toContain('background: red;');
+ expect(wrapper.find('[data-testid="avatar"]').exists()).toBe(false);
+ });
+
+ it('falls back to Avatar image when no frames', async () => {
+ const wrapper = mount(Emoji, {
+ props: {
+ imageUrl: 'https://example.com/file_2.png',
+ size: 48
+ }
+ });
+
+ await flush();
+
+ expect(mocks.getCachedEmoji).toHaveBeenCalledWith('file_1');
+ expect(wrapper.find('[data-testid="avatar"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="avatar-image"]').attributes('src')).toBe('https://example.com/file_2.png');
+ expect(wrapper.find('[data-testid="avatar-fallback"]').exists()).toBe(true);
+ });
+
+ it('updates when imageUrl changes', async () => {
+ const wrapper = mount(Emoji, {
+ props: {
+ imageUrl: 'https://example.com/a.png'
+ }
+ });
+
+ await flush();
+ mocks.extractFileId.mockReturnValue('file_2');
+
+ await wrapper.setProps({ imageUrl: 'https://example.com/b.png' });
+ await flush();
+
+ expect(mocks.getCachedEmoji).toHaveBeenCalledWith('file_1');
+ expect(mocks.getCachedEmoji).toHaveBeenCalledWith('file_2');
+ });
+});
diff --git a/src/components/__tests__/FullscreenImagePreview.test.js b/src/components/__tests__/FullscreenImagePreview.test.js
new file mode 100644
index 00000000..df09d1f0
--- /dev/null
+++ b/src/components/__tests__/FullscreenImagePreview.test.js
@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ dialog: { value: { visible: true, imageUrl: 'https://example.com/a.png', fileName: 'a.png' } }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('@/stores/settings/general', () => ({ useGeneralSettingsStore: () => ({ disableGpuAcceleration: { value: false } }) }));
+vi.mock('../../stores', () => ({ useGalleryStore: () => ({ fullscreenImageDialog: mocks.dialog, showFullscreenImageDialog: vi.fn() }) }));
+vi.mock('@/lib/modalPortalLayers', () => ({ acquireModalPortalLayer: () => ({ element: 'body', bringToFront: vi.fn(), release: vi.fn() }) }));
+vi.mock('@/lib/utils', () => ({ cn: (...a) => a.filter(Boolean).join(' ') }));
+vi.mock('../../shared/utils', () => ({ escapeTag: (s) => s, extractFileId: () => 'f1' }));
+vi.mock('vue-sonner', () => ({ toast: { info: vi.fn(() => 'id'), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' } }));
+vi.mock('reka-ui', () => ({ DialogPortal: { template: '
' }, DialogOverlay: { template: '
' }, DialogContent: { emits: ['click'], template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('lucide-vue-next', () => ({
+ Copy: { template: '' },
+ Download: { template: '' },
+ RefreshCcw: { template: '' },
+ RotateCcw: { template: '' },
+ RotateCw: { template: '' },
+ X: { template: '' },
+ ZoomIn: { template: '' },
+ ZoomOut: { template: '' }
+}));
+
+import FullscreenImagePreview from '../FullscreenImagePreview.vue';
+
+describe('FullscreenImagePreview.vue', () => {
+ it('closes dialog when close button clicked', async () => {
+ const wrapper = mount(FullscreenImagePreview);
+
+ await wrapper.get('button[aria-label="Close"]').trigger('click');
+
+ expect(mocks.dialog.value.visible).toBe(false);
+ });
+});
diff --git a/src/components/__tests__/GlobalSearchDialog.test.js b/src/components/__tests__/GlobalSearchDialog.test.js
new file mode 100644
index 00000000..edb4b81f
--- /dev/null
+++ b/src/components/__tests__/GlobalSearchDialog.test.js
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ selectResult: vi.fn(),
+ userImage: vi.fn(() => 'https://example.com/u.png'),
+ isOpen: { value: true },
+ query: { value: '' },
+ friendResults: { value: [] },
+ ownAvatarResults: { value: [] },
+ favoriteAvatarResults: { value: [] },
+ ownWorldResults: { value: [] },
+ favoriteWorldResults: { value: [] },
+ ownGroupResults: { value: [] },
+ joinedGroupResults: { value: [] },
+ hasResults: { value: false }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../stores/globalSearch', () => ({
+ useGlobalSearchStore: () => ({
+ isOpen: mocks.isOpen,
+ query: mocks.query,
+ friendResults: mocks.friendResults,
+ ownAvatarResults: mocks.ownAvatarResults,
+ favoriteAvatarResults: mocks.favoriteAvatarResults,
+ ownWorldResults: mocks.ownWorldResults,
+ favoriteWorldResults: mocks.favoriteWorldResults,
+ ownGroupResults: mocks.ownGroupResults,
+ joinedGroupResults: mocks.joinedGroupResults,
+ hasResults: mocks.hasResults,
+ selectResult: (...args) => mocks.selectResult(...args)
+ })
+}));
+vi.mock('../../composables/useUserDisplay', () => ({ useUserDisplay: () => ({ userImage: (...a) => mocks.userImage(...a) }) }));
+vi.mock('../GlobalSearchSync.vue', () => ({ default: { template: '' } }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogDescription: { template: '
' } }));
+vi.mock('@/components/ui/command', () => ({
+ Command: { template: '
' },
+ CommandInput: { template: '' },
+ CommandList: { template: '
' },
+ CommandGroup: { template: '
' },
+ CommandItem: { emits: ['select'], template: '' }
+}));
+vi.mock('lucide-vue-next', () => ({ Globe: { template: '' }, Image: { template: '' }, Users: { template: '' } }));
+
+import GlobalSearchDialog from '../GlobalSearchDialog.vue';
+
+describe('GlobalSearchDialog.vue', () => {
+ beforeEach(() => {
+ mocks.selectResult.mockClear();
+ mocks.query.value = '';
+ mocks.hasResults.value = false;
+ mocks.friendResults.value = [];
+ });
+
+ it('renders search dialog structure', () => {
+ const wrapper = mount(GlobalSearchDialog);
+ expect(wrapper.text()).toContain('side_panel.search_placeholder');
+ expect(wrapper.find('[data-testid="sync"]').exists()).toBe(true);
+ });
+});
diff --git a/src/components/__tests__/GlobalSearchSync.test.js b/src/components/__tests__/GlobalSearchSync.test.js
new file mode 100644
index 00000000..d8e483fe
--- /dev/null
+++ b/src/components/__tests__/GlobalSearchSync.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ setQuery: vi.fn(),
+ filterStateRaw: {
+ search: '',
+ filtered: {
+ items: new Map(),
+ count: 0,
+ groups: new Set()
+ }
+ },
+ filterState: null,
+ allItemsEntries: [['a', {}], ['b', {}]],
+ allGroupsEntries: [['g1', {}]]
+}));
+
+vi.mock('@/components/ui/command', async () => {
+ const { reactive, ref } = await import('vue');
+ const filterState = reactive(mocks.filterStateRaw);
+ mocks.filterState = filterState;
+ const allItems = ref(new Map(mocks.allItemsEntries));
+ const allGroups = ref(new Map(mocks.allGroupsEntries));
+ return {
+ useCommand: () => ({
+ filterState,
+ allItems,
+ allGroups
+ })
+ };
+});
+
+vi.mock('../../stores/globalSearch', () => ({
+ useGlobalSearchStore: () => ({
+ setQuery: (...args) => mocks.setQuery(...args)
+ })
+}));
+
+import GlobalSearchSync from '../GlobalSearchSync.vue';
+
+describe('GlobalSearchSync.vue', () => {
+ it('syncs query and keeps hint groups/items visible when query length < 2', async () => {
+ mount(GlobalSearchSync);
+
+ mocks.filterState.search = 'a';
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(mocks.setQuery).toHaveBeenCalledWith('a');
+ expect(mocks.filterState.filtered.count).toBe(2);
+ expect(mocks.filterState.filtered.items.get('a')).toBe(1);
+ expect(mocks.filterState.filtered.groups.has('g1')).toBe(true);
+ });
+});
diff --git a/src/components/__tests__/InstanceActionBar.test.js b/src/components/__tests__/InstanceActionBar.test.js
new file mode 100644
index 00000000..0736e9fa
--- /dev/null
+++ b/src/components/__tests__/InstanceActionBar.test.js
@@ -0,0 +1,329 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ checkCanInviteSelf: vi.fn(() => true),
+ parseLocation: vi.fn(() => ({ isRealInstance: true, instanceId: 'inst_1', worldId: 'wrld_1', tag: 'wrld_1:inst_1' })),
+ hasGroupPermission: vi.fn(() => false),
+ formatDateFilter: vi.fn(() => 'formatted-date'),
+ selfInvite: vi.fn(() => Promise.resolve({})),
+ closeInstance: vi.fn(() => Promise.resolve({ json: { id: 'inst_closed' } })),
+ showUserDialog: vi.fn(),
+ toastSuccess: vi.fn(),
+ applyInstance: vi.fn(),
+ showLaunchDialog: vi.fn(),
+ tryOpenInstanceInVrc: vi.fn(),
+ modalConfirm: vi.fn(() => Promise.resolve({ ok: true })),
+ instanceJoinHistory: { value: new Map() },
+ canOpenInstanceInGame: false,
+ isOpeningInstance: false,
+ lastLocation: { location: 'wrld_here:111', playerList: new Set(['u1', 'u2']) },
+ currentUser: { id: 'usr_me' },
+ cachedGroups: new Map()
+}));
+
+vi.mock('pinia', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ storeToRefs: (store) =>
+ Object.fromEntries(
+ Object.entries(store).map(([key, value]) => [
+ key,
+ key === 'instanceJoinHistory' ? value : value?.value ?? value
+ ])
+ )
+ };
+});
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('vue-sonner', () => ({
+ toast: {
+ success: (...args) => mocks.toastSuccess(...args)
+ }
+}));
+
+vi.mock('../../stores', () => ({
+ useLocationStore: () => ({
+ lastLocation: mocks.lastLocation
+ }),
+ useUserStore: () => ({
+ currentUser: mocks.currentUser
+ }),
+ useGroupStore: () => ({
+ cachedGroups: mocks.cachedGroups
+ }),
+ useInstanceStore: () => ({
+ instanceJoinHistory: mocks.instanceJoinHistory,
+ applyInstance: (...args) => mocks.applyInstance(...args)
+ }),
+ useModalStore: () => ({
+ confirm: (...args) => mocks.modalConfirm(...args)
+ }),
+ useLaunchStore: () => ({
+ isOpeningInstance: mocks.isOpeningInstance,
+ showLaunchDialog: (...args) => mocks.showLaunchDialog(...args),
+ tryOpenInstanceInVrc: (...args) => mocks.tryOpenInstanceInVrc(...args)
+ }),
+ useInviteStore: () => ({
+ canOpenInstanceInGame: mocks.canOpenInstanceInGame
+ })
+}));
+
+vi.mock('../../composables/useInviteChecks', () => ({
+ useInviteChecks: () => ({
+ checkCanInviteSelf: (...args) => mocks.checkCanInviteSelf(...args)
+ })
+}));
+
+vi.mock('../../shared/utils', () => ({
+ parseLocation: (...args) => mocks.parseLocation(...args),
+ hasGroupPermission: (...args) => mocks.hasGroupPermission(...args),
+ formatDateFilter: (...args) => mocks.formatDateFilter(...args)
+}));
+
+vi.mock('../../api', () => ({
+ instanceRequest: {
+ selfInvite: (...args) => mocks.selfInvite(...args)
+ },
+ miscRequest: {
+ closeInstance: (...args) => mocks.closeInstance(...args)
+ }
+}));
+
+vi.mock('../../coordinators/userCoordinator', () => ({
+ showUserDialog: (...args) => mocks.showUserDialog(...args)
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ History: { template: '' },
+ Loader2: { template: '' },
+ LogIn: { template: '' },
+ Mail: { template: '' },
+ MapPin: { template: '' },
+ RefreshCw: { template: '' },
+ UsersRound: { template: '' }
+}));
+
+import InstanceActionBar from '../InstanceActionBar.vue';
+
+function mountBar(props = {}) {
+ return mount(InstanceActionBar, {
+ props: {
+ location: 'wrld_base:111',
+ launchLocation: '',
+ inviteLocation: '',
+ lastJoinLocation: '',
+ instanceLocation: '',
+ shortname: 'sn',
+ instance: {
+ ownerId: 'usr_me',
+ capacity: 16,
+ userCount: 4,
+ hasCapacityForYou: true,
+ platforms: { standalonewindows: 1, android: 2, ios: 0 },
+ users: [{ id: 'usr_a', displayName: 'Alice' }],
+ gameServerVersion: 123,
+ $disabledContentSettings: []
+ },
+ friendcount: 2,
+ currentlocation: '',
+ showLaunch: true,
+ showInvite: true,
+ showRefresh: true,
+ showHistory: true,
+ showLastJoin: true,
+ showInstanceInfo: true,
+ refreshTooltip: 'refresh',
+ historyTooltip: 'history',
+ onRefresh: vi.fn(),
+ onHistory: vi.fn(),
+ ...props
+ },
+ global: {
+ stubs: {
+ TooltipWrapper: {
+ props: ['content'],
+ template: '{{ content }}
'
+ },
+ Timer: {
+ props: ['epoch'],
+ template: '{{ epoch }}'
+ }
+ }
+ }
+ });
+}
+
+describe('InstanceActionBar.vue', () => {
+ beforeEach(() => {
+ mocks.checkCanInviteSelf.mockReturnValue(true);
+ mocks.parseLocation.mockReturnValue({
+ isRealInstance: true,
+ instanceId: 'inst_1',
+ worldId: 'wrld_1',
+ tag: 'wrld_1:inst_1'
+ });
+ mocks.hasGroupPermission.mockReturnValue(false);
+ mocks.selfInvite.mockClear();
+ mocks.closeInstance.mockClear();
+ mocks.showUserDialog.mockClear();
+ mocks.toastSuccess.mockClear();
+ mocks.applyInstance.mockClear();
+ mocks.showLaunchDialog.mockClear();
+ mocks.tryOpenInstanceInVrc.mockClear();
+ mocks.modalConfirm.mockImplementation(() => Promise.resolve({ ok: true }));
+ mocks.instanceJoinHistory.value = new Map([['wrld_base:111', 1700000000]]);
+ mocks.canOpenInstanceInGame = false;
+ mocks.isOpeningInstance = false;
+ mocks.lastLocation.location = 'wrld_here:111';
+ mocks.lastLocation.playerList = new Set(['u1', 'u2']);
+ mocks.currentUser.id = 'usr_me';
+ mocks.cachedGroups = new Map();
+ });
+
+ it('renders launch and invite buttons when invite-self is allowed', () => {
+ const wrapper = mountBar({
+ showRefresh: false,
+ showHistory: false,
+ showInstanceInfo: false
+ });
+
+ expect(wrapper.findAll('[data-testid="btn"]')).toHaveLength(2);
+ expect(wrapper.text()).toContain('dialog.user.info.launch_invite_tooltip');
+ expect(wrapper.text()).toContain('dialog.user.info.self_invite_tooltip');
+ });
+
+ it('launch button opens launch dialog with resolved launchLocation', async () => {
+ const wrapper = mountBar({
+ launchLocation: 'wrld_launch:222',
+ showRefresh: false,
+ showHistory: false,
+ showInstanceInfo: false
+ });
+ const launchBtn = wrapper.findAll('[data-testid="btn"]')[0];
+ expect(launchBtn).toBeTruthy();
+
+ await launchBtn.trigger('click');
+
+ expect(mocks.showLaunchDialog).toHaveBeenCalledWith('wrld_launch:222');
+ });
+
+ it('invite button sends self-invite when canOpenInstanceInGame is false', async () => {
+ const wrapper = mountBar({
+ inviteLocation: 'wrld_invite:333',
+ showRefresh: false,
+ showHistory: false,
+ showInstanceInfo: false
+ });
+ const inviteBtn = wrapper.findAll('[data-testid="btn"]')[1];
+ expect(inviteBtn).toBeTruthy();
+
+ await inviteBtn.trigger('click');
+ await Promise.resolve();
+
+ expect(mocks.selfInvite).toHaveBeenCalledWith({
+ instanceId: 'inst_1',
+ worldId: 'wrld_1',
+ shortName: 'sn'
+ });
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('message.invite.self_sent');
+ });
+
+ it('invite button opens in VRChat when canOpenInstanceInGame is true', async () => {
+ mocks.canOpenInstanceInGame = true;
+ const wrapper = mountBar({
+ inviteLocation: 'wrld_invite:333',
+ showRefresh: false,
+ showHistory: false,
+ showInstanceInfo: false
+ });
+ const inviteBtn = wrapper.findAll('[data-testid="btn"]')[1];
+ expect(inviteBtn).toBeTruthy();
+
+ await inviteBtn.trigger('click');
+
+ expect(mocks.tryOpenInstanceInVrc).toHaveBeenCalledWith('wrld_1:inst_1', 'sn');
+ expect(mocks.selfInvite).not.toHaveBeenCalled();
+ });
+
+ it('refresh/history callbacks run when buttons clicked', async () => {
+ const onRefresh = vi.fn();
+ const onHistory = vi.fn();
+ const wrapper = mountBar({
+ onRefresh,
+ onHistory,
+ showLaunch: false,
+ showInvite: false,
+ showInstanceInfo: false
+ });
+ const buttons = wrapper.findAll('[data-testid="btn"]');
+ expect(buttons).toHaveLength(2);
+
+ await buttons[0].trigger('click');
+ await buttons[1].trigger('click');
+
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ expect(onHistory).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows last-join timer and friend count', () => {
+ const wrapper = mountBar({ friendcount: 5 });
+
+ expect(wrapper.find('[data-testid="timer"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('5');
+ });
+
+ it('close instance flow confirms, calls api, applies instance and toasts', async () => {
+ const wrapper = mountBar({
+ instanceLocation: 'wrld_close:444',
+ instance: {
+ ownerId: 'usr_me',
+ capacity: 16,
+ userCount: 4,
+ hasCapacityForYou: true,
+ platforms: { standalonewindows: 1, android: 2, ios: 0 },
+ users: [],
+ gameServerVersion: 123,
+ $disabledContentSettings: []
+ }
+ });
+
+ const closeBtn = wrapper.findAll('button').find((btn) => btn.text().includes('dialog.user.info.close_instance'));
+ expect(closeBtn).toBeTruthy();
+
+ await closeBtn.trigger('click');
+ await Promise.resolve();
+ await Promise.resolve();
+ await nextTick();
+
+ expect(mocks.modalConfirm).toHaveBeenCalled();
+ expect(mocks.closeInstance).toHaveBeenCalledWith({ location: 'wrld_close:444', hardClose: false });
+ expect(mocks.applyInstance).toHaveBeenCalledWith({ id: 'inst_closed' });
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('message.instance.closed');
+ });
+
+ it('hides launch and invite buttons when invite-self is not allowed', () => {
+ mocks.checkCanInviteSelf.mockReturnValue(false);
+ const wrapper = mountBar({
+ showRefresh: false,
+ showHistory: false,
+ showInstanceInfo: false
+ });
+
+ expect(wrapper.findAll('[data-testid="btn"]')).toHaveLength(0);
+ });
+});
diff --git a/src/components/__tests__/LocationWorld.test.js b/src/components/__tests__/LocationWorld.test.js
new file mode 100644
index 00000000..5b8f53ef
--- /dev/null
+++ b/src/components/__tests__/LocationWorld.test.js
@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ cachedInstances: new Map(),
+ lastInstanceApplied: { value: '' },
+ showLaunchDialog: vi.fn(),
+ showGroupDialog: vi.fn(),
+ getGroupName: vi.fn(() => Promise.resolve('Fetched Group')),
+ parseLocation: vi.fn(() => ({ isRealInstance: true, tag: 'wrld_1:inst_1', groupId: 'grp_1' }))
+}));
+
+vi.mock('pinia', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ storeToRefs: (store) => store
+ };
+});
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('../../stores', () => ({
+ useInstanceStore: () => ({
+ cachedInstances: mocks.cachedInstances,
+ lastInstanceApplied: mocks.lastInstanceApplied
+ }),
+ useLaunchStore: () => ({
+ showLaunchDialog: (...args) => mocks.showLaunchDialog(...args)
+ }),
+ useGroupStore: () => ({})
+}));
+
+vi.mock('../../coordinators/groupCoordinator', () => ({
+ showGroupDialog: (...args) => mocks.showGroupDialog(...args)
+}));
+
+vi.mock('../../shared/constants', () => ({
+ accessTypeLocaleKeyMap: {
+ friends: 'dialog.world.instance.friends',
+ groupPublic: 'dialog.world.instance.group_public',
+ group: 'dialog.world.instance.group'
+ }
+}));
+
+vi.mock('../../shared/utils', () => ({
+ getGroupName: (...args) => mocks.getGroupName(...args),
+ parseLocation: (...args) => mocks.parseLocation(...args)
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ AlertTriangle: { template: '' },
+ Lock: { template: '' },
+ Unlock: { template: '' }
+}));
+
+import LocationWorld from '../LocationWorld.vue';
+
+async function flush() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+function mountComponent(props = {}) {
+ return mount(LocationWorld, {
+ props: {
+ locationobject: {
+ tag: 'wrld_1:inst_1',
+ accessTypeName: 'friends',
+ strict: false,
+ shortName: 'short-1',
+ userId: 'usr_owner',
+ region: 'eu',
+ instanceName: 'Instance Name',
+ groupId: 'grp_1'
+ },
+ currentuserid: 'usr_owner',
+ worlddialogshortname: '',
+ grouphint: '',
+ ...props
+ },
+ global: {
+ stubs: {
+ TooltipWrapper: {
+ props: ['content'],
+ template: ''
+ }
+ }
+ }
+ });
+}
+
+describe('LocationWorld.vue', () => {
+ beforeEach(() => {
+ mocks.cachedInstances = new Map();
+ mocks.lastInstanceApplied.value = '';
+ mocks.showLaunchDialog.mockClear();
+ mocks.showGroupDialog.mockClear();
+ mocks.getGroupName.mockClear();
+ mocks.parseLocation.mockClear();
+ mocks.parseLocation.mockImplementation(() => ({ isRealInstance: true, tag: 'wrld_1:inst_1', groupId: 'grp_1' }));
+ });
+
+ it('renders translated access type and instance name', () => {
+ const wrapper = mountComponent();
+
+ expect(wrapper.text()).toContain('dialog.world.instance.friends #Instance Name');
+ expect(wrapper.find('.flags.eu').exists()).toBe(true);
+ });
+
+ it('marks unlocked for owner and opens launch dialog on click', async () => {
+ const wrapper = mountComponent();
+
+ expect(wrapper.find('[data-testid="unlock"]').exists()).toBe(true);
+
+ await wrapper.findAll('.cursor-pointer')[0].trigger('click');
+
+ expect(mocks.showLaunchDialog).toHaveBeenCalledWith('wrld_1:inst_1', 'short-1');
+ });
+
+ it('shows group hint and opens group dialog', async () => {
+ const wrapper = mountComponent({ grouphint: 'Hint Group' });
+
+ expect(wrapper.text()).toContain('(Hint Group)');
+
+ await wrapper.findAll('.cursor-pointer')[1].trigger('click');
+
+ expect(mocks.showGroupDialog).toHaveBeenCalledWith('grp_1');
+ });
+
+ it('loads group name asynchronously when no hint', async () => {
+ const wrapper = mountComponent({ grouphint: '' });
+ await flush();
+
+ expect(mocks.getGroupName).toHaveBeenCalledWith('grp_1');
+ expect(wrapper.text()).toContain('(Fetched Group)');
+ });
+
+ it('shows closed indicator and strict lock from instance cache', () => {
+ mocks.cachedInstances = new Map([
+ [
+ 'wrld_1:inst_1',
+ {
+ displayName: 'Resolved Name',
+ closedAt: '2026-01-01T00:00:00.000Z'
+ }
+ ]
+ ]);
+
+ const wrapper = mountComponent({
+ locationobject: {
+ tag: 'wrld_1:inst_1',
+ accessTypeName: 'friends',
+ strict: true,
+ shortName: 'short-1',
+ userId: 'usr_other',
+ region: 'us',
+ instanceName: 'Fallback Name',
+ groupId: 'grp_1'
+ },
+ currentuserid: 'usr_me'
+ });
+
+ expect(wrapper.text()).toContain('#Resolved Name');
+ expect(wrapper.find('[data-testid="alert"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="lock"]').exists()).toBe(true);
+ });
+});
diff --git a/src/components/__tests__/NavMenu.test.js b/src/components/__tests__/NavMenu.test.js
new file mode 100644
index 00000000..f179e9eb
--- /dev/null
+++ b/src/components/__tests__/NavMenu.test.js
@@ -0,0 +1,309 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ routerPush: vi.fn(() => Promise.resolve()),
+ directAccessPaste: vi.fn(),
+ logout: vi.fn(),
+ clearAllNotifications: vi.fn(),
+ toggleThemeMode: vi.fn(),
+ toggleNavCollapsed: vi.fn(),
+ initThemeColor: vi.fn(() => Promise.resolve()),
+ applyThemeColor: vi.fn(() => Promise.resolve()),
+ openExternalLink: vi.fn(),
+ getString: vi.fn(() => Promise.resolve(null)),
+ setString: vi.fn(() => Promise.resolve()),
+ showVRCXUpdateDialog: vi.fn(),
+ showChangeLogDialog: vi.fn(),
+ notifiedMenus: { value: [] },
+ pendingVRCXUpdate: { value: false },
+ pendingVRCXInstall: { value: false },
+ appVersion: { value: 'VRCX 2026.01.01' },
+ themeMode: { value: 'system' },
+ tableDensity: { value: 'standard' },
+ isDarkMode: { value: false },
+ isNavCollapsed: { value: false },
+ currentRoute: { value: { name: 'unknown', meta: {} } }
+}));
+
+vi.mock('pinia', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ storeToRefs: (store) => store
+ };
+});
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key,
+ locale: { value: 'en' }
+ })
+}));
+
+vi.mock('../../views/Feed/Feed.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../../views/Feed/columns.jsx', () => ({
+ columns: []
+}));
+
+vi.mock('../../plugins/router', () => ({
+ router: {
+ beforeEach: vi.fn(),
+ push: vi.fn(),
+ replace: vi.fn(),
+ currentRoute: mocks.currentRoute,
+ isReady: vi.fn().mockResolvedValue(true)
+ },
+ initRouter: vi.fn()
+}));
+
+vi.mock('../../plugins/interopApi', () => ({
+ initInteropApi: vi.fn()
+}));
+
+vi.mock('../../services/database', () => ({
+ database: new Proxy(
+ {},
+ {
+ get: (_target, prop) => {
+ if (prop === '__esModule') return false;
+ return vi.fn().mockResolvedValue(null);
+ }
+ }
+ )
+}));
+
+vi.mock('../../services/jsonStorage', () => ({
+ default: vi.fn()
+}));
+
+vi.mock('../../services/watchState', () => ({
+ watchState: { isLoggedIn: false }
+}));
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({
+ push: (...args) => mocks.routerPush(...args),
+ currentRoute: mocks.currentRoute
+ })
+}));
+
+vi.mock('../../stores', () => ({
+ useVRCXUpdaterStore: () => ({
+ pendingVRCXUpdate: mocks.pendingVRCXUpdate,
+ pendingVRCXInstall: mocks.pendingVRCXInstall,
+ appVersion: mocks.appVersion,
+ showVRCXUpdateDialog: (...args) => mocks.showVRCXUpdateDialog(...args),
+ showChangeLogDialog: (...args) => mocks.showChangeLogDialog(...args)
+ }),
+ useUiStore: () => ({
+ notifiedMenus: mocks.notifiedMenus,
+ clearAllNotifications: (...args) => mocks.clearAllNotifications(...args)
+ }),
+ useSearchStore: () => ({
+ directAccessPaste: (...args) => mocks.directAccessPaste(...args)
+ }),
+ useAuthStore: () => ({
+ logout: (...args) => mocks.logout(...args)
+ }),
+ useAppearanceSettingsStore: () => ({
+ themeMode: mocks.themeMode,
+ tableDensity: mocks.tableDensity,
+ isDarkMode: mocks.isDarkMode,
+ isNavCollapsed: mocks.isNavCollapsed,
+ setThemeMode: vi.fn(),
+ toggleThemeMode: (...args) => mocks.toggleThemeMode(...args),
+ setTableDensity: vi.fn(),
+ toggleNavCollapsed: (...args) => mocks.toggleNavCollapsed(...args)
+ })
+}));
+
+vi.mock('../../services/config', () => ({
+ default: {
+ getString: (...args) => mocks.getString(...args),
+ setString: (...args) => mocks.setString(...args)
+ }
+}));
+
+vi.mock('../../shared/constants', () => ({
+ THEME_CONFIG: {
+ system: { name: 'System' },
+ light: { name: 'Light' },
+ dark: { name: 'Dark' }
+ },
+ links: {
+ github: 'https://github.com/vrcx-team/VRCX'
+ },
+ navDefinitions: [
+ {
+ key: 'feed',
+ routeName: 'feed',
+ labelKey: 'nav_tooltip.feed',
+ tooltip: 'nav_tooltip.feed',
+ icon: 'ri-feed-line'
+ },
+ {
+ key: 'direct-access',
+ action: 'direct-access',
+ labelKey: 'nav_tooltip.direct_access',
+ tooltip: 'nav_tooltip.direct_access',
+ icon: 'ri-door-open-line'
+ }
+ ]
+}));
+
+vi.mock('./navMenuUtils', () => ({
+ getFirstNavRoute: () => 'feed',
+ isEntryNotified: () => false,
+ normalizeHiddenKeys: (keys) => keys || [],
+ sanitizeLayout: (layout) => layout
+}));
+
+vi.mock('../../shared/utils', () => ({
+ openExternalLink: (...args) => mocks.openExternalLink(...args)
+}));
+
+vi.mock('@/shared/utils/base/ui', () => ({
+ useThemeColor: () => ({
+ themeColors: { value: [{ key: 'blue', label: 'Blue', swatch: '#00f' }] },
+ currentThemeColor: { value: 'blue' },
+ isApplyingThemeColor: { value: false },
+ applyThemeColor: (...args) => mocks.applyThemeColor(...args),
+ initThemeColor: (...args) => mocks.initThemeColor(...args)
+ })
+}));
+
+vi.mock('@/components/ui/sidebar', () => ({
+ Sidebar: { template: '
' },
+ SidebarContent: { template: '
' },
+ SidebarFooter: { template: '
' },
+ SidebarGroup: { template: '
' },
+ SidebarGroupContent: { template: '
' },
+ SidebarMenu: { template: '
' },
+ SidebarMenuItem: { template: '
' },
+ SidebarMenuSub: { template: '
' },
+ SidebarMenuSubItem: { template: '
' },
+ SidebarMenuButton: {
+ emits: ['click'],
+ template: ''
+ },
+ SidebarMenuSubButton: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: { template: '
' },
+ DropdownMenuTrigger: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuItem: { emits: ['click', 'select'], template: '' },
+ DropdownMenuSeparator: { template: '
' },
+ DropdownMenuLabel: { template: '
' },
+ DropdownMenuSub: { template: '
' },
+ DropdownMenuSubTrigger: { template: '
' },
+ DropdownMenuSubContent: { template: '
' },
+ DropdownMenuCheckboxItem: { emits: ['select'], template: '' }
+}));
+
+vi.mock('@/components/ui/context-menu', () => ({
+ ContextMenu: { template: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuItem: { emits: ['click'], template: '' },
+ ContextMenuSeparator: { template: '
' }
+}));
+
+vi.mock('@/components/ui/collapsible', () => ({
+ Collapsible: { template: '
' },
+ CollapsibleTrigger: { template: '
' },
+ CollapsibleContent: { template: '
' }
+}));
+
+vi.mock('@/components/ui/kbd', () => ({
+ Kbd: { template: '' }
+}));
+
+vi.mock('@/components/ui/tooltip', () => ({
+ TooltipWrapper: { template: '' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ ChevronRight: { template: '' },
+ Heart: { template: '' }
+}));
+
+import NavMenu from '../NavMenu.vue';
+
+function mountComponent() {
+ return mount(NavMenu, {
+ global: {
+ stubs: {
+ CustomNavDialog: { template: '' }
+ }
+ }
+ });
+}
+
+describe('NavMenu.vue', () => {
+ beforeEach(() => {
+ mocks.routerPush.mockClear();
+ mocks.directAccessPaste.mockClear();
+ mocks.logout.mockClear();
+ mocks.clearAllNotifications.mockClear();
+ mocks.toggleThemeMode.mockClear();
+ mocks.toggleNavCollapsed.mockClear();
+ mocks.initThemeColor.mockClear();
+ mocks.applyThemeColor.mockClear();
+ mocks.openExternalLink.mockClear();
+ mocks.getString.mockClear();
+ mocks.setString.mockClear();
+ mocks.currentRoute.value = { name: 'unknown', meta: {} };
+ });
+
+ it('initializes theme and navigates to first route on mount', async () => {
+ mountComponent();
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(mocks.initThemeColor).toHaveBeenCalled();
+ expect(mocks.getString).toHaveBeenCalledWith('VRCX_customNavMenuLayoutList');
+ expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'feed' });
+ });
+
+ it('runs direct access action when direct-access menu is clicked', async () => {
+ const wrapper = mountComponent();
+ await vi.waitFor(() => {
+ const target = wrapper
+ .findAll('[data-testid="menu-btn"]')
+ .find((node) => node.text().includes('nav_tooltip.direct_access'));
+ expect(target).toBeTruthy();
+ });
+
+ const target = wrapper
+ .findAll('[data-testid="menu-btn"]')
+ .find((node) => node.text().includes('nav_tooltip.direct_access'));
+ await target.trigger('click');
+
+ expect(mocks.directAccessPaste).toHaveBeenCalledTimes(1);
+ });
+
+ it('toggles theme when toggle-theme button is clicked', async () => {
+ const wrapper = mountComponent();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const target = wrapper
+ .findAll('[data-testid="menu-btn"]')
+ .find((node) => node.text().includes('nav_tooltip.toggle_theme'));
+ expect(target).toBeTruthy();
+
+ await target.trigger('click');
+
+ expect(mocks.toggleThemeMode).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/__tests__/Timer.test.js b/src/components/__tests__/Timer.test.js
new file mode 100644
index 00000000..d53193b5
--- /dev/null
+++ b/src/components/__tests__/Timer.test.js
@@ -0,0 +1,80 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ timeToText: vi.fn((ms) => `${ms}ms`)
+}));
+
+vi.mock('../../shared/utils', () => ({
+ timeToText: (...args) => mocks.timeToText(...args)
+}));
+
+import Timer from '../Timer.vue';
+
+describe('Timer.vue', () => {
+ let intervalCallback;
+
+ beforeEach(() => {
+ intervalCallback = null;
+ mocks.timeToText.mockClear();
+
+ vi.spyOn(globalThis, 'setInterval').mockImplementation((cb) => {
+ intervalCallback = cb;
+ return 99;
+ });
+ vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {});
+ vi.spyOn(Date, 'now').mockReturnValue(10000);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders elapsed time text from epoch', () => {
+ const wrapper = mount(Timer, {
+ props: {
+ epoch: 4000
+ }
+ });
+
+ expect(wrapper.text()).toBe('6000ms');
+ expect(mocks.timeToText).toHaveBeenCalledWith(6000);
+ });
+
+ it('updates text when interval callback runs', async () => {
+ const wrapper = mount(Timer, {
+ props: {
+ epoch: 4000
+ }
+ });
+
+ vi.mocked(Date.now).mockReturnValue(13000);
+ intervalCallback?.();
+ await nextTick();
+
+ expect(wrapper.text()).toBe('9000ms');
+ });
+
+ it('renders dash when epoch is falsy', () => {
+ const wrapper = mount(Timer, {
+ props: {
+ epoch: 0
+ }
+ });
+
+ expect(wrapper.text()).toBe('-');
+ });
+
+ it('clears interval on unmount', () => {
+ const wrapper = mount(Timer, {
+ props: {
+ epoch: 1
+ }
+ });
+
+ wrapper.unmount();
+
+ expect(clearInterval).toHaveBeenCalledWith(99);
+ });
+});
diff --git a/src/components/dialogs/UserDialog/__tests__/EditNoteAndMemoDialog.test.js b/src/components/dialogs/UserDialog/__tests__/EditNoteAndMemoDialog.test.js
new file mode 100644
index 00000000..f91c07b1
--- /dev/null
+++ b/src/components/dialogs/UserDialog/__tests__/EditNoteAndMemoDialog.test.js
@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({ saveUserMemo: vi.fn(), saveNote: vi.fn(async () => ({ json: { note: 'n1' }, params: { targetUserId: 'usr_1', note: 'n1' } })), getUser: vi.fn() }));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../../stores', () => ({
+ useUserStore: () => ({ userDialog: ref({ id: 'usr_1', note: 'n1', memo: 'm1', ref: { id: 'usr_1', note: 'n1' } }), cachedUsers: new Map([['usr_1', { note: 'n1' }]]) }),
+ useAppearanceSettingsStore: () => ({ hideUserNotes: ref(false), hideUserMemos: ref(false) })
+}));
+vi.mock('../../../../api', () => ({ miscRequest: { saveNote: (...a) => mocks.saveNote(...a) }, userRequest: { getUser: (...a) => mocks.getUser(...a) } }));
+vi.mock('../../../../coordinators/memoCoordinator', () => ({ saveUserMemo: (...a) => mocks.saveUserMemo(...a) }));
+vi.mock('../../../../shared/utils', () => ({ replaceBioSymbols: (s) => s }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogFooter: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/input-group', () => ({ InputGroupTextareaField: { props: ['modelValue'], emits: ['update:modelValue'], template: '' } }));
+
+import EditNoteAndMemoDialog from '../EditNoteAndMemoDialog.vue';
+
+describe('EditNoteAndMemoDialog.vue', () => {
+ beforeEach(() => {
+ mocks.saveUserMemo.mockClear();
+ });
+
+ it('emits close and saves memo on confirm', async () => {
+ const wrapper = mount(EditNoteAndMemoDialog, { props: { visible: false } });
+ await wrapper.setProps({ visible: true });
+ const buttons = wrapper.findAll('[data-testid="btn"]');
+ await buttons[1].trigger('click');
+
+ expect(mocks.saveUserMemo).toHaveBeenCalledWith('usr_1', 'm1');
+ expect(wrapper.emitted('update:visible')).toEqual([[false]]);
+ });
+});
diff --git a/src/components/dialogs/UserDialog/__tests__/UserActionDropdown.test.js b/src/components/dialogs/UserDialog/__tests__/UserActionDropdown.test.js
new file mode 100644
index 00000000..a25fa0f5
--- /dev/null
+++ b/src/components/dialogs/UserDialog/__tests__/UserActionDropdown.test.js
@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../../stores', () => ({
+ useUserStore: () => ({ userDialog: ref({ ref: { id: 'usr_2', $isModerator: false }, isFriend: false, isFavorite: false, incomingRequest: false, outgoingRequest: false, isBlock: false, isMute: false, isMuteChat: false, isInteractOff: false, isHideAvatar: false, isShowAvatar: false }), currentUser: ref({ id: 'usr_1', isBoopingEnabled: true }) }),
+ useGameStore: () => ({ isGameRunning: ref(false) }),
+ useLocationStore: () => ({ lastLocation: ref({ location: 'wrld_1:1' }) })
+}));
+vi.mock('../../../../composables/useInviteChecks', () => ({ useInviteChecks: () => ({ checkCanInvite: () => true }) }));
+vi.mock('../../../ui/dropdown-menu', () => ({ DropdownMenu: { template: '
' }, DropdownMenuTrigger: { template: '
' }, DropdownMenuContent: { template: '
' }, DropdownMenuSeparator: { template: '
' }, DropdownMenuItem: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('../../../ui/tooltip', () => ({ TooltipWrapper: { template: '
' } }));
+vi.mock('lucide-vue-next', () => ({
+ Check: { template: '' },
+ CheckCircle: { template: '' },
+ Flag: { template: '' },
+ LineChart: { template: '' },
+ Mail: { template: '' },
+ MessageCircle: { template: '' },
+ MessageSquare: { template: '' },
+ Mic: { template: '' },
+ MoreHorizontal: { template: '' },
+ MousePointer: { template: '' },
+ Pencil: { template: '' },
+ Plus: { template: '' },
+ RefreshCw: { template: '' },
+ Settings: { template: '' },
+ Share2: { template: '' },
+ Star: { template: '' },
+ Trash2: { template: '' },
+ User: { template: '' },
+ VolumeX: { template: '' },
+ X: { template: '' },
+ XCircle: { template: '' }
+}));
+
+import UserActionDropdown from '../UserActionDropdown.vue';
+
+describe('UserActionDropdown.vue', () => {
+ it('forwards command callback from dropdown item', async () => {
+ const userDialogCommand = vi.fn();
+ const wrapper = mount(UserActionDropdown, { props: { userDialogCommand } });
+
+ await wrapper.findAll('[data-testid="dd-item"]')[0].trigger('click');
+
+ expect(userDialogCommand).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/dialogs/__tests__/ChooseFavoriteGroupDialog.test.js b/src/components/dialogs/__tests__/ChooseFavoriteGroupDialog.test.js
new file mode 100644
index 00000000..cf4da653
--- /dev/null
+++ b/src/components/dialogs/__tests__/ChooseFavoriteGroupDialog.test.js
@@ -0,0 +1,60 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ addFavorite: vi.fn(() => Promise.resolve()),
+ deleteFavoriteNoConfirm: vi.fn(),
+ toastSuccess: vi.fn(),
+ favoriteDialog: { __v_isRef: true, value: { visible: true, type: 'friend', objectId: 'usr_1', currentGroup: null } }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('vue-sonner', () => ({ toast: { success: (...a) => mocks.toastSuccess(...a) } }));
+vi.mock('../../../stores', () => ({
+ useFavoriteStore: () => ({
+ favoriteFriendGroups: ref([{ key: 'group_1', type: 'friend', name: 'group_1', displayName: 'G1', count: 0, capacity: 100 }]),
+ favoriteAvatarGroups: ref([]),
+ favoriteWorldGroups: ref([]),
+ favoriteDialog: mocks.favoriteDialog,
+ localWorldFavoriteGroups: ref([]),
+ localAvatarFavoriteGroups: ref([]),
+ localFriendFavoriteGroups: ref([]),
+ localWorldFavGroupLength: vi.fn(() => 0),
+ hasLocalWorldFavorite: vi.fn(() => false),
+ hasLocalAvatarFavorite: vi.fn(() => false),
+ localAvatarFavGroupLength: vi.fn(() => 0),
+ deleteFavoriteNoConfirm: (...a) => mocks.deleteFavoriteNoConfirm(...a),
+ localFriendFavGroupLength: vi.fn(() => 0),
+ hasLocalFriendFavorite: vi.fn(() => false)
+ }),
+ useUserStore: () => ({ isLocalUserVrcPlusSupporter: ref(true) })
+}));
+vi.mock('../../../api', () => ({ favoriteRequest: { addFavorite: (...a) => mocks.addFavorite(...a) } }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('lucide-vue-next', () => ({ Check: { template: '' } }));
+
+import ChooseFavoriteGroupDialog from '../ChooseFavoriteGroupDialog.vue';
+
+describe('ChooseFavoriteGroupDialog.vue', () => {
+ beforeEach(() => {
+ mocks.addFavorite.mockClear();
+ mocks.toastSuccess.mockClear();
+ mocks.favoriteDialog.value = { visible: true, type: 'friend', objectId: 'usr_1', currentGroup: null };
+ });
+
+ it('runs delete action for current group', async () => {
+ mocks.favoriteDialog.value = {
+ visible: true,
+ type: 'friend',
+ objectId: 'usr_1',
+ currentGroup: { key: 'group_1', displayName: 'G1', count: 0, capacity: 100 }
+ };
+ const wrapper = mount(ChooseFavoriteGroupDialog);
+ await wrapper.get('[data-testid="btn"]').trigger('click');
+
+ expect(mocks.deleteFavoriteNoConfirm).toHaveBeenCalledWith('usr_1');
+ });
+});
diff --git a/src/components/dialogs/__tests__/ImageCropDialog.test.js b/src/components/dialogs/__tests__/ImageCropDialog.test.js
new file mode 100644
index 00000000..a8a59ea0
--- /dev/null
+++ b/src/components/dialogs/__tests__/ImageCropDialog.test.js
@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ resetCropState: vi.fn(),
+ loadImageForCrop: vi.fn(),
+ getCroppedBlob: vi.fn(async () => new Blob(['x'], { type: 'image/png' })),
+ cropperRef: { value: null },
+ cropperImageSrc: { value: 'blob://img' }
+}));
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../composables/useImageCropper', () => ({
+ useImageCropper: () => ({
+ cropperRef: mocks.cropperRef,
+ cropperImageSrc: mocks.cropperImageSrc,
+ resetCropState: (...a) => mocks.resetCropState(...a),
+ loadImageForCrop: (...a) => mocks.loadImageForCrop(...a),
+ getCroppedBlob: (...a) => mocks.getCroppedBlob(...a)
+ })
+}));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogFooter: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/slider', () => ({ Slider: { emits: ['value-commit'], template: '' } }));
+vi.mock('@/components/ui/spinner', () => ({ Spinner: { template: '' } }));
+vi.mock('@/components/ui/tooltip/TooltipWrapper.vue', () => ({ default: { template: '
' } }));
+vi.mock('vue-advanced-cropper', () => ({ Cropper: { emits: ['change'], template: '' } }));
+vi.mock('lucide-vue-next', () => new Proxy({}, { get: () => ({ template: '' }) }));
+
+import ImageCropDialog from '../ImageCropDialog.vue';
+
+describe('ImageCropDialog.vue', () => {
+ it('renders crop dialog title', () => {
+ const wrapper = mount(ImageCropDialog, {
+ props: { open: true, title: 'Crop', aspectRatio: 1, file: null }
+ });
+ expect(wrapper.text()).toContain('Crop');
+ });
+});
diff --git a/src/components/dialogs/__tests__/InviteGroupDialog.test.js b/src/components/dialogs/__tests__/InviteGroupDialog.test.js
new file mode 100644
index 00000000..036d9f70
--- /dev/null
+++ b/src/components/dialogs/__tests__/InviteGroupDialog.test.js
@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ confirm: vi.fn(async () => ({ ok: true })),
+ sendGroupInvite: vi.fn(async () => ({})),
+ getGroup: vi.fn(async () => ({ json: { id: 'grp_1' } })),
+ fetch: vi.fn(async () => ({ ref: { name: 'Group One' } })),
+ setString: vi.fn(),
+ getString: vi.fn(async () => ''),
+ applyGroup: vi.fn((g) => g),
+ inviteDialog: { __v_isRef: true, value: { visible: true, loading: false, groupId: 'grp_1', userId: '', userIds: ['usr_1'], groupName: '', userObject: null } }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('vue-sonner', () => ({ toast: { error: vi.fn() } }));
+vi.mock('../../../shared/utils', () => ({ hasGroupPermission: () => true }));
+vi.mock('../../../composables/useUserDisplay', () => ({ useUserDisplay: () => ({ userImage: () => '', userStatusClass: () => '' }) }));
+vi.mock('../../../stores', () => ({
+ useFriendStore: () => ({ vipFriends: ref([]), onlineFriends: ref([]), activeFriends: ref([]), offlineFriends: ref([]) }),
+ useGroupStore: () => ({ currentUserGroups: ref(new Map()), inviteGroupDialog: mocks.inviteDialog, applyGroup: (...a) => mocks.applyGroup(...a) }),
+ useModalStore: () => ({ confirm: (...a) => mocks.confirm(...a) })
+}));
+vi.mock('../../../api', () => ({ groupRequest: { sendGroupInvite: (...a) => mocks.sendGroupInvite(...a), getGroup: (...a) => mocks.getGroup(...a) }, queryRequest: { fetch: (...a) => mocks.fetch(...a) } }));
+vi.mock('../../../services/config', () => ({ default: { getString: (...a) => mocks.getString(...a), setString: (...a) => mocks.setString(...a) } }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogFooter: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('../../ui/virtual-combobox', () => ({ VirtualCombobox: { template: '' } }));
+vi.mock('lucide-vue-next', () => ({ Check: { template: '' } }));
+
+import InviteGroupDialog from '../InviteGroupDialog.vue';
+
+describe('InviteGroupDialog.vue', () => {
+ it('renders invite dialog', async () => {
+ const wrapper = mount(InviteGroupDialog);
+ expect(wrapper.text()).toContain('dialog.invite_to_group.header');
+ });
+});
diff --git a/src/components/dialogs/__tests__/LaunchDialog.test.js b/src/components/dialogs/__tests__/LaunchDialog.test.js
new file mode 100644
index 00000000..884a6ba1
--- /dev/null
+++ b/src/components/dialogs/__tests__/LaunchDialog.test.js
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ selfInvite: vi.fn(async () => ({})),
+ writeText: vi.fn(),
+ getBool: vi.fn(async () => false),
+ launchDialogData: { value: { visible: true, loading: true, tag: 'wrld_1:123', shortName: 'abc' } }
+}));
+
+Object.assign(globalThis, { navigator: { clipboard: { writeText: (...a) => mocks.writeText(...a) } } });
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('../../../stores', () => ({
+ useFriendStore: () => ({ friends: ref(new Map()) }),
+ useGameStore: () => ({ isGameRunning: ref(false) }),
+ useInviteStore: () => ({ canOpenInstanceInGame: ref(false) }),
+ useLaunchStore: () => ({ launchDialogData: mocks.launchDialogData, launchGame: vi.fn(), tryOpenInstanceInVrc: vi.fn() }),
+ useLocationStore: () => ({ lastLocation: ref({ friendList: new Map() }) }),
+ useModalStore: () => ({ confirm: vi.fn() })
+}));
+vi.mock('../../../shared/utils', () => ({
+ getLaunchURL: () => 'vrchat://launch',
+ isRealInstance: () => true,
+ parseLocation: () => ({ isRealInstance: true, worldId: 'wrld_1', instanceId: '123', tag: 'wrld_1:123' })
+}));
+vi.mock('../../../composables/useInviteChecks', () => ({ useInviteChecks: () => ({ checkCanInvite: () => true }) }));
+vi.mock('../../../api', () => ({ instanceRequest: { selfInvite: (...a) => mocks.selfInvite(...a), getInstanceShortName: vi.fn() }, queryRequest: { fetch: vi.fn() } }));
+vi.mock('../../../services/config', () => ({ default: { getBool: (...a) => mocks.getBool(...a), setBool: vi.fn() } }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogDescription: { template: '
' }, DialogFooter: { template: '
' } }));
+vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: { template: '
' }, DropdownMenuTrigger: { template: '
' }, DropdownMenuContent: { template: '
' }, DropdownMenuItem: { template: '
' } }));
+vi.mock('@/components/ui/field', () => ({ Field: { template: '
' }, FieldGroup: { template: '
' }, FieldLabel: { template: '
' }, FieldContent: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/button-group', () => ({ ButtonGroup: { template: '
' } }));
+vi.mock('@/components/ui/input-group', () => ({ InputGroupField: { template: '' } }));
+vi.mock('@/components/ui/tooltip', () => ({ TooltipWrapper: { template: '
' } }));
+vi.mock('../InviteDialog/InviteDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('lucide-vue-next', () => ({ Copy: { template: '' }, Info: { template: '' }, MoreHorizontal: { template: '' } }));
+
+import LaunchDialog from '../LaunchDialog.vue';
+
+describe('LaunchDialog.vue', () => {
+ beforeEach(() => {
+ mocks.selfInvite.mockClear();
+ });
+
+ it('renders launch dialog header', async () => {
+ const wrapper = mount(LaunchDialog);
+ await Promise.resolve();
+ expect(wrapper.text()).toContain('dialog.launch.header');
+ });
+});
diff --git a/src/components/dialogs/__tests__/MainDialogContainer.test.js b/src/components/dialogs/__tests__/MainDialogContainer.test.js
new file mode 100644
index 00000000..5f599130
--- /dev/null
+++ b/src/components/dialogs/__tests__/MainDialogContainer.test.js
@@ -0,0 +1,48 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ closeMainDialog: vi.fn(),
+ handleBreadcrumbClick: vi.fn(),
+ dialogCrumbs: { value: [{ type: 'user', id: 'u1', label: 'User' }, { type: 'world', id: 'w1', label: 'World' }] },
+ userVisible: { value: true }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('@/stores', () => ({
+ useUiStore: () => ({ dialogCrumbs: mocks.dialogCrumbs.value, closeMainDialog: (...a) => mocks.closeMainDialog(...a), handleBreadcrumbClick: (...a) => mocks.handleBreadcrumbClick(...a) }),
+ useUserStore: () => ({ userDialog: { visible: mocks.userVisible.value } }),
+ useWorldStore: () => ({ worldDialog: { visible: false } }),
+ useAvatarStore: () => ({ avatarDialog: { visible: false } }),
+ useGroupStore: () => ({ groupDialog: { visible: false } }),
+ useInstanceStore: () => ({ previousInstancesInfoDialog: ref({ visible: false }), previousInstancesListDialog: ref({ visible: false, variant: 'user' }) })
+}));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' } }));
+vi.mock('@/components/ui/breadcrumb', () => ({ Breadcrumb: { template: '
' }, BreadcrumbList: { template: '
' }, BreadcrumbItem: { template: '
' }, BreadcrumbLink: { template: '
' }, BreadcrumbSeparator: { template: '/' }, BreadcrumbPage: { template: '' }, BreadcrumbEllipsis: { template: '...' } }));
+vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: { template: '
' }, DropdownMenuTrigger: { template: '
' }, DropdownMenuContent: { template: '
' }, DropdownMenuItem: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/tooltip', () => ({ TooltipWrapper: { template: '
' } }));
+vi.mock('lucide-vue-next', () => ({ ArrowLeft: { template: '' } }));
+vi.mock('../AvatarDialog/AvatarDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../GroupDialog/GroupDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../PreviousInstancesDialog/PreviousInstancesInfoDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../PreviousInstancesDialog/PreviousInstancesListDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../UserDialog/UserDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../WorldDialog/WorldDialog.vue', () => ({ default: { template: '' } }));
+
+import MainDialogContainer from '../MainDialogContainer.vue';
+
+describe('MainDialogContainer.vue', () => {
+ beforeEach(() => {
+ mocks.handleBreadcrumbClick.mockClear();
+ });
+
+ it('renders active dialog and handles breadcrumb back click', async () => {
+ const wrapper = mount(MainDialogContainer);
+ expect(wrapper.find('[data-testid="user-dialog"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="btn"]').trigger('click');
+ expect(mocks.handleBreadcrumbClick).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/dialogs/__tests__/SendBoopDialog.test.js b/src/components/dialogs/__tests__/SendBoopDialog.test.js
new file mode 100644
index 00000000..de62b466
--- /dev/null
+++ b/src/components/dialogs/__tests__/SendBoopDialog.test.js
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ sendBoop: vi.fn(),
+ fetch: vi.fn(async () => ({ ref: { displayName: 'User A' } })),
+ boopDialog: { value: { visible: true, userId: 'usr_1' } }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../api', () => ({ miscRequest: { sendBoop: (...a) => mocks.sendBoop(...a) }, notificationRequest: { hideNotificationV2: vi.fn() }, queryRequest: { fetch: (...a) => mocks.fetch(...a) } }));
+vi.mock('../../../stores', () => ({
+ useUserStore: () => ({ sendBoopDialog: mocks.boopDialog, isLocalUserVrcPlusSupporter: { value: false } }),
+ useNotificationStore: () => ({ notificationTable: { value: { data: [] } }, isNotificationExpired: () => false, handleNotificationV2Hide: vi.fn() }),
+ useGalleryStore: () => ({ showGalleryPage: vi.fn(), refreshEmojiTable: vi.fn(), emojiTable: { value: [] } })
+}));
+vi.mock('../../../shared/constants/photon.js', () => ({ photonEmojis: ['Wave'] }));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogFooter: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('../../ui/virtual-combobox', () => ({ VirtualCombobox: { template: '' } }));
+vi.mock('../../Emoji.vue', () => ({ default: { template: '' } }));
+vi.mock('lucide-vue-next', () => ({ Check: { template: '' } }));
+
+import SendBoopDialog from '../SendBoopDialog.vue';
+
+describe('SendBoopDialog.vue', () => {
+ it('renders boop dialog content', async () => {
+ const wrapper = mount(SendBoopDialog);
+ expect(wrapper.text()).toContain('dialog.boop_dialog.header');
+ });
+});
diff --git a/src/components/dialogs/__tests__/TableLimitsDialog.test.js b/src/components/dialogs/__tests__/TableLimitsDialog.test.js
new file mode 100644
index 00000000..30c8a2e3
--- /dev/null
+++ b/src/components/dialogs/__tests__/TableLimitsDialog.test.js
@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ close: vi.fn(),
+ save: vi.fn(),
+ dialog: { value: { visible: true, maxTableSize: '1000', searchLimit: '100' } }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../stores', () => ({
+ useAppearanceSettingsStore: () => ({
+ tableLimitsDialog: mocks.dialog,
+ closeTableLimitsDialog: (...a) => mocks.close(...a),
+ saveTableLimitsDialog: (...a) => mocks.save(...a),
+ TABLE_MAX_SIZE_MIN: 100,
+ TABLE_MAX_SIZE_MAX: 5000,
+ SEARCH_LIMIT_MIN: 10,
+ SEARCH_LIMIT_MAX: 1000
+ })
+}));
+vi.mock('@/components/ui/dialog', () => ({ Dialog: { template: '
' }, DialogContent: { template: '
' }, DialogHeader: { template: '
' }, DialogTitle: { template: '
' }, DialogDescription: { template: '
' }, DialogFooter: { template: '
' } }));
+vi.mock('@/components/ui/field', () => ({ Field: { template: '
' }, FieldGroup: { template: '
' }, FieldLabel: { template: '
' }, FieldContent: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/input-group', () => ({ InputGroupField: { template: '' } }));
+
+import TableLimitsDialog from '../TableLimitsDialog.vue';
+
+describe('TableLimitsDialog.vue', () => {
+ it('disables save when limits are invalid and calls close', async () => {
+ mocks.dialog.value.maxTableSize = '1';
+ const wrapper = mount(TableLimitsDialog);
+ const buttons = wrapper.findAll('[data-testid="btn"]');
+
+ expect(buttons[1].attributes('disabled')).toBeDefined();
+ await buttons[0].trigger('click');
+ expect(mocks.close).toHaveBeenCalled();
+ });
+});
diff --git a/src/views/Favorites/components/__tests__/FavoritesAvatarItem.test.js b/src/views/Favorites/components/__tests__/FavoritesAvatarItem.test.js
new file mode 100644
index 00000000..86991f0b
--- /dev/null
+++ b/src/views/Favorites/components/__tests__/FavoritesAvatarItem.test.js
@@ -0,0 +1,232 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ showFavoriteDialog: vi.fn(),
+ showAvatarDialog: vi.fn(),
+ selectAvatarWithConfirmation: vi.fn(),
+ deleteFavorite: vi.fn(),
+ removeLocalAvatarFavorite: vi.fn(),
+ currentUser: { currentAvatar: '' }
+}));
+
+vi.mock('pinia', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ storeToRefs: (store) => store
+ };
+});
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('../../../../stores', () => ({
+ useFavoriteStore: () => ({
+ showFavoriteDialog: (...args) => mocks.showFavoriteDialog(...args)
+ }),
+ useUserStore: () => ({
+ currentUser: mocks.currentUser
+ })
+}));
+
+vi.mock('../../../../coordinators/avatarCoordinator', () => ({
+ showAvatarDialog: (...args) => mocks.showAvatarDialog(...args),
+ selectAvatarWithConfirmation: (...args) => mocks.selectAvatarWithConfirmation(...args)
+}));
+
+vi.mock('../../../../coordinators/favoriteCoordinator', () => ({
+ removeLocalAvatarFavorite: (...args) => mocks.removeLocalAvatarFavorite(...args)
+}));
+
+vi.mock('../../../../api', () => ({
+ favoriteRequest: {
+ deleteFavorite: (...args) => mocks.deleteFavorite(...args)
+ }
+}));
+
+vi.mock('@/components/ui/item', () => ({
+ Item: {
+ emits: ['click'],
+ template: '
'
+ },
+ ItemActions: { template: '
' },
+ ItemMedia: { template: '
' },
+ ItemContent: { template: '
' },
+ ItemTitle: { template: '
' },
+ ItemDescription: { template: '
' }
+}));
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: { template: '
' },
+ AvatarImage: {
+ props: ['src'],
+ template: '
'
+ },
+ AvatarFallback: { template: '' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/checkbox', () => ({
+ Checkbox: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('@/components/ui/context-menu', () => ({
+ ContextMenu: { template: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuSeparator: { template: '
' },
+ ContextMenuItem: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: { template: '
' },
+ DropdownMenuTrigger: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuSeparator: { template: '
' },
+ DropdownMenuItem: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ AlertTriangle: { template: '' },
+ Lock: { template: '' },
+ MoreHorizontal: { template: '' },
+ Trash2: { template: '' }
+}));
+
+import FavoritesAvatarItem from '../FavoritesAvatarItem.vue';
+
+/**
+ *
+ * @param {Record} props
+ */
+function mountItem(props = {}) {
+ return mount(FavoritesAvatarItem, {
+ props: {
+ favorite: {
+ id: 'avtr_1',
+ ref: {
+ name: 'Avatar One',
+ authorName: 'Author',
+ thumbnailImageUrl: 'https://example.com/avatar_256.png',
+ releaseStatus: 'public'
+ }
+ },
+ group: 'FavGroup',
+ isLocalFavorite: false,
+ editMode: false,
+ selected: false,
+ ...props
+ }
+ });
+}
+
+/**
+ *
+ * @param wrapper
+ * @param text
+ */
+async function clickMenuItem(wrapper, text) {
+ const buttons = wrapper.findAll('button');
+ const target = buttons.find((btn) => btn.text().includes(text));
+ expect(target, `menu item not found: ${text}`).toBeTruthy();
+ await target.trigger('click');
+}
+
+describe('FavoritesAvatarItem.vue', () => {
+ beforeEach(() => {
+ mocks.showFavoriteDialog.mockReset();
+ mocks.showAvatarDialog.mockReset();
+ mocks.selectAvatarWithConfirmation.mockReset();
+ mocks.deleteFavorite.mockReset();
+ mocks.removeLocalAvatarFavorite.mockReset();
+ mocks.currentUser.currentAvatar = '';
+ });
+
+ it('opens avatar details when item is clicked', async () => {
+ const wrapper = mountItem();
+
+ await wrapper.get('[data-testid="item"]').trigger('click');
+
+ expect(mocks.showAvatarDialog).toHaveBeenCalledWith('avtr_1');
+ });
+
+ it('adds the unified hover classes on item', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.get('[data-testid="item"]').classes()).toEqual(
+ expect.arrayContaining(['favorites-item', 'hover:bg-muted', 'x-hover-list'])
+ );
+ });
+
+ it('uses rounded avatar shell and 128 thumbnail image', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.get('[data-testid="avatar"]').classes()).toEqual(expect.arrayContaining(['rounded-sm', 'size-full']));
+ expect(wrapper.get('[data-testid="avatar-image"]').attributes('src')).toContain('avatar_128.png');
+ expect(wrapper.get('[data-testid="avatar-fallback"]').classes()).toContain('rounded-sm');
+ });
+
+ it('shows fallback text when thumbnail is missing', () => {
+ const wrapper = mountItem({
+ favorite: {
+ id: 'avtr_no_thumb',
+ ref: {
+ name: 'Bravo',
+ authorName: 'Author',
+ thumbnailImageUrl: '',
+ releaseStatus: 'public'
+ }
+ }
+ });
+
+ expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe(false);
+ expect(wrapper.get('[data-testid="avatar-fallback"]').text()).toContain('B');
+ });
+
+ it('uses local delete flow for local favorites', async () => {
+ const wrapper = mountItem({
+ isLocalFavorite: true,
+ group: { name: 'LocalGroup' },
+ favorite: {
+ id: 'avtr_local',
+ name: 'Local Avatar',
+ thumbnailImageUrl: ''
+ }
+ });
+
+ await clickMenuItem(wrapper, 'view.favorite.delete_tooltip');
+
+ expect(mocks.removeLocalAvatarFavorite).toHaveBeenCalledWith('avtr_local', 'LocalGroup');
+ expect(mocks.deleteFavorite).not.toHaveBeenCalled();
+ });
+
+ it('uses remote delete flow for remote favorites', async () => {
+ const wrapper = mountItem();
+
+ await clickMenuItem(wrapper, 'view.favorite.unfavorite_tooltip');
+
+ expect(mocks.deleteFavorite).toHaveBeenCalledWith({ objectId: 'avtr_1' });
+ expect(mocks.removeLocalAvatarFavorite).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/views/Favorites/components/__tests__/FavoritesAvatarLocalHistoryItem.test.js b/src/views/Favorites/components/__tests__/FavoritesAvatarLocalHistoryItem.test.js
new file mode 100644
index 00000000..dc13dc26
--- /dev/null
+++ b/src/views/Favorites/components/__tests__/FavoritesAvatarLocalHistoryItem.test.js
@@ -0,0 +1,177 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ showFavoriteDialog: vi.fn(),
+ showAvatarDialog: vi.fn(),
+ selectAvatarWithConfirmation: vi.fn(),
+ currentUser: { currentAvatar: '' }
+}));
+
+vi.mock('pinia', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ storeToRefs: (store) => store
+ };
+});
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key) => key
+ })
+}));
+
+vi.mock('../../../../stores', () => ({
+ useFavoriteStore: () => ({
+ showFavoriteDialog: (...args) => mocks.showFavoriteDialog(...args)
+ }),
+ useUserStore: () => ({
+ currentUser: mocks.currentUser
+ })
+}));
+
+vi.mock('../../../../coordinators/avatarCoordinator', () => ({
+ showAvatarDialog: (...args) => mocks.showAvatarDialog(...args),
+ selectAvatarWithConfirmation: (...args) => mocks.selectAvatarWithConfirmation(...args)
+}));
+
+vi.mock('@/components/ui/item', () => ({
+ Item: {
+ emits: ['click'],
+ template: '
'
+ },
+ ItemActions: { template: '
' },
+ ItemMedia: { template: '
' },
+ ItemContent: { template: '
' },
+ ItemTitle: { template: '
' },
+ ItemDescription: { template: '
' }
+}));
+
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: { template: '
' },
+ AvatarImage: {
+ props: ['src'],
+ template: '
'
+ },
+ AvatarFallback: { template: '' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/context-menu', () => ({
+ ContextMenu: { template: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuSeparator: { template: '
' },
+ ContextMenuItem: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: { template: '
' },
+ DropdownMenuTrigger: { template: '
' },
+ DropdownMenuContent: { template: '
' },
+ DropdownMenuSeparator: { template: '
' },
+ DropdownMenuItem: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ MoreHorizontal: { template: '' }
+}));
+
+import FavoritesAvatarLocalHistoryItem from '../FavoritesAvatarLocalHistoryItem.vue';
+
+/**
+ *
+ * @param {Record} props
+ */
+function mountItem(props = {}) {
+ return mount(FavoritesAvatarLocalHistoryItem, {
+ props: {
+ favorite: {
+ id: 'avtr_hist_1',
+ name: 'Local History Avatar',
+ authorName: 'Author',
+ thumbnailImageUrl: 'https://example.com/history_256.png',
+ ...props.favorite
+ }
+ }
+ });
+}
+
+/**
+ *
+ * @param wrapper
+ * @param text
+ */
+async function clickMenuItem(wrapper, text) {
+ const buttons = wrapper.findAll('button');
+ const target = buttons.find((btn) => btn.text().includes(text));
+ expect(target, `menu item not found: ${text}`).toBeTruthy();
+ await target.trigger('click');
+}
+
+describe('FavoritesAvatarLocalHistoryItem.vue', () => {
+ beforeEach(() => {
+ mocks.showFavoriteDialog.mockReset();
+ mocks.showAvatarDialog.mockReset();
+ mocks.selectAvatarWithConfirmation.mockReset();
+ mocks.currentUser.currentAvatar = '';
+ });
+
+ it('opens avatar details when item is clicked', async () => {
+ const wrapper = mountItem();
+
+ await wrapper.get('[data-testid="item"]').trigger('click');
+
+ expect(mocks.showAvatarDialog).toHaveBeenCalledWith('avtr_hist_1');
+ });
+
+ it('adds the unified hover classes on item', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.get('[data-testid="item"]').classes()).toEqual(
+ expect.arrayContaining(['favorites-item', 'hover:bg-muted', 'x-hover-list'])
+ );
+ });
+
+ it('uses rounded avatar shell and 128 thumbnail image', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.get('[data-testid="avatar"]').classes()).toEqual(expect.arrayContaining(['rounded-sm', 'size-full']));
+ expect(wrapper.get('[data-testid="avatar-image"]').attributes('src')).toContain('history_128.png');
+ expect(wrapper.get('[data-testid="avatar-fallback"]').classes()).toContain('rounded-sm');
+ });
+
+ it('shows fallback text when thumbnail is missing', () => {
+ const wrapper = mountItem({
+ favorite: {
+ id: 'avtr_hist_no_thumb',
+ name: 'Charlie',
+ thumbnailImageUrl: ''
+ }
+ });
+
+ expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe(false);
+ expect(wrapper.get('[data-testid="avatar-fallback"]').text()).toContain('C');
+ });
+
+ it('runs select-avatar action from menu', async () => {
+ const wrapper = mountItem();
+
+ await clickMenuItem(wrapper, 'view.favorite.select_avatar_tooltip');
+
+ expect(mocks.selectAvatarWithConfirmation).toHaveBeenCalledWith('avtr_hist_1');
+ });
+});
diff --git a/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js b/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js
index 19ad8a1b..a40f1119 100644
--- a/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js
+++ b/src/views/Favorites/components/__tests__/FavoritesFriendItem.test.js
@@ -240,6 +240,14 @@ describe('FavoritesFriendItem.vue', () => {
expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1');
});
+ it('adds the unified hover classes on item', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.get('[data-testid="item"]').classes()).toEqual(
+ expect.arrayContaining(['favorites-item', 'hover:bg-muted', 'x-hover-list'])
+ );
+ });
+
it('emits toggle-select in edit mode checkbox', async () => {
const wrapper = mountItem({ editMode: true });
diff --git a/src/views/Favorites/components/__tests__/FavoritesWorldItem.test.js b/src/views/Favorites/components/__tests__/FavoritesWorldItem.test.js
index 659f5332..472b43e3 100644
--- a/src/views/Favorites/components/__tests__/FavoritesWorldItem.test.js
+++ b/src/views/Favorites/components/__tests__/FavoritesWorldItem.test.js
@@ -76,6 +76,15 @@ vi.mock('@/components/ui/item', () => ({
ItemDescription: { template: '
' }
}));
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: { template: '
' },
+ AvatarImage: {
+ props: ['src'],
+ template: '
'
+ },
+ AvatarFallback: { template: '' }
+}));
+
vi.mock('@/components/ui/button', () => ({
Button: {
emits: ['click'],
@@ -245,6 +254,32 @@ describe('FavoritesWorldItem.vue', () => {
expect(wrapper.text()).toContain('wrld_missing_ref');
});
+ it('adds the unified hover classes on item', () => {
+ const wrapper = mountItem();
+
+ expect(wrapper.get('[data-testid="item"]').classes()).toEqual(
+ expect.arrayContaining(['favorites-item', 'hover:bg-muted', 'x-hover-list'])
+ );
+ });
+
+ it('uses rounded avatar fallback when thumbnail is missing', () => {
+ const wrapper = mountItem({
+ favorite: {
+ id: 'wrld_no_thumb',
+ ref: {
+ name: 'No Thumb World',
+ authorName: 'Author',
+ thumbnailImageUrl: '',
+ releaseStatus: 'public'
+ }
+ }
+ });
+
+ expect(wrapper.find('[data-testid="avatar-image"]').exists()).toBe(false);
+ expect(wrapper.get('[data-testid="avatar"]').classes()).toEqual(expect.arrayContaining(['rounded-sm', 'size-full']));
+ expect(wrapper.get('[data-testid="avatar-fallback"]').classes()).toContain('rounded-sm');
+ });
+
it('deletes local favorite via coordinator', async () => {
const wrapper = mountItem({
favorite: {
diff --git a/src/views/Favorites/composables/__tests__/useFavoritesCardScaling.test.js b/src/views/Favorites/composables/__tests__/useFavoritesCardScaling.test.js
new file mode 100644
index 00000000..d12a73cd
--- /dev/null
+++ b/src/views/Favorites/composables/__tests__/useFavoritesCardScaling.test.js
@@ -0,0 +1,53 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent, h } from 'vue';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ getString: vi.fn(async (_key, fallback) => fallback),
+ setString: vi.fn()
+}));
+
+vi.mock('../../../../services/config.js', () => ({
+ default: {
+ getString: (...args) => mocks.getString(...args),
+ setString: (...args) => mocks.setString(...args)
+ }
+}));
+
+import { useFavoritesCardScaling } from '../useFavoritesCardScaling';
+
+function mountComposable() {
+ let api;
+ const Comp = defineComponent({
+ setup() {
+ api = useFavoritesCardScaling({
+ configKey: 'scale-key',
+ spacingConfigKey: 'spacing-key'
+ });
+ return () => h('div');
+ }
+ });
+ mount(Comp);
+ return api;
+}
+
+describe('useFavoritesCardScaling', () => {
+ beforeEach(() => {
+ mocks.getString.mockClear();
+ mocks.setString.mockClear();
+ });
+
+ it('builds grid style css vars from scale/spacing', async () => {
+ const api = mountComposable();
+ api.cardScale.value = 0.8;
+ api.cardSpacing.value = 1.2;
+
+ const style = api.gridStyle.value(3, { preferredColumns: 2 });
+
+ expect(style['--favorites-card-scale']).toBe('0.80');
+ expect(style['--favorites-card-spacing-scale']).toBe('1.20');
+ expect(Number(style['--favorites-grid-columns'])).toBeGreaterThanOrEqual(1);
+ expect(mocks.setString).toHaveBeenCalledWith('scale-key', '0.8');
+ expect(mocks.setString).toHaveBeenCalledWith('spacing-key', '1.2');
+ });
+});
diff --git a/src/views/Favorites/composables/__tests__/useFavoritesLocalGroups.test.js b/src/views/Favorites/composables/__tests__/useFavoritesLocalGroups.test.js
new file mode 100644
index 00000000..8a105fa5
--- /dev/null
+++ b/src/views/Favorites/composables/__tests__/useFavoritesLocalGroups.test.js
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { useFavoritesLocalGroups } from '../useFavoritesLocalGroups';
+
+describe('useFavoritesLocalGroups', () => {
+ it('starts, confirms and selects created local group', async () => {
+ const createGroup = vi.fn();
+ const selectGroup = vi.fn();
+ const api = useFavoritesLocalGroups({ createGroup, selectGroup });
+
+ api.startLocalGroupCreation();
+ expect(api.isCreatingLocalGroup.value).toBe(true);
+
+ api.newLocalGroupName.value = ' Local A ';
+ api.handleLocalGroupCreationConfirm();
+ await Promise.resolve();
+
+ expect(createGroup).toHaveBeenCalledWith('Local A');
+ expect(selectGroup).toHaveBeenCalledWith('local', 'Local A', { userInitiated: true });
+ expect(api.isCreatingLocalGroup.value).toBe(false);
+ });
+
+ it('cancels when name is empty', () => {
+ const createGroup = vi.fn();
+ const selectGroup = vi.fn();
+ const api = useFavoritesLocalGroups({ createGroup, selectGroup });
+
+ api.startLocalGroupCreation();
+ api.newLocalGroupName.value = ' ';
+ api.handleLocalGroupCreationConfirm();
+
+ expect(createGroup).not.toHaveBeenCalled();
+ expect(selectGroup).not.toHaveBeenCalled();
+ expect(api.isCreatingLocalGroup.value).toBe(false);
+ });
+});
diff --git a/src/views/Favorites/composables/__tests__/useFavoritesSplitter.test.js b/src/views/Favorites/composables/__tests__/useFavoritesSplitter.test.js
new file mode 100644
index 00000000..707b29e9
--- /dev/null
+++ b/src/views/Favorites/composables/__tests__/useFavoritesSplitter.test.js
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defineComponent, h } from 'vue';
+import { mount } from '@vue/test-utils';
+import { beforeEach } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ getString: vi.fn(async (_key, fallback) => fallback),
+ setString: vi.fn()
+}));
+
+vi.mock('../../../../services/config.js', () => ({
+ default: {
+ getString: (...args) => mocks.getString(...args),
+ setString: (...args) => mocks.setString(...args)
+ }
+}));
+
+import { useFavoritesSplitter } from '../useFavoritesSplitter';
+
+function mountComposable() {
+ let api;
+ const Comp = defineComponent({
+ setup() {
+ api = useFavoritesSplitter({
+ configKey: 'split-key',
+ defaultSize: 240,
+ minPx: 120,
+ maxPx: 360
+ });
+ return () => h('div');
+ }
+ });
+ mount(Comp);
+ return api;
+}
+
+describe('useFavoritesSplitter', () => {
+ beforeEach(() => {
+ mocks.setString.mockClear();
+ });
+
+ it('persists layout size while dragging', () => {
+ const api = mountComposable();
+ api.splitterGroupRef.value = { getBoundingClientRect: () => ({ width: 1200 }) };
+
+ api.setDragging(true);
+ api.handleLayout([25, 75]);
+ api.setDragging(false);
+
+ expect(mocks.setString).toHaveBeenCalledWith('split-key', '300');
+ });
+
+ it('ignores layout updates when not dragging', () => {
+ const api = mountComposable();
+ api.splitterGroupRef.value = { getBoundingClientRect: () => ({ width: 1200 }) };
+
+ api.handleLayout([20, 80]);
+
+ expect(mocks.setString).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/views/GameLog/__tests__/GameLog.test.js b/src/views/GameLog/__tests__/GameLog.test.js
new file mode 100644
index 00000000..de4bf723
--- /dev/null
+++ b/src/views/GameLog/__tests__/GameLog.test.js
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({ lookup: vi.fn(), table: { value: { vip: false, filter: [], search: '' } } }));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../stores', () => ({
+ useGameLogStore: () => ({ gameLogTableLookup: (...a) => mocks.lookup(...a), gameLogTable: mocks.table, gameLogTableData: ref([]) }),
+ useAppearanceSettingsStore: () => ({ tablePageSizes: [20, 50], tablePageSize: 20 }),
+ useVrcxStore: () => ({ maxTableSize: 500 }),
+ useModalStore: () => ({ confirm: vi.fn() })
+}));
+vi.mock('../../../components/ui/data-table', () => ({ DataTableLayout: { template: '
' } }));
+vi.mock('../../../components/ui/input-group', () => ({ InputGroupField: { template: '' } }));
+vi.mock('@/components/ui/select', () => ({ Select: { emits: ['update:modelValue'], template: '' }, SelectTrigger: { template: '
' }, SelectValue: { template: '
' }, SelectContent: { template: '
' }, SelectGroup: { template: '
' }, SelectItem: { template: '
' } }));
+vi.mock('@/components/ui/toggle', () => ({ Toggle: { template: '' } }));
+vi.mock('lucide-vue-next', () => ({ Star: { template: '' } }));
+vi.mock('../../../services/database', () => ({ database: { deleteGameLogEntry: vi.fn() } }));
+vi.mock('../../../shared/utils', () => ({ removeFromArray: vi.fn() }));
+vi.mock('../../../composables/useDataTableScrollHeight', () => ({ useDataTableScrollHeight: () => ({ tableStyle: ref({}) }) }));
+vi.mock('../../../lib/table/useVrcxVueTable', () => ({ useVrcxVueTable: () => ({ table: { getFilteredRowModel: () => ({ rows: [] }) }, pagination: ref({ pageIndex: 0, pageSize: 20 }) }) }));
+vi.mock('../columns.jsx', () => ({ createColumns: () => [] }));
+
+import GameLog from '../GameLog.vue';
+
+describe('GameLog.vue', () => {
+ it('updates filter and triggers lookup when filter changes', async () => {
+ const wrapper = mount(GameLog);
+ await wrapper.get('[data-testid="sel"]').trigger('click');
+
+ expect(mocks.lookup).toHaveBeenCalled();
+ expect(mocks.table.value.filter).toEqual(['Event']);
+ });
+});
diff --git a/src/views/Layout/__tests__/MainLayout.test.js b/src/views/Layout/__tests__/MainLayout.test.js
new file mode 100644
index 00000000..17ae6cc3
--- /dev/null
+++ b/src/views/Layout/__tests__/MainLayout.test.js
@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({ replace: vi.fn(), setNavCollapsed: vi.fn(), setNavWidth: vi.fn() }));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-router', () => ({ useRouter: () => ({ replace: (...a) => mocks.replace(...a) }) }));
+vi.mock('../../../services/watchState', () => ({ watchState: { isLoggedIn: false } }));
+vi.mock('../../../stores', () => ({ useAppearanceSettingsStore: () => ({ navWidth: ref(240), isNavCollapsed: ref(false), showStatusBar: ref(false), setNavCollapsed: (...a) => mocks.setNavCollapsed(...a), setNavWidth: (...a) => mocks.setNavWidth(...a) }) }));
+vi.mock('../../../composables/useMainLayoutResizable', () => ({ useMainLayoutResizable: () => ({ asideDefaultSize: 30, asideMinSize: 0, asideMaxPx: 480, mainDefaultSize: 70, handleLayout: vi.fn(), isAsideCollapsed: () => false, isAsideCollapsedStatic: false, isSideBarTabShow: ref(true) }) }));
+vi.mock('../../../components/ui/resizable', () => ({ ResizablePanelGroup: { template: '
' }, ResizablePanel: { template: '
' }, ResizableHandle: { template: '' } }));
+vi.mock('../../../components/ui/sidebar', () => ({ SidebarProvider: { template: '
' }, SidebarInset: { template: '
' } }));
+vi.mock('../../../components/NavMenu.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Sidebar/Sidebar.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/StatusBar.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/dialogs/MainDialogContainer.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/FullscreenImagePreview.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/dialogs/ChooseFavoriteGroupDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/dialogs/LaunchDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Settings/dialogs/LaunchOptionsDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Favorites/dialogs/FriendImportDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Favorites/dialogs/WorldImportDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Favorites/dialogs/AvatarImportDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/dialogs/GroupDialog/GroupMemberModerationDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/dialogs/InviteGroupDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Settings/dialogs/VRChatConfigDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Settings/dialogs/PrimaryPasswordDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/dialogs/SendBoopDialog.vue', () => ({ default: { template: '' } }));
+vi.mock('../../Settings/dialogs/ChangelogDialog.vue', () => ({ default: { template: '' } }));
+
+import MainLayout from '../MainLayout.vue';
+
+describe('MainLayout.vue', () => {
+ it('redirects to login when not logged in', () => {
+ mount(MainLayout, { global: { stubs: { RouterView: { template: '' }, KeepAlive: { template: '
' } } } });
+ expect(mocks.replace).toHaveBeenCalledWith({ name: 'login' });
+ });
+});
diff --git a/src/views/Settings/components/Tabs/__tests__/DiscordPresenceTab.test.js b/src/views/Settings/components/Tabs/__tests__/DiscordPresenceTab.test.js
new file mode 100644
index 00000000..d272dce5
--- /dev/null
+++ b/src/views/Settings/components/Tabs/__tests__/DiscordPresenceTab.test.js
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ discordStore: {
+ setDiscordActive: vi.fn(),
+ setDiscordInstance: vi.fn(),
+ setDiscordHideInvite: vi.fn(),
+ setDiscordJoinButton: vi.fn(),
+ setDiscordHideImage: vi.fn(),
+ setDiscordShowPlatform: vi.fn(),
+ setDiscordWorldIntegration: vi.fn(),
+ setDiscordWorldNameAsDiscordStatus: vi.fn(),
+ saveDiscordOption: vi.fn(),
+ discordActive: { __v_isRef: true, value: true },
+ discordInstance: { __v_isRef: true, value: true },
+ discordHideInvite: { __v_isRef: true, value: false },
+ discordJoinButton: { __v_isRef: true, value: true },
+ discordHideImage: { __v_isRef: true, value: false },
+ discordShowPlatform: { __v_isRef: true, value: true },
+ discordWorldIntegration: { __v_isRef: true, value: true },
+ discordWorldNameAsDiscordStatus: { __v_isRef: true, value: false }
+ },
+ showVRChatConfig: vi.fn()
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../../../stores', () => ({
+ useDiscordPresenceSettingsStore: () => mocks.discordStore,
+ useAdvancedSettingsStore: () => ({ showVRChatConfig: (...a) => mocks.showVRChatConfig(...a) })
+}));
+
+vi.mock('../../SimpleSwitch.vue', () => ({
+ default: {
+ props: ['label', 'disabled'],
+ emits: ['change'],
+ template:
+ ''
+ }
+}));
+
+import DiscordPresenceTab from '../DiscordPresenceTab.vue';
+
+describe('DiscordPresenceTab.vue', () => {
+ beforeEach(() => {
+ mocks.discordStore.discordActive.value = true;
+ mocks.discordStore.discordInstance.value = true;
+ mocks.discordStore.setDiscordActive.mockClear();
+ mocks.discordStore.saveDiscordOption.mockClear();
+ mocks.showVRChatConfig.mockClear();
+ });
+
+ it('opens VRChat config and handles switch changes', async () => {
+ const wrapper = mount(DiscordPresenceTab);
+
+ const tooltipRow = wrapper
+ .findAll('div.options-container-item')
+ .find((node) => node.text().includes('view.settings.discord_presence.discord_presence.enable_tooltip'));
+ await tooltipRow.trigger('click');
+
+ expect(mocks.showVRChatConfig).toHaveBeenCalledTimes(1);
+
+ await wrapper.findAll('.emit-change')[0].trigger('click');
+ expect(mocks.discordStore.setDiscordActive).toHaveBeenCalledTimes(1);
+ expect(mocks.discordStore.saveDiscordOption).toHaveBeenCalled();
+ });
+
+ it('passes disabled state to dependent switches when discord is disabled', () => {
+ mocks.discordStore.discordActive.value = false;
+ const wrapper = mount(DiscordPresenceTab);
+
+ const worldIntegration = wrapper
+ .findAll('[data-testid="simple-switch"]')
+ .find((node) =>
+ node.attributes('data-label')?.includes('world_integration')
+ );
+
+ expect(worldIntegration?.attributes('data-disabled')).toBe('true');
+ });
+});
diff --git a/src/views/Settings/components/Tabs/__tests__/WristOverlayTab.test.js b/src/views/Settings/components/Tabs/__tests__/WristOverlayTab.test.js
new file mode 100644
index 00000000..e6766045
--- /dev/null
+++ b/src/views/Settings/components/Tabs/__tests__/WristOverlayTab.test.js
@@ -0,0 +1,31 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+vi.mock('../../WristOverlaySettings.vue', () => ({
+ default: {
+ emits: ['open-feed-filters'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../dialogs/FeedFiltersDialog.vue', () => ({
+ default: {
+ props: ['feedFiltersDialogMode'],
+ template: ''
+ }
+}));
+
+import WristOverlayTab from '../WristOverlayTab.vue';
+
+describe('WristOverlayTab.vue', () => {
+ it('sets feed dialog mode to wrist when child emits open-feed-filters', async () => {
+ const wrapper = mount(WristOverlayTab);
+
+ expect(wrapper.get('[data-testid="feed-dialog"]').attributes('data-mode')).toBe('');
+
+ await wrapper.get('[data-testid="open-filters"]').trigger('click');
+
+ expect(wrapper.get('[data-testid="feed-dialog"]').attributes('data-mode')).toBe('wrist');
+ });
+});
diff --git a/src/views/Settings/components/__tests__/SimpleSwitch.test.js b/src/views/Settings/components/__tests__/SimpleSwitch.test.js
new file mode 100644
index 00000000..153d6708
--- /dev/null
+++ b/src/views/Settings/components/__tests__/SimpleSwitch.test.js
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+vi.mock('../../../../components/ui/switch', () => ({
+ Switch: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ Info: { template: '' }
+}));
+
+import SimpleSwitch from '../SimpleSwitch.vue';
+
+describe('SimpleSwitch.vue', () => {
+ it('emits change when inner switch updates', async () => {
+ const wrapper = mount(SimpleSwitch, {
+ props: {
+ label: 'Label',
+ value: false
+ },
+ global: {
+ stubs: {
+ TooltipWrapper: { template: '' }
+ }
+ }
+ });
+
+ await wrapper.get('[data-testid="switch"]').trigger('click');
+ expect(wrapper.emitted('change')).toEqual([[true]]);
+ });
+
+ it('applies long label style and renders tooltip', () => {
+ const wrapper = mount(SimpleSwitch, {
+ props: {
+ label: 'Long',
+ value: true,
+ longLabel: true,
+ tooltip: 'tip',
+ disabled: true
+ },
+ global: {
+ stubs: {
+ TooltipWrapper: { template: '' }
+ }
+ }
+ });
+
+ expect(wrapper.get('.name').attributes('style')).toContain('width: 300px');
+ expect(wrapper.find('[data-testid="tooltip"]').exists()).toBe(true);
+ expect(wrapper.get('[data-testid="switch"]').attributes('data-disabled')).toBe('true');
+ });
+});
diff --git a/src/views/Settings/components/__tests__/WristOverlaySettings.test.js b/src/views/Settings/components/__tests__/WristOverlaySettings.test.js
new file mode 100644
index 00000000..845d57a6
--- /dev/null
+++ b/src/views/Settings/components/__tests__/WristOverlaySettings.test.js
@@ -0,0 +1,127 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ notificationsStore: {
+ openVR: { value: true },
+ setOpenVR: vi.fn()
+ },
+ wristStore: {
+ overlayWrist: { value: true },
+ hidePrivateFromFeed: { value: false },
+ openVRAlways: { value: false },
+ overlaybutton: { value: false },
+ overlayHand: { value: '1' },
+ vrBackgroundEnabled: { value: false },
+ minimalFeed: { value: false },
+ hideDevicesFromFeed: { value: false },
+ vrOverlayCpuUsage: { value: false },
+ hideUptimeFromFeed: { value: false },
+ pcUptimeOnFeed: { value: false },
+ setOverlayWrist: vi.fn(),
+ setHidePrivateFromFeed: vi.fn(),
+ setOpenVRAlways: vi.fn(),
+ setOverlaybutton: vi.fn(),
+ setOverlayHand: vi.fn(),
+ setVrBackgroundEnabled: vi.fn(),
+ setMinimalFeed: vi.fn(),
+ setHideDevicesFromFeed: vi.fn(),
+ setVrOverlayCpuUsage: vi.fn(),
+ setHideUptimeFromFeed: vi.fn(),
+ setPcUptimeOnFeed: vi.fn()
+ },
+ saveOpenVROption: vi.fn()
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+
+vi.mock('../../../../stores', () => ({
+ useNotificationsSettingsStore: () => mocks.notificationsStore,
+ useWristOverlaySettingsStore: () => mocks.wristStore,
+ useVrStore: () => ({ saveOpenVROption: (...a) => mocks.saveOpenVROption(...a) })
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ props: ['disabled'],
+ emits: ['click'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../../components/ui/radio-group', () => ({
+ RadioGroup: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template:
+ '
'
+ },
+ RadioGroupItem: { template: '' }
+}));
+
+vi.mock('../../../../components/ui/toggle-group', () => ({
+ ToggleGroup: {
+ emits: ['update:model-value'],
+ template:
+ '
'
+ },
+ ToggleGroupItem: { template: '
' }
+}));
+
+vi.mock('../SimpleSwitch.vue', () => ({
+ default: {
+ props: ['label'],
+ emits: ['change'],
+ template:
+ ''
+ }
+}));
+
+import WristOverlaySettings from '../WristOverlaySettings.vue';
+
+describe('WristOverlaySettings.vue', () => {
+ beforeEach(() => {
+ mocks.notificationsStore.openVR.value = true;
+ mocks.wristStore.overlayWrist.value = true;
+ mocks.wristStore.openVRAlways.value = false;
+ mocks.wristStore.overlaybutton.value = false;
+ mocks.notificationsStore.setOpenVR.mockClear();
+ mocks.wristStore.setOpenVRAlways.mockClear();
+ mocks.wristStore.setOverlaybutton.mockClear();
+ mocks.wristStore.setOverlayHand.mockClear();
+ mocks.saveOpenVROption.mockClear();
+ });
+
+ it('emits open-feed-filters and handles switch/radio/toggle updates', async () => {
+ const wrapper = mount(WristOverlaySettings);
+
+ await wrapper.get('[data-testid="filters-btn"]').trigger('click');
+ expect(wrapper.emitted('open-feed-filters')).toBeTruthy();
+
+ await wrapper.findAll('.emit-change')[0].trigger('click');
+ expect(mocks.notificationsStore.setOpenVR).toHaveBeenCalledTimes(1);
+ expect(mocks.saveOpenVROption).toHaveBeenCalled();
+
+ const radioGroups = wrapper.findAll('[data-testid="radio-group"]');
+ await radioGroups[0].get('[data-testid="radio-true"]').trigger('click');
+ expect(mocks.wristStore.setOpenVRAlways).toHaveBeenCalledTimes(1);
+
+ await radioGroups[1].get('[data-testid="radio-true"]').trigger('click');
+ expect(mocks.wristStore.setOverlaybutton).toHaveBeenCalledTimes(1);
+
+ await wrapper.get('[data-testid="toggle-right"]').trigger('click');
+ expect(mocks.wristStore.setOverlayHand).toHaveBeenCalledWith('2');
+ });
+
+ it('does not toggle openVRAlways when the value is unchanged', async () => {
+ mocks.wristStore.openVRAlways.value = true;
+ const wrapper = mount(WristOverlaySettings);
+
+ const firstRadio = wrapper.findAll('[data-testid="radio-group"]')[0];
+ await firstRadio.get('[data-testid="radio-true"]').trigger('click');
+
+ expect(mocks.wristStore.setOpenVRAlways).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/views/Settings/dialogs/__tests__/FeedFiltersDialog.test.js b/src/views/Settings/dialogs/__tests__/FeedFiltersDialog.test.js
new file mode 100644
index 00000000..2c4d9e15
--- /dev/null
+++ b/src/views/Settings/dialogs/__tests__/FeedFiltersDialog.test.js
@@ -0,0 +1,141 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ sharedFeedFilters: {
+ __v_isRef: true,
+ value: {
+ noty: { status: 'all' },
+ wrist: { status: 'none' }
+ }
+ },
+ photonLoggingEnabled: { __v_isRef: true, value: false },
+ loadSharedFeed: vi.fn(),
+ setString: vi.fn()
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+
+vi.mock('../../../../stores', () => ({
+ usePhotonStore: () => ({ photonLoggingEnabled: mocks.photonLoggingEnabled }),
+ useNotificationsSettingsStore: () => ({ sharedFeedFilters: mocks.sharedFeedFilters }),
+ useSharedFeedStore: () => ({ loadSharedFeed: (...a) => mocks.loadSharedFeed(...a) })
+}));
+
+vi.mock('../../../../services/config', () => ({
+ default: {
+ setString: (...a) => mocks.setString(...a)
+ }
+}));
+
+vi.mock('../../../../shared/constants', () => ({
+ feedFiltersOptions: () => ({
+ notyFeedFiltersOptions: [
+ {
+ key: 'status',
+ name: 'Noty Status',
+ options: [{ label: 'all', textKey: 'all' }]
+ }
+ ],
+ wristFeedFiltersOptions: [
+ {
+ key: 'status',
+ name: 'Wrist Status',
+ options: [{ label: 'none', textKey: 'none' }]
+ }
+ ],
+ photonFeedFiltersOptions: []
+ }),
+ sharedFeedFiltersDefaults: {
+ noty: { status: 'default-noty' },
+ wrist: { status: 'default-wrist' }
+ }
+}));
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: {
+ emits: ['update:open'],
+ template:
+ '
'
+ },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogFooter: { template: '
' }
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: {
+ emits: ['click'],
+ template: ''
+ }
+}));
+
+vi.mock('../../../../components/ui/toggle-group', () => ({
+ ToggleGroup: {
+ emits: ['update:model-value'],
+ template:
+ '
'
+ },
+ ToggleGroupItem: { template: '' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ AlertTriangle: { template: '' },
+ Info: { template: '' }
+}));
+
+import FeedFiltersDialog from '../FeedFiltersDialog.vue';
+
+describe('FeedFiltersDialog.vue', () => {
+ beforeEach(() => {
+ mocks.sharedFeedFilters.value = {
+ noty: { status: 'all' },
+ wrist: { status: 'none' }
+ };
+ mocks.loadSharedFeed.mockClear();
+ mocks.setString.mockClear();
+ });
+
+ it('renders title by mode, saves filter change, and closes dialog', async () => {
+ const wrapper = mount(FeedFiltersDialog, {
+ props: {
+ feedFiltersDialogMode: 'wrist'
+ },
+ global: {
+ stubs: {
+ TooltipWrapper: { template: '' }
+ }
+ }
+ });
+
+ expect(wrapper.get('[data-testid="dialog-title"]').text()).toBe('dialog.shared_feed_filters.wrist');
+
+ await wrapper.get('[data-testid="toggle-update"]').trigger('click');
+ expect(mocks.sharedFeedFilters.value.wrist.status).toBe('all');
+ expect(mocks.setString).toHaveBeenCalledWith(
+ 'sharedFeedFilters',
+ JSON.stringify(mocks.sharedFeedFilters.value)
+ );
+ expect(mocks.loadSharedFeed).toHaveBeenCalledTimes(1);
+
+ const closeButton = wrapper.findAll('[data-testid="btn"]')[1];
+ await closeButton.trigger('click');
+ expect(wrapper.emitted('update:feedFiltersDialogMode')).toEqual([['']]);
+ });
+
+ it('resets noty filters to defaults', async () => {
+ const wrapper = mount(FeedFiltersDialog, {
+ props: {
+ feedFiltersDialogMode: 'noty'
+ }
+ });
+
+ const resetButton = wrapper.findAll('[data-testid="btn"]')[0];
+ await resetButton.trigger('click');
+
+ expect(mocks.sharedFeedFilters.value.noty).toEqual({ status: 'default-noty' });
+ expect(mocks.setString).toHaveBeenCalled();
+ });
+});
diff --git a/src/views/Sidebar/__tests__/Sidebar.test.js b/src/views/Sidebar/__tests__/Sidebar.test.js
new file mode 100644
index 00000000..f818d920
--- /dev/null
+++ b/src/views/Sidebar/__tests__/Sidebar.test.js
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+
+const mocks = vi.hoisted(() => ({
+ openSearch: vi.fn(),
+ markAllAsSeen: vi.fn(),
+ refreshFriends: vi.fn(),
+ hasUnseen: { value: true },
+ centerOpen: { value: false }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('@vueuse/core', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useMagicKeys: () => ({}),
+ whenever: vi.fn()
+ };
+});
+vi.mock('../../../stores', () => ({
+ useFriendStore: () => ({ friends: ref(new Map()), isRefreshFriendsLoading: ref(false), onlineFriendCount: ref(0) }),
+ useGroupStore: () => ({ groupInstances: ref([]) }),
+ useNotificationStore: () => ({ isNotificationCenterOpen: mocks.centerOpen, hasUnseenNotifications: mocks.hasUnseen, markAllAsSeen: (...a) => mocks.markAllAsSeen(...a) }),
+ useAppearanceSettingsStore: () => ({ sidebarSortMethod1: ref(''), sidebarSortMethod2: ref(''), sidebarSortMethod3: ref(''), isSidebarGroupByInstance: ref(false), isHideFriendsInSameInstance: ref(false), isSidebarDivideByFriendGroup: ref(false), sidebarFavoriteGroups: ref([]), setSidebarSortMethod1: vi.fn(), setSidebarSortMethod2: vi.fn(), setSidebarSortMethod3: vi.fn(), setIsSidebarGroupByInstance: vi.fn(), setIsHideFriendsInSameInstance: vi.fn(), setIsSidebarDivideByFriendGroup: vi.fn(), setSidebarFavoriteGroups: vi.fn() }),
+ useFavoriteStore: () => ({ favoriteFriendGroups: ref([]), localFriendFavoriteGroups: ref([]) })
+}));
+vi.mock('../../../stores/globalSearch', () => ({ useGlobalSearchStore: () => ({ open: (...a) => mocks.openSearch(...a) }) }));
+vi.mock('../../../coordinators/friendSyncCoordinator', () => ({ runRefreshFriendsListFlow: (...a) => mocks.refreshFriends(...a) }));
+vi.mock('../sidebarSettingsUtils', () => ({ normalizeFavoriteGroupsChange: (v) => v, resolveFavoriteGroups: (v) => v }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/context-menu', () => ({ ContextMenu: { template: '
' }, ContextMenuTrigger: { template: '
' }, ContextMenuContent: { template: '
' }, ContextMenuItem: { emits: ['click'], template: '' } }));
+vi.mock('@/components/ui/popover', () => ({ Popover: { template: '
' }, PopoverTrigger: { template: '
' }, PopoverContent: { template: '
' } }));
+vi.mock('@/components/ui/select', () => ({ Select: { template: '
' }, SelectTrigger: { template: '
' }, SelectValue: { template: '
' }, SelectContent: { template: '
' }, SelectGroup: { template: '
' }, SelectItem: { template: '
' }, SelectSeparator: { template: '
' } }));
+vi.mock('@/components/ui/field', () => ({ Field: { template: '
' }, FieldLabel: { template: '
' }, FieldContent: { template: '
' } }));
+vi.mock('@/components/ui/tabs', () => ({ TabsUnderline: { template: '
' } }));
+vi.mock('@/components/ui/switch', () => ({ Switch: { template: '' } }));
+vi.mock('@/components/ui/spinner', () => ({ Spinner: { template: '' } }));
+vi.mock('@/components/ui/tooltip', () => ({ TooltipWrapper: { template: '
' } }));
+vi.mock('@/components/ui/kbd', () => ({ Kbd: { template: '' } }));
+vi.mock('@/components/ui/separator', () => ({ Separator: { template: '
' } }));
+vi.mock('lucide-vue-next', () => ({
+ Bell: { template: '' },
+ RefreshCw: { template: '' },
+ Search: { template: '' },
+ Settings: { template: '' }
+}));
+vi.mock('../components/FriendsSidebar.vue', () => ({ default: { template: '' } }));
+vi.mock('../components/GroupsSidebar.vue', () => ({ default: { template: '' } }));
+vi.mock('../components/GroupOrderSheet.vue', () => ({ default: { template: '' } }));
+vi.mock('../components/NotificationCenterSheet.vue', () => ({ default: { template: '' } }));
+vi.mock('../../../components/GlobalSearchDialog.vue', () => ({ default: { template: '' } }));
+
+import Sidebar from '../Sidebar.vue';
+
+describe('Sidebar.vue', () => {
+ beforeEach(() => {
+ mocks.openSearch.mockClear();
+ mocks.markAllAsSeen.mockClear();
+ });
+
+ it('opens global search and marks notifications read', async () => {
+ const wrapper = mount(Sidebar);
+ const buttons = wrapper.findAll('button');
+ for (const button of buttons) {
+ await button.trigger('click');
+ if (mocks.openSearch.mock.calls.length > 0) {
+ break;
+ }
+ }
+ await wrapper.get('[data-testid="ctx"]').trigger('click');
+
+ expect(mocks.openSearch).toHaveBeenCalled();
+ expect(mocks.markAllAsSeen).toHaveBeenCalled();
+ });
+});
diff --git a/src/views/Sidebar/components/__tests__/GroupsSidebar.test.js b/src/views/Sidebar/components/__tests__/GroupsSidebar.test.js
new file mode 100644
index 00000000..5973f302
--- /dev/null
+++ b/src/views/Sidebar/components/__tests__/GroupsSidebar.test.js
@@ -0,0 +1,145 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ isAgeGatedInstancesVisible: { value: true },
+ groupInstances: {
+ value: [
+ {
+ group: {
+ groupId: 'grp_1',
+ name: 'Group One',
+ iconUrl: 'https://example.com/icon.png'
+ },
+ instance: {
+ id: 'inst_1',
+ ownerId: 'usr_owner',
+ userCount: 1,
+ capacity: 16,
+ location: 'wrld_1:123'
+ }
+ }
+ ]
+ },
+ sortGroupInstancesByInGame: (a, b) => a[0].group.name.localeCompare(b[0].group.name),
+ showLaunchDialog: vi.fn(),
+ checkCanInviteSelf: vi.fn(() => true),
+ selfInvite: vi.fn().mockResolvedValue({}),
+ showGroupDialog: vi.fn(),
+ toastSuccess: vi.fn()
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+
+vi.mock('@tanstack/vue-virtual', () => ({
+ useVirtualizer: (optionsRef) => ({
+ value: {
+ getVirtualItems: () =>
+ Array.from({ length: optionsRef.value.count }, (_, index) => ({
+ index,
+ key: optionsRef.value.getItemKey(index),
+ start: index * 52
+ })),
+ getTotalSize: () => optionsRef.value.count * 52,
+ measure: vi.fn(),
+ measureElement: vi.fn()
+ }
+ })
+}));
+
+vi.mock('../../../../stores', () => ({
+ useAppearanceSettingsStore: () => ({
+ isAgeGatedInstancesVisible: mocks.isAgeGatedInstancesVisible
+ }),
+ useGroupStore: () => ({
+ sortGroupInstancesByInGame: mocks.sortGroupInstancesByInGame,
+ groupInstances: mocks.groupInstances
+ }),
+ useLaunchStore: () => ({ showLaunchDialog: (...a) => mocks.showLaunchDialog(...a) })
+}));
+
+vi.mock('../../../../composables/useInviteChecks', () => ({
+ useInviteChecks: () => ({ checkCanInviteSelf: (...a) => mocks.checkCanInviteSelf(...a) })
+}));
+
+vi.mock('../../../../shared/utils', () => ({
+ convertFileUrlToImageUrl: (url) => `${url}?small`,
+ parseLocation: (location) => ({
+ isRealInstance: !!location,
+ worldId: location.split(':')[0],
+ instanceId: location.split(':')[1]
+ })
+}));
+
+vi.mock('../../../../coordinators/groupCoordinator', () => ({
+ showGroupDialog: (...a) => mocks.showGroupDialog(...a)
+}));
+
+vi.mock('../../../../api', () => ({
+ instanceRequest: {
+ selfInvite: (...a) => mocks.selfInvite(...a)
+ }
+}));
+
+vi.mock('vue-sonner', () => ({
+ toast: {
+ success: (...a) => mocks.toastSuccess(...a)
+ }
+}));
+
+vi.mock('../../../../components/ui/context-menu', () => ({
+ ContextMenu: { template: '
' },
+ ContextMenuTrigger: { template: '
' },
+ ContextMenuContent: { template: '
' },
+ ContextMenuItem: {
+ emits: ['click'],
+ props: ['disabled'],
+ template:
+ ''
+ }
+}));
+
+vi.mock('../../../../components/BackToTop.vue', () => ({
+ default: { template: '' }
+}));
+
+vi.mock('../../../../components/Location.vue', () => ({
+ default: { props: ['location'], template: '{{ location }}' }
+}));
+
+vi.mock('lucide-vue-next', () => ({
+ ChevronDown: { template: '' }
+}));
+
+import GroupsSidebar from '../GroupsSidebar.vue';
+
+describe('GroupsSidebar.vue', () => {
+ beforeEach(() => {
+ mocks.showLaunchDialog.mockClear();
+ mocks.selfInvite.mockClear();
+ mocks.showGroupDialog.mockClear();
+ mocks.toastSuccess.mockClear();
+ });
+
+ it('renders group rows and handles launch/self-invite actions', async () => {
+ const wrapper = mount(GroupsSidebar);
+
+ expect(wrapper.text()).toContain('Group One');
+
+ await wrapper.get('[data-testid="location"]').trigger('click');
+ expect(mocks.showGroupDialog).toHaveBeenCalledWith('usr_owner');
+
+ const items = wrapper.findAll('[data-testid="ctx-item"]');
+ await items[0].trigger('click');
+ expect(mocks.showLaunchDialog).toHaveBeenCalledWith('wrld_1:123');
+
+ await items[1].trigger('click');
+ await Promise.resolve();
+ expect(mocks.selfInvite).toHaveBeenCalledWith({
+ worldId: 'wrld_1',
+ instanceId: '123'
+ });
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('message.invite.self_sent');
+ });
+});
diff --git a/src/views/Tools/components/__tests__/GroupCalendarEventCard.test.js b/src/views/Tools/components/__tests__/GroupCalendarEventCard.test.js
new file mode 100644
index 00000000..d53ba3ab
--- /dev/null
+++ b/src/views/Tools/components/__tests__/GroupCalendarEventCard.test.js
@@ -0,0 +1,62 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ followGroupEvent: vi.fn(async () => ({ json: { ok: true } })),
+ showFullscreenImageDialog: vi.fn(),
+ writeText: vi.fn(),
+ toastSuccess: vi.fn()
+}));
+
+Object.assign(globalThis, { navigator: { clipboard: { writeText: (...a) => mocks.writeText(...a) } } });
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+vi.mock('../../../../stores', () => ({
+ useGalleryStore: () => ({ showFullscreenImageDialog: (...a) => mocks.showFullscreenImageDialog(...a) }),
+ useGroupStore: () => ({ cachedGroups: new Map([['grp_1', { name: 'Group One', bannerUrl: 'https://example.com/banner.png' }]]) })
+}));
+vi.mock('../../../../services/appConfig', () => ({ AppDebug: { endpointDomain: 'https://api.example.com' } }));
+vi.mock('../../../../shared/utils', () => ({ formatDateFilter: () => '12:00' }));
+vi.mock('../../../../api', () => ({ groupRequest: { followGroupEvent: (...a) => mocks.followGroupEvent(...a) } }));
+vi.mock('vue-sonner', () => ({ toast: { success: (...a) => mocks.toastSuccess(...a), error: vi.fn() } }));
+vi.mock('@/components/ui/popover', () => ({ Popover: { template: '
' }, PopoverTrigger: { template: '
' }, PopoverContent: { template: '
' } }));
+vi.mock('@/components/ui/card', () => ({ Card: { template: '
' } }));
+vi.mock('@/components/ui/button', () => ({ Button: { emits: ['click'], template: '' } }));
+vi.mock('lucide-vue-next', () => ({
+ Calendar: { template: '' },
+ Download: { template: '' },
+ Share2: { template: '' },
+ Star: { template: '' }
+}));
+
+import GroupCalendarEventCard from '../GroupCalendarEventCard.vue';
+
+function mountCard() {
+ return mount(GroupCalendarEventCard, {
+ props: {
+ event: { id: 'evt_1', ownerId: 'grp_1', title: 'Event One', startsAt: '2026-01-01', endsAt: '2026-01-01', accessType: 'public', category: 'social', interestedUserCount: 2, closeInstanceAfterEndMinutes: 30, createdAt: '2026-01-01', description: 'desc', imageUrl: '' },
+ mode: 'timeline',
+ isFollowing: false
+ }
+ });
+}
+
+describe('GroupCalendarEventCard.vue', () => {
+ beforeEach(() => {
+ mocks.followGroupEvent.mockClear();
+ mocks.writeText.mockClear();
+ });
+
+ it('copies event link and toggles follow', async () => {
+ const wrapper = mountCard();
+ const buttons = wrapper.findAll('[data-testid="btn"]');
+
+ await buttons[0].trigger('click');
+ await buttons[1].trigger('click');
+ await Promise.resolve();
+
+ expect(mocks.writeText).toHaveBeenCalledWith('https://vrchat.com/home/group/grp_1/calendar/evt_1');
+ expect(mocks.followGroupEvent).toHaveBeenCalledWith({ groupId: 'grp_1', eventId: 'evt_1', isFollowing: true });
+ expect(wrapper.emitted('update-following-calendar-data')).toBeTruthy();
+ });
+});
diff --git a/src/views/Tools/dialogs/__tests__/AutoChangeStatusDialog.test.js b/src/views/Tools/dialogs/__tests__/AutoChangeStatusDialog.test.js
new file mode 100644
index 00000000..bf3edd4e
--- /dev/null
+++ b/src/views/Tools/dialogs/__tests__/AutoChangeStatusDialog.test.js
@@ -0,0 +1,159 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+
+const mocks = vi.hoisted(() => ({
+ generalStore: {
+ autoStateChangeEnabled: { __v_isRef: true, value: true },
+ autoStateChangeAloneStatus: { __v_isRef: true, value: 'active' },
+ autoStateChangeCompanyStatus: { __v_isRef: true, value: 'busy' },
+ autoStateChangeInstanceTypes: { __v_isRef: true, value: ['invite'] },
+ autoStateChangeNoFriends: { __v_isRef: true, value: false },
+ autoStateChangeAloneDescEnabled: { __v_isRef: true, value: false },
+ autoStateChangeAloneDesc: { __v_isRef: true, value: '' },
+ autoStateChangeCompanyDescEnabled: { __v_isRef: true, value: false },
+ autoStateChangeCompanyDesc: { __v_isRef: true, value: '' },
+ autoStateChangeGroups: { __v_isRef: true, value: [] },
+ autoAcceptInviteRequests: { __v_isRef: true, value: 'Off' },
+ autoAcceptInviteGroups: { __v_isRef: true, value: [] },
+ setAutoStateChangeEnabled: vi.fn(),
+ setAutoStateChangeAloneStatus: vi.fn(),
+ setAutoStateChangeCompanyStatus: vi.fn(),
+ setAutoStateChangeInstanceTypes: vi.fn(),
+ setAutoStateChangeNoFriends: vi.fn(),
+ setAutoStateChangeAloneDescEnabled: vi.fn(),
+ setAutoStateChangeAloneDesc: vi.fn(),
+ setAutoStateChangeCompanyDescEnabled: vi.fn(),
+ setAutoStateChangeCompanyDesc: vi.fn(),
+ setAutoStateChangeGroups: vi.fn(),
+ setAutoAcceptInviteRequests: vi.fn(),
+ setAutoAcceptInviteGroups: vi.fn()
+ },
+ favoriteStore: {
+ favoriteFriendGroups: {
+ __v_isRef: true,
+ value: [{ key: 'grp_a', displayName: 'Group A' }]
+ },
+ localFriendFavoriteGroups: { __v_isRef: true, value: ['Local A'] }
+ }
+}));
+
+vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
+
+vi.mock('../../../../stores', () => ({
+ useGeneralSettingsStore: () => mocks.generalStore,
+ useFavoriteStore: () => mocks.favoriteStore
+}));
+
+vi.mock('../../../../shared/constants', () => ({
+ accessTypeLocaleKeyMap: {
+ invite: 'access.invite',
+ invitePlus: 'access.invite_plus',
+ friends: 'access.friends',
+ friendsPlus: 'access.friends_plus',
+ public: 'access.public',
+ groupPublic: 'access.group_public',
+ groupPlus: 'access.group_plus',
+ groupMembers: 'access.group_members',
+ group: 'access.group'
+ }
+}));
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: {
+ emits: ['update:open'],
+ template:
+ '
'
+ },
+ DialogContent: { template: '
' },
+ DialogHeader: { template: '
' },
+ DialogTitle: { template: '
' }
+}));
+
+vi.mock('@/components/ui/field', () => ({
+ Field: { template: '
' },
+ FieldContent: { template: '
' },
+ FieldGroup: { template: '
' },
+ FieldLabel: { template: '
' },
+ FieldSeparator: { template: '
' }
+}));
+
+vi.mock('@/components/ui/select', () => ({
+ Select: { template: '
' },
+ SelectContent: { template: '
' },
+ SelectGroup: { template: '
' },
+ SelectItem: { template: '
' },
+ SelectSeparator: { template: '
' },
+ SelectTrigger: { template: '
' },
+ SelectValue: { template: '
' }
+}));
+
+vi.mock('@/components/ui/radio-group', () => ({
+ RadioGroup: {
+ emits: ['update:modelValue'],
+ template:
+ '
'
+ },
+ RadioGroupItem: { template: '' }
+}));
+
+vi.mock('@/components/ui/input', () => ({
+ Input: { template: '' }
+}));
+
+vi.mock('../../../Settings/components/SimpleSwitch.vue', () => ({
+ default: {
+ props: ['label'],
+ emits: ['change'],
+ template:
+ ''
+ }
+}));
+
+import AutoChangeStatusDialog from '../AutoChangeStatusDialog.vue';
+
+describe('AutoChangeStatusDialog.vue', () => {
+ beforeEach(() => {
+ mocks.generalStore.autoStateChangeNoFriends.value = false;
+ mocks.generalStore.autoAcceptInviteRequests.value = 'Off';
+ mocks.generalStore.setAutoStateChangeNoFriends.mockClear();
+ mocks.generalStore.setAutoAcceptInviteRequests.mockClear();
+ });
+
+ it('emits close when dialog is closed', async () => {
+ const wrapper = mount(AutoChangeStatusDialog, {
+ props: { isAutoChangeStatusDialogVisible: true }
+ });
+
+ await wrapper.get('[data-testid="dialog-close"]').trigger('click');
+
+ expect(wrapper.emitted('close')).toEqual([[]]);
+ });
+
+ it('handles auto accept switch and alone-condition radio changes', async () => {
+ const wrapper = mount(AutoChangeStatusDialog, {
+ props: { isAutoChangeStatusDialogVisible: true }
+ });
+
+ const autoAcceptSwitch = wrapper
+ .findAll('[data-testid="simple-switch"]')
+ .find((node) =>
+ node
+ .attributes('data-label')
+ ?.includes('view.settings.general.automation.auto_invite_request_accept')
+ );
+
+ await autoAcceptSwitch.find('.emit-false').trigger('click');
+ expect(mocks.generalStore.setAutoAcceptInviteRequests).toHaveBeenCalledWith('Off');
+
+ await autoAcceptSwitch.find('.emit-true').trigger('click');
+ expect(mocks.generalStore.setAutoAcceptInviteRequests).toHaveBeenCalledWith('All Favorites');
+
+ const noFriendsRadio = wrapper.findAll('[data-testid="radio-group"]')[0];
+ await noFriendsRadio.find('.emit-false').trigger('click');
+ expect(mocks.generalStore.setAutoStateChangeNoFriends).not.toHaveBeenCalled();
+
+ await noFriendsRadio.find('.emit-true').trigger('click');
+ expect(mocks.generalStore.setAutoStateChangeNoFriends).toHaveBeenCalledTimes(1);
+ });
+});