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: '