diff --git a/src/components/__tests__/AvatarInfo.test.js b/src/components/__tests__/AvatarInfo.test.js new file mode 100644 index 00000000..3994d6f9 --- /dev/null +++ b/src/components/__tests__/AvatarInfo.test.js @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createI18n } from 'vue-i18n'; +import { createTestingPinia } from '@pinia/testing'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; + +import AvatarInfo from '../AvatarInfo.vue'; +import en from '../../localization/en.json'; + +vi.mock('../../views/Feed/Feed.vue', () => ({ + default: { template: '
' } +})); +vi.mock('../../views/Feed/columns.jsx', () => ({ + columns: [] +})); +vi.mock('../../plugin/router', () => ({ + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() +})); +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }) + })) + }; +}); +vi.mock('../../plugin/interopApi', () => ({ + initInteropApi: vi.fn() +})); +vi.mock('../../service/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(null); + } + } + ) +})); +vi.mock('../../service/config', () => ({ + default: { + init: vi.fn(), + getString: vi + .fn() + .mockImplementation((_key, defaultValue) => defaultValue ?? '{}'), + setString: vi.fn(), + getBool: vi + .fn() + .mockImplementation((_key, defaultValue) => defaultValue ?? false), + setBool: vi.fn(), + getInt: vi + .fn() + .mockImplementation((_key, defaultValue) => defaultValue ?? 0), + setInt: vi.fn(), + getFloat: vi + .fn() + .mockImplementation((_key, defaultValue) => defaultValue ?? 0), + setFloat: vi.fn(), + getObject: vi.fn().mockReturnValue(null), + setObject: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + setArray: vi.fn(), + remove: vi.fn() + } +})); +vi.mock('../../service/jsonStorage', () => ({ + default: vi.fn() +})); +vi.mock('../../service/watchState', () => ({ + watchState: { isLoggedIn: false } +})); + +const i18n = createI18n({ + locale: 'en', + fallbackLocale: 'en', + legacy: false, + globalInjection: false, + missingWarn: false, + fallbackWarn: false, + messages: { en } +}); + +const stubs = { + TooltipWrapper: { + template: + '', + props: ['content'] + } +}; + +function mountAvatarInfo(props = {}, storeOverrides = {}) { + const pinia = createTestingPinia({ + stubActions: true, + initialState: { + Avatar: {}, + ...storeOverrides + } + }); + return mount(AvatarInfo, { + props, + global: { + plugins: [i18n, pinia], + stubs + } + }); +} + +describe('AvatarInfo.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('avatar name display', () => { + test('shows hintavatarname when hintownerid is provided', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + hintownerid: 'usr_owner_123', + hintavatarname: 'Cool Avatar' + }); + expect(wrapper.text()).toContain('Cool Avatar'); + }); + + test('shows empty when no imageurl', () => { + const wrapper = mountAvatarInfo({}); + expect(wrapper.text().trim()).toBe(''); + }); + + test('does not show hintavatarname if it is not a string', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + hintownerid: 'usr_owner_123', + hintavatarname: { notAString: true } + }); + // avatarName stays empty since hintavatarname is not a string + expect(wrapper.text()).not.toContain('notAString'); + }); + }); + + describe('avatar type (own vs public)', () => { + test('shows lock icon when owner matches userid (own avatar)', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + userid: 'usr_owner_123', + hintownerid: 'usr_owner_123', + hintavatarname: 'My Avatar' + }); + expect(wrapper.find('.lucide-lock').exists()).toBe(true); + }); + + test('does not show lock when owner differs from userid (public)', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + userid: 'usr_viewer_456', + hintownerid: 'usr_owner_123', + hintavatarname: 'Someone Avatar' + }); + expect(wrapper.find('.lucide-lock').exists()).toBe(false); + }); + + test('does not show lock when userid is undefined', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + hintownerid: 'usr_owner_123', + hintavatarname: 'Avatar' + }); + expect(wrapper.find('.lucide-lock').exists()).toBe(false); + }); + }); + + describe('avatar tags', () => { + test('displays tags with content_ prefix stripped', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + hintownerid: 'usr_123', + hintavatarname: 'Test', + avatartags: [ + 'content_horror', + 'content_gore', + 'content_adult_language' + ] + }); + expect(wrapper.text()).toContain('horror'); + expect(wrapper.text()).toContain('gore'); + expect(wrapper.text()).toContain('adult_language'); + expect(wrapper.text()).not.toContain('content_horror'); + }); + + test('does not show tags section when avatartags is empty', () => { + const wrapper = mountAvatarInfo({ + imageurl: 'https://example.com/avatar.png', + hintownerid: 'usr_123', + hintavatarname: 'Test' + }); + expect(wrapper.find('.tooltip').exists()).toBe(false); + }); + }); + + describe('click behavior', () => { + test('does not call showAvatarAuthorDialog when no imageurl', async () => { + const wrapper = mountAvatarInfo({}); + await wrapper.trigger('click'); + const { useAvatarStore } = await import('../../stores'); + const avatarStore = useAvatarStore(); + expect(avatarStore.showAvatarAuthorDialog).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/composables/useTableHeight.js b/src/composables/useTableHeight.js deleted file mode 100644 index 880a1a16..00000000 --- a/src/composables/useTableHeight.js +++ /dev/null @@ -1,44 +0,0 @@ -import { onMounted, onUnmounted, ref } from 'vue'; - -export function useTableHeight(tableRef, options = {}) { - const containerRef = ref(null); - const offset = options.offset ?? 127; - const immediate = options.immediate ?? true; - - let resizeObserver; - - const setTableHeight = () => { - if (!tableRef?.value || !containerRef.value) { - return; - } - - tableRef.value.tableProps = { - ...(tableRef.value.tableProps || {}), - // @ts-ignore default is null - height: containerRef.value.clientHeight - offset - }; - }; - - onMounted(() => { - if (immediate) { - setTableHeight(); - } - - resizeObserver = new ResizeObserver(() => { - setTableHeight(); - }); - - if (containerRef.value) { - resizeObserver.observe(containerRef.value); - } - }); - - onUnmounted(() => { - resizeObserver?.disconnect(); - }); - - return { - containerRef, - setTableHeight - }; -} diff --git a/src/shared/utils/__tests__/avatar.test.js b/src/shared/utils/__tests__/avatar.test.js new file mode 100644 index 00000000..b1cd9af6 --- /dev/null +++ b/src/shared/utils/__tests__/avatar.test.js @@ -0,0 +1,189 @@ +import { describe, expect, test, vi } from 'vitest'; +import { ref } from 'vue'; + +import { getPlatformInfo, parseAvatarUrl, storeAvatarImage } from '../avatar'; + +vi.mock('../../../views/Feed/Feed.vue', () => ({ + default: { template: '
' } +})); +vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] })); +vi.mock('../../../plugin/router', () => ({ + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() +})); + +describe('storeAvatarImage', () => { + function makeArgs(name, ownerId, createdAt = '2024-01-01T00:00:00Z') { + return { + params: { fileId: 'file_abc123' }, + json: { + name, + ownerId, + versions: [{ created_at: createdAt }] + } + }; + } + + test('extracts avatar name from standard image file name', () => { + const cache = new Map(); + const result = storeAvatarImage( + makeArgs('Avatar - Cool Robot - Image - 2024.01.01', 'usr_owner1'), + cache + ); + expect(result.avatarName).toBe('Cool Robot'); + expect(result.ownerId).toBe('usr_owner1'); + expect(result.fileCreatedAt).toBe('2024-01-01T00:00:00Z'); + }); + + test('stores result in cachedAvatarNames map', () => { + const cache = new Map(); + storeAvatarImage( + makeArgs('Avatar - Test - Image - x', 'usr_123'), + cache + ); + expect(cache.has('file_abc123')).toBe(true); + expect(cache.get('file_abc123').avatarName).toBe('Test'); + }); + + test('returns empty avatarName when name does not match pattern', () => { + const cache = new Map(); + const result = storeAvatarImage( + makeArgs('SomeOtherFileName.png', 'usr_456'), + cache + ); + expect(result.avatarName).toBe(''); + }); + + test('handles special characters in avatar name', () => { + const cache = new Map(); + const result = storeAvatarImage( + makeArgs('Avatar - ★ Fancy (Name) ★ - Image - v1', 'usr_789'), + cache + ); + expect(result.avatarName).toContain('Fancy'); + }); +}); + +describe('parseAvatarUrl', () => { + test('extracts avatar ID from valid avatar URL', () => { + const result = parseAvatarUrl( + 'https://api.vrchat.cloud/file/avatar/avtr_12345-abcde' + ); + expect(result).toBe('avtr_12345-abcde'); + }); + + test('returns null for non-avatar URL', () => { + const result = parseAvatarUrl( + 'https://api.vrchat.cloud/api/1/worlds/wrld_12345' + ); + expect(result).toBeNull(); + }); + + test('returns null for unrelated URL', () => { + const result = parseAvatarUrl('https://example.com/something'); + expect(result).toBeNull(); + }); +}); + +describe('getPlatformInfo', () => { + test('separates packages by platform', () => { + const packages = [ + { + platform: 'standalonewindows', + performanceRating: 'Good', + variant: 'standard' + }, + { + platform: 'android', + performanceRating: 'Medium', + variant: 'standard' + }, + { platform: 'ios', performanceRating: 'Poor', variant: 'standard' } + ]; + const result = getPlatformInfo(packages); + expect(result.pc.platform).toBe('standalonewindows'); + expect(result.android.platform).toBe('android'); + expect(result.ios.platform).toBe('ios'); + }); + + test('skips non-standard/non-security variants', () => { + const packages = [ + { + platform: 'standalonewindows', + performanceRating: 'Good', + variant: 'impostor' + } + ]; + const result = getPlatformInfo(packages); + expect(result.pc).toEqual({}); + }); + + test('allows standard and security variants', () => { + const packages = [ + { + platform: 'standalonewindows', + performanceRating: 'Good', + variant: 'security' + } + ]; + const result = getPlatformInfo(packages); + expect(result.pc.platform).toBe('standalonewindows'); + }); + + test('skips None performanceRating if platform already has a rating', () => { + const packages = [ + { + platform: 'standalonewindows', + performanceRating: 'Good', + variant: 'standard' + }, + { + platform: 'standalonewindows', + performanceRating: 'None', + variant: 'standard' + } + ]; + const result = getPlatformInfo(packages); + expect(result.pc.performanceRating).toBe('Good'); + }); + + test('accepts None performanceRating when no prior entry exists', () => { + const packages = [ + { + platform: 'android', + performanceRating: 'None', + variant: 'standard' + } + ]; + const result = getPlatformInfo(packages); + expect(result.android.performanceRating).toBe('None'); + }); + + test('returns empty objects when input is undefined', () => { + const result = getPlatformInfo(undefined); + expect(result.pc).toEqual({}); + expect(result.android).toEqual({}); + expect(result.ios).toEqual({}); + }); + + test('returns empty objects for empty array', () => { + const result = getPlatformInfo([]); + expect(result.pc).toEqual({}); + expect(result.android).toEqual({}); + expect(result.ios).toEqual({}); + }); + + test('allows packages without variant (undefined)', () => { + const packages = [ + { platform: 'standalonewindows', performanceRating: 'Good' } + ]; + const result = getPlatformInfo(packages); + expect(result.pc.platform).toBe('standalonewindows'); + }); +}); diff --git a/src/stores/__tests__/launch.test.js b/src/stores/__tests__/launch.test.js new file mode 100644 index 00000000..8f2a06e7 --- /dev/null +++ b/src/stores/__tests__/launch.test.js @@ -0,0 +1,196 @@ +/* eslint-disable pretty-import/sort-import-groups */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { ref } from 'vue'; + +import en from '../../localization/en.json'; + +vi.mock('../../views/Feed/Feed.vue', () => ({ + default: { template: '
' } +})); +vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] })); +vi.mock('../../plugin/router', () => ({ + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() +})); +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }) + })) + }; +}); +vi.mock('../../plugin/interopApi', () => ({ initInteropApi: vi.fn() })); +vi.mock('../../service/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(null); + } + } + ) +})); +vi.mock('../../service/config', () => ({ + default: { + init: vi.fn(), + getString: vi.fn().mockResolvedValue(''), + setString: vi.fn(), + getBool: vi.fn().mockImplementation((_k, d) => d ?? false), + setBool: vi.fn(), + getInt: vi.fn().mockImplementation((_k, d) => d ?? 0), + setInt: vi.fn(), + getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0), + setFloat: vi.fn(), + getObject: vi.fn().mockReturnValue(null), + setObject: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + setArray: vi.fn(), + remove: vi.fn() + } +})); +vi.mock('../../service/jsonStorage', () => ({ default: vi.fn() })); +vi.mock('../../service/watchState', () => ({ + watchState: { isLoggedIn: false } +})); +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal(); + const i18n = actual.createI18n({ + locale: 'en', + fallbackLocale: 'en', + legacy: false, + missingWarn: false, + fallbackWarn: false, + messages: { en } + }); + return { + ...actual, + useI18n: () => i18n.global + }; +}); + +const mockGetInstanceShortName = vi.fn(); +vi.mock('../../api', () => ({ + instanceRequest: { + getInstanceShortName: (...args) => mockGetInstanceShortName(...args), + selfInvite: vi.fn().mockResolvedValue({}) + }, + miscRequest: {} +})); +vi.mock('vue-sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn() + } +})); + +import { useLaunchStore } from '../launch'; + +describe('useLaunchStore', () => { + let store; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useLaunchStore(); + vi.clearAllMocks(); + }); + + describe('showLaunchDialog', () => { + test('sets dialog visible with tag and shortName', async () => { + await store.showLaunchDialog( + 'wrld_123:456~friends(usr_abc)', + 'abc' + ); + + expect(store.launchDialogData.visible).toBe(true); + expect(store.launchDialogData.tag).toBe( + 'wrld_123:456~friends(usr_abc)' + ); + expect(store.launchDialogData.shortName).toBe('abc'); + }); + + test('defaults shortName to null', async () => { + await store.showLaunchDialog('wrld_123:456'); + + expect(store.launchDialogData.shortName).toBeNull(); + }); + }); + + describe('showLaunchOptions', () => { + test('sets isLaunchOptionsDialogVisible to true', () => { + expect(store.isLaunchOptionsDialogVisible).toBe(false); + store.showLaunchOptions(); + expect(store.isLaunchOptionsDialogVisible).toBe(true); + }); + }); + + describe('getLaunchUrl', () => { + test('uses provided shortName for non-public instance', async () => { + const url = await store.getLaunchUrl( + 'wrld_123:456~friends(usr_abc)', + 'myShort' + ); + expect(url).toBe( + 'vrchat://launch?ref=vrcx.app&id=wrld_123:456~friends(usr_abc)&shortName=myShort' + ); + expect(mockGetInstanceShortName).not.toHaveBeenCalled(); + }); + + test('fetches shortName from API when not provided', async () => { + mockGetInstanceShortName.mockResolvedValue({ + json: { shortName: 'fetched123' } + }); + + const url = await store.getLaunchUrl( + 'wrld_123:456~friends(usr_abc)', + '' + ); + expect(url).toContain('shortName=fetched123'); + expect(mockGetInstanceShortName).toHaveBeenCalled(); + }); + + test('uses secureName as fallback when shortName is empty', async () => { + mockGetInstanceShortName.mockResolvedValue({ + json: { shortName: '', secureName: 'secure456' } + }); + + const url = await store.getLaunchUrl( + 'wrld_123:456~friends(usr_abc)', + '' + ); + expect(url).toContain('shortName=secure456'); + }); + + test('omits shortName when API returns nothing', async () => { + mockGetInstanceShortName.mockResolvedValue({ json: null }); + + const url = await store.getLaunchUrl( + 'wrld_123:456~friends(usr_abc)', + '' + ); + expect(url).toBe( + 'vrchat://launch?ref=vrcx.app&id=wrld_123:456~friends(usr_abc)' + ); + expect(url).not.toContain('shortName'); + }); + + test('calls API for public instances without shortName', async () => { + mockGetInstanceShortName.mockResolvedValue({ json: null }); + await store.getLaunchUrl('wrld_123:456', ''); + expect(mockGetInstanceShortName).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/stores/__tests__/modal.test.js b/src/stores/__tests__/modal.test.js new file mode 100644 index 00000000..847aa395 --- /dev/null +++ b/src/stores/__tests__/modal.test.js @@ -0,0 +1,323 @@ +/* eslint-disable pretty-import/sort-import-groups */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { ref } from 'vue'; + +import en from '../../localization/en.json'; + +vi.mock('../../views/Feed/Feed.vue', () => ({ + default: { template: '
' } +})); +vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] })); +vi.mock('../../plugin/router', () => ({ + router: { + beforeEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }), + isReady: vi.fn().mockResolvedValue(true) + }, + initRouter: vi.fn() +})); +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + currentRoute: ref({ path: '/', name: '', meta: {} }) + })) + }; +}); +vi.mock('../../plugin/interopApi', () => ({ initInteropApi: vi.fn() })); +vi.mock('../../service/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(null); + } + } + ) +})); +vi.mock('../../service/config', () => ({ + default: { + init: vi.fn(), + getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'), + setString: vi.fn(), + getBool: vi.fn().mockImplementation((_k, d) => d ?? false), + setBool: vi.fn(), + getInt: vi.fn().mockImplementation((_k, d) => d ?? 0), + setInt: vi.fn(), + getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0), + setFloat: vi.fn(), + getObject: vi.fn().mockReturnValue(null), + setObject: vi.fn(), + getArray: vi.fn().mockReturnValue([]), + setArray: vi.fn(), + remove: vi.fn() + } +})); +vi.mock('../../service/jsonStorage', () => ({ default: vi.fn() })); +vi.mock('../../service/watchState', () => ({ + watchState: { isLoggedIn: false } +})); +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal(); + const i18n = actual.createI18n({ + locale: 'en', + fallbackLocale: 'en', + legacy: false, + missingWarn: false, + fallbackWarn: false, + messages: { en } + }); + return { + ...actual, + useI18n: () => i18n.global + }; +}); + +import { useModalStore } from '../modal'; + +describe('useModalStore', () => { + let store; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useModalStore(); + }); + + describe('confirm', () => { + test('opens dialog and resolves ok:true on handleOk', async () => { + const promise = store.confirm({ + title: 'Delete?', + description: 'Are you sure?' + }); + + expect(store.alertOpen).toBe(true); + expect(store.alertMode).toBe('confirm'); + expect(store.alertTitle).toBe('Delete?'); + expect(store.alertDescription).toBe('Are you sure?'); + + store.handleOk(); + + const result = await promise; + expect(result.ok).toBe(true); + expect(result.reason).toBe('ok'); + expect(store.alertOpen).toBe(false); + }); + + test('resolves ok:false on handleCancel', async () => { + const promise = store.confirm({ + title: 'Test', + description: 'desc' + }); + store.handleCancel(); + + const result = await promise; + expect(result.ok).toBe(false); + expect(result.reason).toBe('cancel'); + expect(store.alertOpen).toBe(false); + }); + + test('resolves ok:false with reason dismiss on handleDismiss', async () => { + const promise = store.confirm({ + title: 'Test', + description: 'desc' + }); + store.handleDismiss(); + + const result = await promise; + expect(result.ok).toBe(false); + expect(result.reason).toBe('dismiss'); + }); + + test('does not dismiss when dismissible is false', async () => { + const promise = store.confirm({ + title: 'Test', + description: 'desc', + dismissible: false + }); + + expect(store.alertDismissible).toBe(false); + store.handleDismiss(); + expect(store.alertOpen).toBe(true); + + store.handleCancel(); + await promise; + }); + + test('uses custom confirmText and cancelText', () => { + store.confirm({ + title: 'T', + description: 'D', + confirmText: 'Yes', + cancelText: 'No' + }); + + expect(store.alertOkText).toBe('Yes'); + expect(store.alertCancelText).toBe('No'); + + store.handleCancel(); + }); + + test('replaces previous dialog with reason replaced', async () => { + const first = store.confirm({ + title: 'First', + description: 'first desc' + }); + const second = store.confirm({ + title: 'Second', + description: 'second desc' + }); + + const firstResult = await first; + expect(firstResult.ok).toBe(false); + expect(firstResult.reason).toBe('replaced'); + + expect(store.alertTitle).toBe('Second'); + expect(store.alertOpen).toBe(true); + + store.handleOk(); + const secondResult = await second; + expect(secondResult.ok).toBe(true); + }); + }); + + describe('alert', () => { + test('opens in alert mode with only ok button text', () => { + store.alert({ + title: 'Notice', + description: 'Something happened' + }); + + expect(store.alertMode).toBe('alert'); + expect(store.alertCancelText).toBe(''); + expect(store.alertOpen).toBe(true); + + store.handleOk(); + }); + + test('handleCancel resolves as ok for alert mode', async () => { + const promise = store.alert({ + title: 'Notice', + description: 'desc' + }); + store.handleCancel(); + + const result = await promise; + expect(result.ok).toBe(true); + expect(result.reason).toBe('ok'); + }); + + test('handleDismiss resolves as ok for alert mode', async () => { + const promise = store.alert({ + title: 'Notice', + description: 'desc' + }); + store.handleDismiss(); + + const result = await promise; + expect(result.ok).toBe(true); + expect(result.reason).toBe('ok'); + }); + }); + + describe('prompt', () => { + test('opens prompt dialog with input value', () => { + store.prompt({ + title: 'Enter name', + description: 'New name', + inputValue: 'default' + }); + + expect(store.promptOpen).toBe(true); + expect(store.promptTitle).toBe('Enter name'); + expect(store.promptInputValue).toBe('default'); + + store.handlePromptCancel(''); + }); + + test('resolves with value on handlePromptOk', async () => { + const promise = store.prompt({ + title: 'T', + description: 'D' + }); + + store.handlePromptOk('myValue'); + + const result = await promise; + expect(result.ok).toBe(true); + expect(result.reason).toBe('ok'); + expect(result.value).toBe('myValue'); + expect(store.promptOpen).toBe(false); + }); + + test('resolves with value on handlePromptCancel', async () => { + const promise = store.prompt({ + title: 'T', + description: 'D', + inputValue: 'initial' + }); + + store.handlePromptCancel('initial'); + + const result = await promise; + expect(result.ok).toBe(false); + expect(result.reason).toBe('cancel'); + expect(result.value).toBe('initial'); + }); + + test('sets pattern and errorMessage', () => { + store.prompt({ + title: 'T', + description: 'D', + pattern: /^\d+$/, + errorMessage: 'Numbers only' + }); + + expect(store.promptPattern).toEqual(/^\d+$/); + expect(store.promptErrorMessage).toBe('Numbers only'); + + store.handlePromptCancel(''); + }); + + test('replaces previous prompt with reason replaced', async () => { + const first = store.prompt({ + title: 'First', + description: 'D', + inputValue: 'a' + }); + const second = store.prompt({ + title: 'Second', + description: 'D', + inputValue: 'b' + }); + + const firstResult = await first; + expect(firstResult.ok).toBe(false); + expect(firstResult.reason).toBe('replaced'); + expect(firstResult.value).toBe('a'); + + store.handlePromptOk('b'); + const secondResult = await second; + expect(secondResult.ok).toBe(true); + }); + }); + + describe('no-op when no pending', () => { + test('handleOk does nothing without pending dialog', () => { + store.handleOk(); + expect(store.alertOpen).toBe(false); + }); + + test('handlePromptOk does nothing without pending prompt', () => { + store.handlePromptOk('test'); + expect(store.promptOpen).toBe(false); + }); + }); +}); diff --git a/src/stores/modal.js b/src/stores/modal.js index 582a5e14..2fde57bf 100644 --- a/src/stores/modal.js +++ b/src/stores/modal.js @@ -1,14 +1,6 @@ import { defineStore } from 'pinia'; -import { i18n } from '@/plugin'; import { ref } from 'vue'; - -function translate(key, fallback) { - try { - return i18n.global.t(key); - } catch { - return fallback; - } -} +import { useI18n } from 'vue-i18n'; /** * @typedef {Object} ConfirmResult @@ -53,9 +45,9 @@ function translate(key, fallback) { * @property {boolean=} dismissible */ -// TODO: Method chains for confirm - export const useModalStore = defineStore('Modal', () => { + const { t } = useI18n(); + const alertOpen = ref(false); const alertMode = ref('confirm'); // 'confirm' | 'alert' const alertTitle = ref(''); @@ -146,15 +138,13 @@ export const useModalStore = defineStore('Modal', () => { if (mode === 'alert') { alertOkText.value = - options.confirmText || translate('dialog.alertdialog.ok', 'OK'); + options.confirmText || t('dialog.alertdialog.ok'); alertCancelText.value = ''; } else { alertOkText.value = - options.confirmText || - translate('dialog.alertdialog.confirm', 'Confirm'); + options.confirmText || t('dialog.alertdialog.confirm'); alertCancelText.value = - options.cancelText || - translate('dialog.alertdialog.cancel', 'Cancel'); + options.cancelText || t('dialog.alertdialog.cancel'); } alertOpen.value = true; @@ -186,15 +176,12 @@ export const useModalStore = defineStore('Modal', () => { promptInputType.value = options.inputType || 'text'; promptPattern.value = options.pattern ?? null; promptErrorMessage.value = - options.errorMessage || - translate('dialog.prompt.input_invalid', '输入错误'); + options.errorMessage || t('dialog.prompt.input_invalid'); promptOkText.value = - options.confirmText || - translate('dialog.alertdialog.confirm', 'Confirm'); + options.confirmText || t('dialog.alertdialog.confirm'); promptCancelText.value = - options.cancelText || - translate('dialog.alertdialog.cancel', 'Cancel'); + options.cancelText || t('dialog.alertdialog.cancel'); promptOpen.value = true;