mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
add test
This commit is contained in:
@@ -80,6 +80,11 @@ export default defineConfig([
|
||||
config: 'flat/recommended'
|
||||
}),
|
||||
{
|
||||
ignores: [
|
||||
'**/__tests__/**',
|
||||
'**/*.spec.{js,mjs,cjs,vue}',
|
||||
'**/*.test.{js,mjs,cjs,vue}'
|
||||
],
|
||||
plugins: { 'pretty-import': prettyImport },
|
||||
rules: {
|
||||
'pretty-import/separate-type-imports': 'warn',
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getGroupName, getWorldName, parseLocation, resolveRegion, translateAccessType } from '../shared/utils';
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
useGroupStore,
|
||||
@@ -42,7 +43,6 @@
|
||||
useSearchStore,
|
||||
useWorldStore
|
||||
} from '../stores';
|
||||
import { getGroupName, getWorldName, parseLocation } from '../shared/utils';
|
||||
import { Spinner } from './ui/spinner';
|
||||
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function currentInstanceId() {
|
||||
if (typeof props.traveling !== 'undefined' && props.location === 'traveling') {
|
||||
return props.traveling;
|
||||
@@ -125,6 +128,9 @@
|
||||
return props.location;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function resetState() {
|
||||
text.value = '';
|
||||
region.value = '';
|
||||
@@ -135,6 +141,9 @@
|
||||
instanceName.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function parse() {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
@@ -159,6 +168,10 @@
|
||||
strict.value = L.strict;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function applyInstanceRef(L) {
|
||||
const instanceRef = cachedInstances.get(L.tag);
|
||||
if (typeof instanceRef === 'undefined') {
|
||||
@@ -173,6 +186,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
* @param instanceId
|
||||
*/
|
||||
function updateGroupName(L, instanceId) {
|
||||
if (props.grouphint) {
|
||||
groupName.value = props.grouphint;
|
||||
@@ -189,18 +207,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function updateRegion(L) {
|
||||
region.value = '';
|
||||
if (!L.isOffline && !L.isPrivate && !L.isTraveling) {
|
||||
region.value = L.region;
|
||||
if (!L.region && L.instanceId) {
|
||||
region.value = 'us';
|
||||
}
|
||||
}
|
||||
region.value = resolveRegion(L);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param accessTypeName
|
||||
*/
|
||||
function getAccessTypeLabel(accessTypeName) {
|
||||
return translateAccessType(accessTypeName, t, accessTypeLocaleKeyMap);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function setText(L) {
|
||||
const accessTypeLabel = translateAccessType(L.accessTypeName);
|
||||
const accessTypeLabel = getAccessTypeLabel(L.accessTypeName);
|
||||
|
||||
if (L.isOffline) {
|
||||
text.value = t('location.offline');
|
||||
@@ -225,7 +253,7 @@
|
||||
getWorldName(L.worldId).then((name) => {
|
||||
if (!isDisposed && name && currentInstanceId() === L.tag) {
|
||||
if (L.instanceId) {
|
||||
text.value = `${name} · ${translateAccessType(L.accessTypeName)}`;
|
||||
text.value = `${name} · ${getAccessTypeLabel(L.accessTypeName)}`;
|
||||
} else {
|
||||
text.value = name;
|
||||
}
|
||||
@@ -239,18 +267,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function translateAccessType(accessTypeNameRaw) {
|
||||
const key = accessTypeLocaleKeyMap[accessTypeNameRaw];
|
||||
if (!key) {
|
||||
return accessTypeNameRaw;
|
||||
}
|
||||
if (accessTypeNameRaw === 'groupPublic' || accessTypeNameRaw === 'groupPlus') {
|
||||
const groupKey = accessTypeLocaleKeyMap['group'];
|
||||
return t(groupKey) + ' ' + t(key);
|
||||
}
|
||||
return t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowWorldDialog() {
|
||||
if (props.link) {
|
||||
let instanceId = currentInstanceId();
|
||||
@@ -266,6 +285,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowGroupDialog() {
|
||||
let location = currentInstanceId();
|
||||
if (!location) {
|
||||
|
||||
@@ -356,6 +356,7 @@
|
||||
useUiStore,
|
||||
useVRCXUpdaterStore
|
||||
} from '../stores';
|
||||
import { getFirstNavRoute, isEntryNotified, normalizeHiddenKeys, sanitizeLayout } from './navMenuUtils';
|
||||
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
|
||||
import { openExternalLink } from '../shared/utils';
|
||||
|
||||
@@ -418,23 +419,6 @@
|
||||
];
|
||||
|
||||
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
||||
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
|
||||
|
||||
const normalizeHiddenKeys = (hiddenKeys = []) => {
|
||||
if (!Array.isArray(hiddenKeys)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
hiddenKeys.forEach((key) => {
|
||||
if (!key || seen.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
});
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const VRCXUpdaterStore = useVRCXUpdaterStore();
|
||||
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
||||
@@ -498,7 +482,7 @@
|
||||
const currentRouteName = currentRoute?.name;
|
||||
const navKey = currentRoute?.meta?.navKey || currentRouteName;
|
||||
if (!navKey) {
|
||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
||||
return getFirstNavRouteLocal(navLayout.value) || 'feed';
|
||||
}
|
||||
|
||||
for (const entry of navLayout.value) {
|
||||
@@ -510,7 +494,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
||||
return getFirstNavRouteLocal(navLayout.value) || 'feed';
|
||||
});
|
||||
|
||||
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
|
||||
@@ -553,90 +537,8 @@
|
||||
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
||||
};
|
||||
|
||||
const sanitizeLayout = (layout, hiddenKeys = []) => {
|
||||
const usedKeys = new Set();
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const hiddenSet = new Set(normalizedHiddenKeys);
|
||||
const normalized = [];
|
||||
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
||||
|
||||
const appendItemEntry = (key, target = normalized) => {
|
||||
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
target.push({ type: 'item', key });
|
||||
usedKeys.add(key);
|
||||
};
|
||||
|
||||
const appendChartsFolder = (target = normalized) => {
|
||||
if (chartsKeys.some((key) => usedKeys.has(key))) {
|
||||
return;
|
||||
}
|
||||
if (!chartsKeys.every((key) => navDefinitionMap.has(key))) {
|
||||
return;
|
||||
}
|
||||
chartsKeys.forEach((key) => usedKeys.add(key));
|
||||
target.push({
|
||||
type: 'folder',
|
||||
id: 'default-folder-charts',
|
||||
nameKey: 'nav_tooltip.charts',
|
||||
name: t('nav_tooltip.charts'),
|
||||
icon: 'ri-pie-chart-line',
|
||||
items: [...chartsKeys]
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(layout)) {
|
||||
layout.forEach((entry) => {
|
||||
if (entry?.type === 'item') {
|
||||
if (entry.key === 'charts') {
|
||||
appendChartsFolder();
|
||||
return;
|
||||
}
|
||||
appendItemEntry(entry.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry?.type === 'folder') {
|
||||
const folderItems = [];
|
||||
(entry.items || []).forEach((key) => {
|
||||
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
folderItems.push(key);
|
||||
usedKeys.add(key);
|
||||
});
|
||||
|
||||
if (folderItems.length >= 1) {
|
||||
const folderNameKey = entry.nameKey || null;
|
||||
const folderName = folderNameKey ? t(folderNameKey) : entry.name || '';
|
||||
normalized.push({
|
||||
type: 'folder',
|
||||
id: entry.id || generateFolderId(),
|
||||
name: folderName,
|
||||
nameKey: folderNameKey,
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
items: folderItems
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navDefinitions.forEach((item) => {
|
||||
if (!usedKeys.has(item.key) && !hiddenSet.has(item.key)) {
|
||||
if (chartsKeys.includes(item.key)) {
|
||||
return;
|
||||
}
|
||||
appendItemEntry(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
if (!chartsKeys.some((key) => usedKeys.has(key)) && !chartsKeys.some((key) => hiddenSet.has(key))) {
|
||||
appendChartsFolder();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
|
||||
return sanitizeLayout(layout, hiddenKeys, navDefinitionMap, navDefinitions, t, generateFolderId);
|
||||
};
|
||||
|
||||
const themeDisplayName = (themeKey) => {
|
||||
@@ -693,10 +595,10 @@
|
||||
|
||||
const customNavDialogVisible = ref(false);
|
||||
const navHiddenKeys = ref([]);
|
||||
const defaultNavLayout = computed(() => sanitizeLayout(createDefaultNavLayout(), []));
|
||||
const defaultNavLayout = computed(() => sanitizeLayoutLocal(createDefaultNavLayout(), []));
|
||||
|
||||
const saveNavLayout = async (layout, hiddenKeys = []) => {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
|
||||
try {
|
||||
await configRepository.setString(
|
||||
'VRCX_customNavMenuLayoutList',
|
||||
@@ -715,8 +617,8 @@
|
||||
};
|
||||
|
||||
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const sanitized = sanitizeLayout(layout, normalizedHiddenKeys);
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
|
||||
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
|
||||
navLayout.value = sanitized;
|
||||
navHiddenKeys.value = normalizedHiddenKeys;
|
||||
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||
@@ -740,9 +642,9 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nav', error);
|
||||
} finally {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData);
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData, navDefinitionMap);
|
||||
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
||||
const sanitized = sanitizeLayout(fallbackLayout, normalizedHiddenKeys);
|
||||
const sanitized = sanitizeLayoutLocal(fallbackLayout, normalizedHiddenKeys);
|
||||
navLayout.value = sanitized;
|
||||
navHiddenKeys.value = normalizedHiddenKeys;
|
||||
if (
|
||||
@@ -764,26 +666,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const isEntryNotified = (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const targets = [];
|
||||
if (entry.index) {
|
||||
targets.push(entry.index);
|
||||
}
|
||||
if (entry.routeName) {
|
||||
targets.push(entry.routeName);
|
||||
}
|
||||
if (entry.path) {
|
||||
const lastSegment = entry.path.split('/').pop();
|
||||
if (lastSegment) {
|
||||
targets.push(lastSegment);
|
||||
}
|
||||
}
|
||||
return targets.some((key) => notifiedMenus.value.includes(key));
|
||||
};
|
||||
|
||||
const isNavItemNotified = (item) => {
|
||||
if (!item) {
|
||||
return false;
|
||||
@@ -792,7 +674,7 @@
|
||||
return true;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
return item.children.some((entry) => isEntryNotified(entry));
|
||||
return item.children.some((entry) => isEntryNotified(entry, notifiedMenus.value));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -828,30 +710,14 @@
|
||||
*
|
||||
* @param layout
|
||||
*/
|
||||
function getFirstNavRoute(layout) {
|
||||
for (const entry of layout) {
|
||||
if (entry.type === 'item') {
|
||||
const definition = navDefinitionMap.get(entry.key);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
if (entry.type === 'folder' && entry.items?.length) {
|
||||
const definition = entry.items.map((key) => navDefinitionMap.get(key)).find((def) => def?.routeName);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const getFirstNavRouteLocal = (layout) => getFirstNavRoute(layout, navDefinitionMap);
|
||||
|
||||
let hasNavigatedToInitialRoute = false;
|
||||
const navigateToFirstNavEntry = () => {
|
||||
if (hasNavigatedToInitialRoute) {
|
||||
return;
|
||||
}
|
||||
const firstRoute = getFirstNavRoute(navLayout.value);
|
||||
const firstRoute = getFirstNavRouteLocal(navLayout.value);
|
||||
if (!firstRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
311
src/components/__tests__/navMenuUtils.test.js
Normal file
311
src/components/__tests__/navMenuUtils.test.js
Normal file
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
getFirstNavRoute,
|
||||
isEntryNotified,
|
||||
normalizeHiddenKeys,
|
||||
sanitizeLayout
|
||||
} from '../navMenuUtils';
|
||||
|
||||
// Minimal nav definitions for testing
|
||||
const testDefinitions = [
|
||||
{ key: 'feed', routeName: 'feed' },
|
||||
{ key: 'search', routeName: 'search' },
|
||||
{ key: 'tools', routeName: 'tools' },
|
||||
{ key: 'charts-instance', routeName: 'charts-instance' },
|
||||
{ key: 'charts-mutual', routeName: 'charts-mutual' },
|
||||
{ key: 'notification', routeName: 'notification' },
|
||||
{ key: 'direct-access', action: 'direct-access' }
|
||||
];
|
||||
const testDefinitionMap = new Map(testDefinitions.map((d) => [d.key, d]));
|
||||
const mockT = (key) => `translated:${key}`;
|
||||
const mockGenerateFolderId = () => 'generated-folder-id';
|
||||
|
||||
// ─── normalizeHiddenKeys ─────────────────────────────────────────────
|
||||
|
||||
describe('normalizeHiddenKeys', () => {
|
||||
test('returns empty array for non-array input', () => {
|
||||
expect(normalizeHiddenKeys(null, testDefinitionMap)).toEqual([]);
|
||||
expect(normalizeHiddenKeys(undefined, testDefinitionMap)).toEqual([]);
|
||||
expect(normalizeHiddenKeys('string', testDefinitionMap)).toEqual([]);
|
||||
expect(normalizeHiddenKeys(42, testDefinitionMap)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array for empty array', () => {
|
||||
expect(normalizeHiddenKeys([], testDefinitionMap)).toEqual([]);
|
||||
});
|
||||
|
||||
test('filters out invalid keys', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(
|
||||
['feed', 'nonexistent', 'search'],
|
||||
testDefinitionMap
|
||||
)
|
||||
).toEqual(['feed', 'search']);
|
||||
});
|
||||
|
||||
test('deduplicates keys', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(['feed', 'feed', 'search'], testDefinitionMap)
|
||||
).toEqual(['feed', 'search']);
|
||||
});
|
||||
|
||||
test('filters out falsy values', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(
|
||||
[null, '', undefined, 'feed'],
|
||||
testDefinitionMap
|
||||
)
|
||||
).toEqual(['feed']);
|
||||
});
|
||||
|
||||
test('preserves order of valid keys', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(['tools', 'feed', 'search'], testDefinitionMap)
|
||||
).toEqual(['tools', 'feed', 'search']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getFirstNavRoute ────────────────────────────────────────────────
|
||||
|
||||
describe('getFirstNavRoute', () => {
|
||||
test('returns null for empty layout', () => {
|
||||
expect(getFirstNavRoute([], testDefinitionMap)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns first item routeName', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('feed');
|
||||
});
|
||||
|
||||
test('skips items without routeName', () => {
|
||||
const layout = [
|
||||
{ type: 'item', key: 'direct-access' },
|
||||
{ type: 'item', key: 'search' }
|
||||
];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('search');
|
||||
});
|
||||
|
||||
test('returns route from folder items', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
items: ['feed', 'search']
|
||||
}
|
||||
];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('feed');
|
||||
});
|
||||
|
||||
test('returns null when no routable items exist', () => {
|
||||
const layout = [{ type: 'item', key: 'direct-access' }];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for unknown keys', () => {
|
||||
const layout = [{ type: 'item', key: 'unknown' }];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBeNull();
|
||||
});
|
||||
|
||||
test('checks folder items for routable entry', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
items: ['direct-access', 'tools']
|
||||
}
|
||||
];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('tools');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEntryNotified ─────────────────────────────────────────────────
|
||||
|
||||
describe('isEntryNotified', () => {
|
||||
test('returns false for null/undefined entry', () => {
|
||||
expect(isEntryNotified(null, ['feed'])).toBe(false);
|
||||
expect(isEntryNotified(undefined, ['feed'])).toBe(false);
|
||||
});
|
||||
|
||||
test('matches by index', () => {
|
||||
const entry = { index: 'feed' };
|
||||
expect(isEntryNotified(entry, ['feed', 'search'])).toBe(true);
|
||||
});
|
||||
|
||||
test('matches by routeName', () => {
|
||||
const entry = { routeName: 'search' };
|
||||
expect(isEntryNotified(entry, ['search'])).toBe(true);
|
||||
});
|
||||
|
||||
test('matches by path last segment', () => {
|
||||
const entry = { path: '/app/settings' };
|
||||
expect(isEntryNotified(entry, ['settings'])).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when no match', () => {
|
||||
const entry = { index: 'feed', routeName: 'feed' };
|
||||
expect(isEntryNotified(entry, ['search', 'tools'])).toBe(false);
|
||||
});
|
||||
|
||||
test('matches any of multiple targets', () => {
|
||||
const entry = {
|
||||
index: 'feed',
|
||||
routeName: 'home',
|
||||
path: '/app/dashboard'
|
||||
};
|
||||
expect(isEntryNotified(entry, ['dashboard'])).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for empty notifiedMenus', () => {
|
||||
const entry = { index: 'feed' };
|
||||
expect(isEntryNotified(entry, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizeLayout ──────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeLayout', () => {
|
||||
const runSanitize = (layout, hiddenKeys = []) =>
|
||||
sanitizeLayout(
|
||||
layout,
|
||||
hiddenKeys,
|
||||
testDefinitionMap,
|
||||
testDefinitions,
|
||||
mockT,
|
||||
mockGenerateFolderId
|
||||
);
|
||||
|
||||
test('returns default items for null/undefined layout', () => {
|
||||
const result = runSanitize(null);
|
||||
// Should include all non-chart items + charts folder
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.some((e) => e.type === 'item' && e.key === 'feed')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves valid item entries', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout);
|
||||
expect(result[0]).toEqual({ type: 'item', key: 'feed' });
|
||||
});
|
||||
|
||||
test('skips invalid item keys', () => {
|
||||
const layout = [
|
||||
{ type: 'item', key: 'feed' },
|
||||
{ type: 'item', key: 'nonexistent' }
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
expect(result.find((e) => e.key === 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('deduplicates item keys', () => {
|
||||
const layout = [
|
||||
{ type: 'item', key: 'feed' },
|
||||
{ type: 'item', key: 'feed' }
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const feedEntries = result.filter(
|
||||
(e) => e.type === 'item' && e.key === 'feed'
|
||||
);
|
||||
expect(feedEntries.length).toBe(1);
|
||||
});
|
||||
|
||||
test('creates folder entries from valid items', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'my-folder',
|
||||
name: 'My Folder',
|
||||
icon: 'ri-star-line',
|
||||
items: ['feed', 'search']
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const folder = result.find(
|
||||
(e) => e.type === 'folder' && e.id === 'my-folder'
|
||||
);
|
||||
expect(folder).toBeDefined();
|
||||
expect(folder.items).toEqual(['feed', 'search']);
|
||||
expect(folder.name).toBe('My Folder');
|
||||
});
|
||||
|
||||
test('generates folder ID when missing', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
name: 'No ID Folder',
|
||||
items: ['feed']
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const folder = result.find((e) => e.type === 'folder');
|
||||
expect(folder.id).toBe('generated-folder-id');
|
||||
});
|
||||
|
||||
test('translates folder name from nameKey', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'f1',
|
||||
nameKey: 'nav_tooltip.favorites',
|
||||
items: ['feed']
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const folder = result.find((e) => e.type === 'folder');
|
||||
expect(folder.name).toBe('translated:nav_tooltip.favorites');
|
||||
});
|
||||
|
||||
test('appends missing definitions not in layout or hidden', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout);
|
||||
// All non-chart, non-hidden items should be present
|
||||
expect(result.some((e) => e.key === 'search')).toBe(true);
|
||||
expect(result.some((e) => e.key === 'tools')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not append hidden keys', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout, ['search', 'tools']);
|
||||
expect(
|
||||
result.find((e) => e.type === 'item' && e.key === 'search')
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.find((e) => e.type === 'item' && e.key === 'tools')
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('converts legacy "charts" item to charts folder', () => {
|
||||
const layout = [{ type: 'item', key: 'charts' }];
|
||||
const result = runSanitize(layout);
|
||||
const chartsFolder = result.find(
|
||||
(e) => e.type === 'folder' && e.id === 'default-folder-charts'
|
||||
);
|
||||
expect(chartsFolder).toBeDefined();
|
||||
expect(chartsFolder.items).toEqual([
|
||||
'charts-instance',
|
||||
'charts-mutual'
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto-appends charts folder when charts keys are neither used nor hidden', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout);
|
||||
const chartsFolder = result.find(
|
||||
(e) => e.type === 'folder' && e.id === 'default-folder-charts'
|
||||
);
|
||||
expect(chartsFolder).toBeDefined();
|
||||
});
|
||||
|
||||
test('skips empty folders', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'empty-folder',
|
||||
name: 'Empty',
|
||||
items: []
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
expect(result.find((e) => e.id === 'empty-folder')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@
|
||||
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { copyToClipboard } from '../../../shared/utils';
|
||||
import { copyToClipboard, formatCsvField } from '../../../shared/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
|
||||
const checkedExportBansOptions = ref(['userId', 'displayName', 'roles', 'managerNotes', 'joinedAt', 'bannedAt']);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param label
|
||||
* @param checked
|
||||
*/
|
||||
function toggleExportOption(label, checked) {
|
||||
const selection = checkedExportBansOptions.value;
|
||||
const index = selection.indexOf(label);
|
||||
@@ -88,6 +93,11 @@
|
||||
updateExportContent();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param item
|
||||
* @param key
|
||||
*/
|
||||
function getRowValue(item, key) {
|
||||
switch (key) {
|
||||
case 'displayName':
|
||||
@@ -101,9 +111,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateExportContent() {
|
||||
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
|
||||
|
||||
const sortedCheckedOptions = exportBansOptions
|
||||
.filter((option) => checkedExportBansOptions.value.includes(option.label))
|
||||
.map((option) => option.label);
|
||||
@@ -111,16 +122,22 @@
|
||||
const header = `${sortedCheckedOptions.join(',')}\n`;
|
||||
|
||||
const content = props.groupBansModerationTable.data
|
||||
.map((item) => sortedCheckedOptions.map((key) => formatter(String(getRowValue(item, key)))).join(','))
|
||||
.map((item) => sortedCheckedOptions.map((key) => formatCsvField(String(getRowValue(item, key)))).join(','))
|
||||
.join('\n');
|
||||
|
||||
exportContent.value = header + content;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleCopyExportContent() {
|
||||
copyToClipboard(exportContent.value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function setIsGroupBansExportDialogVisible() {
|
||||
emit('update:isGroupBansExportDialogVisible', false);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { copyToClipboard } from '../../../shared/utils';
|
||||
import { copyToClipboard, formatCsvField } from '../../../shared/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
'data'
|
||||
]);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param label
|
||||
* @param checked
|
||||
*/
|
||||
function toggleGroupLogsExportOption(label, checked) {
|
||||
const selection = checkedGroupLogsExportLogsOptions.value;
|
||||
const index = selection.indexOf(label);
|
||||
@@ -94,9 +99,10 @@
|
||||
updateGroupLogsExportContent();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateGroupLogsExportContent() {
|
||||
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
|
||||
|
||||
const sortedCheckedOptions = checkGroupsLogsExportLogsOptions
|
||||
.filter((option) => checkedGroupLogsExportLogsOptions.value.includes(option.label))
|
||||
.map((option) => option.label);
|
||||
@@ -106,18 +112,24 @@
|
||||
const content = props.groupLogsModerationTable.data
|
||||
.map((item) =>
|
||||
sortedCheckedOptions
|
||||
.map((key) => formatter(key === 'data' ? JSON.stringify(item[key]) : item[key]))
|
||||
.map((key) => formatCsvField(key === 'data' ? JSON.stringify(item[key]) : item[key]))
|
||||
.join(',')
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
groupLogsExportContent.value = header + content; // Update ref
|
||||
groupLogsExportContent.value = header + content;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleCopyGroupLogsExportContent() {
|
||||
copyToClipboard(groupLogsExportContent.value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function setIsGroupLogsExportDialogVisible() {
|
||||
emit('update:isGroupLogsExportDialogVisible', false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { useGroupBatchOperations } from '../useGroupBatchOperations';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param overrides
|
||||
*/
|
||||
function createDeps(overrides = {}) {
|
||||
return {
|
||||
selectedUsersArray: ref([
|
||||
{
|
||||
userId: 'usr_1',
|
||||
displayName: 'Alice',
|
||||
roleIds: ['role_1'],
|
||||
managerNotes: ''
|
||||
},
|
||||
{
|
||||
userId: 'usr_2',
|
||||
displayName: 'Bob',
|
||||
roleIds: ['role_1'],
|
||||
managerNotes: ''
|
||||
}
|
||||
]),
|
||||
currentUser: ref({ id: 'usr_self' }),
|
||||
groupMemberModeration: ref({ id: 'grp_test' }),
|
||||
deselectedUsers: vi.fn(),
|
||||
groupRequest: {
|
||||
banGroupMember: vi.fn().mockResolvedValue(undefined),
|
||||
unbanGroupMember: vi.fn().mockResolvedValue(undefined),
|
||||
kickGroupMember: vi.fn().mockResolvedValue(undefined),
|
||||
setGroupMemberProps: vi.fn().mockResolvedValue(undefined),
|
||||
removeGroupMemberRole: vi.fn().mockResolvedValue(undefined),
|
||||
addGroupMemberRole: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSentGroupInvite: vi.fn().mockResolvedValue(undefined),
|
||||
acceptGroupInviteRequest: vi.fn().mockResolvedValue(undefined),
|
||||
rejectGroupInviteRequest: vi.fn().mockResolvedValue(undefined),
|
||||
blockGroupInviteRequest: vi.fn().mockResolvedValue(undefined),
|
||||
deleteBlockedGroupRequest: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
handleGroupMemberRoleChange: vi.fn(),
|
||||
handleGroupMemberProps: vi.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('useGroupBatchOperations', () => {
|
||||
describe('runBatchOperation (via groupMembersBan)', () => {
|
||||
test('calls action for each selected user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersBan } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersBan();
|
||||
|
||||
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledTimes(2);
|
||||
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledWith({
|
||||
groupId: 'grp_test',
|
||||
userId: 'usr_1'
|
||||
});
|
||||
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledWith({
|
||||
groupId: 'grp_test',
|
||||
userId: 'usr_2'
|
||||
});
|
||||
});
|
||||
|
||||
test('skips self user', async () => {
|
||||
const deps = createDeps({
|
||||
selectedUsersArray: ref([
|
||||
{
|
||||
userId: 'usr_self',
|
||||
displayName: 'Self',
|
||||
roleIds: [],
|
||||
managerNotes: ''
|
||||
},
|
||||
{
|
||||
userId: 'usr_1',
|
||||
displayName: 'Alice',
|
||||
roleIds: [],
|
||||
managerNotes: ''
|
||||
}
|
||||
])
|
||||
});
|
||||
const { groupMembersBan } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersBan();
|
||||
|
||||
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledTimes(1);
|
||||
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledWith({
|
||||
groupId: 'grp_test',
|
||||
userId: 'usr_1'
|
||||
});
|
||||
});
|
||||
|
||||
test('calls onComplete callback', async () => {
|
||||
const deps = createDeps();
|
||||
const onComplete = vi.fn();
|
||||
const { groupMembersBan } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersBan({ onComplete });
|
||||
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles errors gracefully', async () => {
|
||||
const deps = createDeps();
|
||||
deps.groupRequest.banGroupMember
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { groupMembersBan } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersBan();
|
||||
|
||||
// should still attempt the second user
|
||||
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('tracks progress during operation', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersBan, progressTotal, progressCurrent } =
|
||||
useGroupBatchOperations(deps);
|
||||
|
||||
expect(progressTotal.value).toBe(0);
|
||||
const p = groupMembersBan();
|
||||
await p;
|
||||
// After completion, progress resets to 0
|
||||
expect(progressTotal.value).toBe(0);
|
||||
expect(progressCurrent.value).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersUnban', () => {
|
||||
test('calls unbanGroupMember for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersUnban } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersUnban();
|
||||
|
||||
expect(deps.groupRequest.unbanGroupMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersKick', () => {
|
||||
test('calls kickGroupMember for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersKick } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersKick();
|
||||
|
||||
expect(deps.groupRequest.kickGroupMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersSaveNote', () => {
|
||||
test('calls setGroupMemberProps with note value', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersSaveNote } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersSaveNote('Test note');
|
||||
|
||||
expect(deps.groupRequest.setGroupMemberProps).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
expect(deps.groupRequest.setGroupMemberProps).toHaveBeenCalledWith(
|
||||
'usr_1',
|
||||
'grp_test',
|
||||
{
|
||||
managerNotes: 'Test note'
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersAddRoles', () => {
|
||||
test('calls addGroupMemberRole for each role per user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersAddRoles } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersAddRoles(['role_1', 'role_2']);
|
||||
|
||||
// Both users already have role_1, so only role_2 gets added → 2 calls
|
||||
expect(deps.groupRequest.addGroupMemberRole).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersRemoveRoles', () => {
|
||||
test('calls removeGroupMemberRole for each role per user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersRemoveRoles } = useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersRemoveRoles(['role_1']);
|
||||
|
||||
expect(
|
||||
deps.groupRequest.removeGroupMemberRole
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersDeleteSentInvite', () => {
|
||||
test('calls deleteSentGroupInvite for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersDeleteSentInvite } =
|
||||
useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersDeleteSentInvite();
|
||||
|
||||
expect(
|
||||
deps.groupRequest.deleteSentGroupInvite
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersAcceptInviteRequest', () => {
|
||||
test('calls acceptGroupInviteRequest for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersAcceptInviteRequest } =
|
||||
useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersAcceptInviteRequest();
|
||||
|
||||
expect(
|
||||
deps.groupRequest.acceptGroupInviteRequest
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersRejectInviteRequest', () => {
|
||||
test('calls rejectGroupInviteRequest for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersRejectInviteRequest } =
|
||||
useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersRejectInviteRequest();
|
||||
|
||||
expect(
|
||||
deps.groupRequest.rejectGroupInviteRequest
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersBlockJoinRequest', () => {
|
||||
test('calls blockGroupInviteRequest for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersBlockJoinRequest } =
|
||||
useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersBlockJoinRequest();
|
||||
|
||||
expect(
|
||||
deps.groupRequest.blockGroupInviteRequest
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMembersDeleteBlockedRequest', () => {
|
||||
test('calls deleteBlockedGroupRequest for each user', async () => {
|
||||
const deps = createDeps();
|
||||
const { groupMembersDeleteBlockedRequest } =
|
||||
useGroupBatchOperations(deps);
|
||||
|
||||
await groupMembersDeleteBlockedRequest();
|
||||
|
||||
expect(
|
||||
deps.groupRequest.deleteBlockedGroupRequest
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { useGroupModerationSelection } from '../useGroupModerationSelection';
|
||||
|
||||
function createTables() {
|
||||
return {
|
||||
members: { data: [] },
|
||||
bans: { data: [] },
|
||||
invites: { data: [] },
|
||||
joinRequests: { data: [] },
|
||||
blocked: { data: [] }
|
||||
};
|
||||
}
|
||||
|
||||
describe('useGroupModerationSelection', () => {
|
||||
describe('setSelectedUsers', () => {
|
||||
test('adds a user to selection', () => {
|
||||
const tables = createTables();
|
||||
const { selectedUsers, selectedUsersArray, setSelectedUsers } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
|
||||
|
||||
expect(selectedUsers['usr_1']).toEqual({
|
||||
userId: 'usr_1',
|
||||
name: 'Alice'
|
||||
});
|
||||
expect(selectedUsersArray.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('ignores null user', () => {
|
||||
const tables = createTables();
|
||||
const { selectedUsersArray, setSelectedUsers } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', null);
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('adds multiple users', () => {
|
||||
const tables = createTables();
|
||||
const { selectedUsersArray, setSelectedUsers } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
|
||||
setSelectedUsers('usr_2', { userId: 'usr_2', name: 'Bob' });
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deselectedUsers', () => {
|
||||
test('removes a specific user', () => {
|
||||
const tables = createTables();
|
||||
const {
|
||||
selectedUsers,
|
||||
selectedUsersArray,
|
||||
setSelectedUsers,
|
||||
deselectedUsers
|
||||
} = useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
|
||||
setSelectedUsers('usr_2', { userId: 'usr_2', name: 'Bob' });
|
||||
deselectedUsers('usr_1');
|
||||
|
||||
expect(selectedUsers['usr_1']).toBeUndefined();
|
||||
expect(selectedUsersArray.value).toHaveLength(1);
|
||||
expect(selectedUsersArray.value[0].name).toBe('Bob');
|
||||
});
|
||||
|
||||
test('removes all users when isAll=true', () => {
|
||||
const tables = createTables();
|
||||
const { selectedUsersArray, setSelectedUsers, deselectedUsers } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
|
||||
setSelectedUsers('usr_2', { userId: 'usr_2', name: 'Bob' });
|
||||
deselectedUsers(null, true);
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSelectionChange', () => {
|
||||
test('selects user when row.$selected is true', () => {
|
||||
const tables = createTables();
|
||||
const { selectedUsersArray, onSelectionChange } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
onSelectionChange({
|
||||
userId: 'usr_1',
|
||||
name: 'Alice',
|
||||
$selected: true
|
||||
});
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('deselects user when row.$selected is false', () => {
|
||||
const tables = createTables();
|
||||
const { selectedUsersArray, setSelectedUsers, onSelectionChange } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
|
||||
onSelectionChange({ userId: 'usr_1', $selected: false });
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deselectInTables', () => {
|
||||
test('deselects specific user in table data', () => {
|
||||
const tables = createTables();
|
||||
tables.members.data = [
|
||||
{ userId: 'usr_1', $selected: true },
|
||||
{ userId: 'usr_2', $selected: true }
|
||||
];
|
||||
const { deselectInTables } = useGroupModerationSelection(tables);
|
||||
|
||||
deselectInTables('usr_1');
|
||||
|
||||
expect(tables.members.data[0].$selected).toBe(false);
|
||||
expect(tables.members.data[1].$selected).toBe(true);
|
||||
});
|
||||
|
||||
test('deselects all users when no userId', () => {
|
||||
const tables = createTables();
|
||||
tables.members.data = [
|
||||
{ userId: 'usr_1', $selected: true },
|
||||
{ userId: 'usr_2', $selected: true }
|
||||
];
|
||||
tables.bans.data = [{ userId: 'usr_3', $selected: true }];
|
||||
const { deselectInTables } = useGroupModerationSelection(tables);
|
||||
|
||||
deselectInTables();
|
||||
|
||||
expect(tables.members.data[0].$selected).toBe(false);
|
||||
expect(tables.members.data[1].$selected).toBe(false);
|
||||
expect(tables.bans.data[0].$selected).toBe(false);
|
||||
});
|
||||
|
||||
test('handles null table gracefully', () => {
|
||||
const tables = createTables();
|
||||
tables.members = null;
|
||||
const { deselectInTables } = useGroupModerationSelection(tables);
|
||||
|
||||
expect(() => deselectInTables('usr_1')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSelectedUser', () => {
|
||||
test('removes user from selection and tables', () => {
|
||||
const tables = createTables();
|
||||
tables.members.data = [{ userId: 'usr_1', $selected: true }];
|
||||
const { selectedUsersArray, setSelectedUsers, deleteSelectedUser } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
|
||||
deleteSelectedUser({ userId: 'usr_1' });
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(0);
|
||||
expect(tables.members.data[0].$selected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllSelected', () => {
|
||||
test('clears all selections and table states', () => {
|
||||
const tables = createTables();
|
||||
tables.members.data = [
|
||||
{ userId: 'usr_1', $selected: true },
|
||||
{ userId: 'usr_2', $selected: true }
|
||||
];
|
||||
tables.bans.data = [{ userId: 'usr_3', $selected: true }];
|
||||
const { selectedUsersArray, setSelectedUsers, clearAllSelected } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
setSelectedUsers('usr_1', { userId: 'usr_1' });
|
||||
setSelectedUsers('usr_2', { userId: 'usr_2' });
|
||||
setSelectedUsers('usr_3', { userId: 'usr_3' });
|
||||
|
||||
clearAllSelected();
|
||||
|
||||
expect(selectedUsersArray.value).toHaveLength(0);
|
||||
expect(tables.members.data.every((r) => !r.$selected)).toBe(true);
|
||||
expect(tables.bans.data.every((r) => !r.$selected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAll', () => {
|
||||
test('selects all rows in a table', () => {
|
||||
const tables = createTables();
|
||||
const tableData = [
|
||||
{ userId: 'usr_1', $selected: false },
|
||||
{ userId: 'usr_2', $selected: false }
|
||||
];
|
||||
const { selectedUsersArray, selectAll } =
|
||||
useGroupModerationSelection(tables);
|
||||
|
||||
selectAll(tableData);
|
||||
|
||||
expect(tableData.every((r) => r.$selected)).toBe(true);
|
||||
expect(selectedUsersArray.value).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -546,6 +546,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
buildLegacyInstanceTag,
|
||||
copyToClipboard,
|
||||
getLaunchURL,
|
||||
hasGroupPermission,
|
||||
@@ -690,6 +691,10 @@
|
||||
return map;
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
function resolveUserDisplayName(userId) {
|
||||
if (currentUser.value?.id && currentUser.value.id === userId) {
|
||||
return currentUser.value.displayName;
|
||||
@@ -742,6 +747,10 @@
|
||||
return groups;
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleRoleIdsChange(value) {
|
||||
const next = Array.isArray(value) ? value.map((v) => String(v ?? '')).filter(Boolean) : [];
|
||||
newInstanceDialog.value.roleIds = next;
|
||||
@@ -757,10 +766,17 @@
|
||||
|
||||
initializeNewInstanceDialog();
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function closeInviteDialog() {
|
||||
inviteDialog.value.visible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param tag
|
||||
*/
|
||||
function showInviteDialog(tag) {
|
||||
if (!isRealInstance(tag)) {
|
||||
return;
|
||||
@@ -788,11 +804,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param location
|
||||
* @param shortName
|
||||
*/
|
||||
function handleAttachGame(location, shortName) {
|
||||
tryOpenInstanceInVrc(location, shortName);
|
||||
closeInviteDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param tag
|
||||
*/
|
||||
async function initNewInstanceDialog(tag) {
|
||||
if (!isRealInstance(tag)) {
|
||||
return;
|
||||
@@ -823,6 +848,9 @@
|
||||
updateNewInstanceDialog();
|
||||
D.visible = true;
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function initializeNewInstanceDialog() {
|
||||
configRepository
|
||||
.getBool('instanceDialogQueueEnabled', true)
|
||||
@@ -860,6 +888,9 @@
|
||||
.getString('instanceDialogDisplayName', '')
|
||||
.then((value) => (newInstanceDialog.value.displayName = value));
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function saveNewInstanceDialog() {
|
||||
const {
|
||||
accessType,
|
||||
@@ -883,6 +914,10 @@
|
||||
configRepository.setBool('instanceDialogAgeGate', ageGate);
|
||||
configRepository.setString('instanceDialogDisplayName', displayName);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param tabName
|
||||
*/
|
||||
function newInstanceTabClick(tabName) {
|
||||
if (tabName === 'Normal') {
|
||||
buildInstance();
|
||||
@@ -890,6 +925,10 @@
|
||||
buildLegacyInstance();
|
||||
}
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param noChanges
|
||||
*/
|
||||
function updateNewInstanceDialog(noChanges) {
|
||||
const D = newInstanceDialog.value;
|
||||
if (D.instanceId) {
|
||||
@@ -905,6 +944,10 @@
|
||||
}
|
||||
D.url = getLaunchURL(L);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param location
|
||||
*/
|
||||
function selfInvite(location) {
|
||||
const L = parseLocation(location);
|
||||
if (!L.isRealInstance) {
|
||||
@@ -920,6 +963,9 @@
|
||||
return args;
|
||||
});
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function handleCreateNewInstance() {
|
||||
const args = await createNewInstance(newInstanceDialog.value.worldId, newInstanceDialog.value);
|
||||
|
||||
@@ -931,6 +977,9 @@
|
||||
updateNewInstanceDialog();
|
||||
}
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function buildInstance() {
|
||||
const D = newInstanceDialog.value;
|
||||
D.instanceCreated = false;
|
||||
@@ -965,56 +1014,37 @@
|
||||
}
|
||||
saveNewInstanceDialog();
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function buildLegacyInstance() {
|
||||
const D = newInstanceDialog.value;
|
||||
D.instanceCreated = false;
|
||||
D.shortName = '';
|
||||
D.secureOrShortName = '';
|
||||
const tags = [];
|
||||
if (D.instanceName) {
|
||||
D.instanceName = D.instanceName.replace(/[^A-Za-z0-9]/g, '');
|
||||
tags.push(D.instanceName);
|
||||
} else {
|
||||
const randValue = (99999 * Math.random() + 1).toFixed(0);
|
||||
tags.push(String(randValue).padStart(5, '0'));
|
||||
}
|
||||
if (!D.userId) {
|
||||
D.userId = currentUser.value.id;
|
||||
}
|
||||
const userId = D.userId;
|
||||
if (D.accessType !== 'public') {
|
||||
if (D.accessType === 'friends+') {
|
||||
tags.push(`~hidden(${userId})`);
|
||||
} else if (D.accessType === 'friends') {
|
||||
tags.push(`~friends(${userId})`);
|
||||
} else if (D.accessType === 'group') {
|
||||
tags.push(`~group(${D.groupId})`);
|
||||
tags.push(`~groupAccessType(${D.groupAccessType})`);
|
||||
} else {
|
||||
tags.push(`~private(${userId})`);
|
||||
}
|
||||
if (D.accessType === 'invite+') {
|
||||
tags.push('~canRequestInvite');
|
||||
}
|
||||
}
|
||||
if (D.accessType === 'group' && D.ageGate) {
|
||||
tags.push('~ageGate');
|
||||
}
|
||||
if (D.region === 'US West') {
|
||||
tags.push(`~region(us)`);
|
||||
} else if (D.region === 'US East') {
|
||||
tags.push(`~region(use)`);
|
||||
} else if (D.region === 'Europe') {
|
||||
tags.push(`~region(eu)`);
|
||||
} else if (D.region === 'Japan') {
|
||||
tags.push(`~region(jp)`);
|
||||
}
|
||||
if (D.accessType !== 'invite' && D.accessType !== 'friends') {
|
||||
D.strict = false;
|
||||
}
|
||||
if (D.strict) {
|
||||
tags.push('~strict');
|
||||
}
|
||||
|
||||
const instanceName = D.instanceName || String((99999 * Math.random() + 1).toFixed(0)).padStart(5, '0');
|
||||
|
||||
D.instanceId = buildLegacyInstanceTag({
|
||||
instanceName,
|
||||
userId: D.userId,
|
||||
accessType: D.accessType,
|
||||
groupId: D.groupId,
|
||||
groupAccessType: D.groupAccessType,
|
||||
region: D.region,
|
||||
ageGate: D.ageGate,
|
||||
strict: D.strict
|
||||
});
|
||||
|
||||
if (D.groupId && D.groupId !== D.lastSelectedGroupId) {
|
||||
D.roleIds = [];
|
||||
const ref = cachedGroups.get(D.groupId);
|
||||
@@ -1038,10 +1068,13 @@
|
||||
D.groupRef = {};
|
||||
D.lastSelectedGroupId = '';
|
||||
}
|
||||
D.instanceId = tags.join('');
|
||||
updateNewInstanceDialog(false);
|
||||
saveNewInstanceDialog();
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param location
|
||||
*/
|
||||
async function copyInstanceUrl(location) {
|
||||
const L = parseLocation(location);
|
||||
const args = await instanceRequest.getInstanceShortName({
|
||||
|
||||
183
src/components/navMenuUtils.js
Normal file
183
src/components/navMenuUtils.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
|
||||
|
||||
/**
|
||||
* Deduplicate and validate hidden navigation keys against the definition map.
|
||||
* @param {string[]} hiddenKeys - Keys to normalize
|
||||
* @param {Map} definitionMap - Map of valid nav definition keys
|
||||
* @returns {string[]} Normalized, deduplicated array of valid keys
|
||||
*/
|
||||
export function normalizeHiddenKeys(hiddenKeys, definitionMap) {
|
||||
if (!Array.isArray(hiddenKeys)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
hiddenKeys.forEach((key) => {
|
||||
if (!key || seen.has(key) || !definitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a saved navigation layout: dedup items, create folders, append missing definitions.
|
||||
* @param {Array} layout - Raw layout from storage
|
||||
* @param {string[]} hiddenKeys - Keys that should be hidden
|
||||
* @param {Map} definitionMap - Map of all valid nav definition keys
|
||||
* @param {Array} allDefinitions - Array of all nav definitions (for appending missing)
|
||||
* @param {Function} t - i18n translation function
|
||||
* @param {Function} generateFolderId - Function to generate unique folder IDs
|
||||
* @returns {Array} Sanitized layout
|
||||
*/
|
||||
export function sanitizeLayout(
|
||||
layout,
|
||||
hiddenKeys,
|
||||
definitionMap,
|
||||
allDefinitions,
|
||||
t,
|
||||
generateFolderId
|
||||
) {
|
||||
const usedKeys = new Set();
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, definitionMap);
|
||||
const hiddenSet = new Set(normalizedHiddenKeys);
|
||||
const normalized = [];
|
||||
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
||||
|
||||
const appendItemEntry = (key, target = normalized) => {
|
||||
if (!key || usedKeys.has(key) || !definitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
target.push({ type: 'item', key });
|
||||
usedKeys.add(key);
|
||||
};
|
||||
|
||||
const appendChartsFolder = (target = normalized) => {
|
||||
if (chartsKeys.some((key) => usedKeys.has(key))) {
|
||||
return;
|
||||
}
|
||||
if (!chartsKeys.every((key) => definitionMap.has(key))) {
|
||||
return;
|
||||
}
|
||||
chartsKeys.forEach((key) => usedKeys.add(key));
|
||||
target.push({
|
||||
type: 'folder',
|
||||
id: 'default-folder-charts',
|
||||
nameKey: 'nav_tooltip.charts',
|
||||
name: t('nav_tooltip.charts'),
|
||||
icon: 'ri-pie-chart-line',
|
||||
items: [...chartsKeys]
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(layout)) {
|
||||
layout.forEach((entry) => {
|
||||
if (entry?.type === 'item') {
|
||||
if (entry.key === 'charts') {
|
||||
appendChartsFolder();
|
||||
return;
|
||||
}
|
||||
appendItemEntry(entry.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry?.type === 'folder') {
|
||||
const folderItems = [];
|
||||
(entry.items || []).forEach((key) => {
|
||||
if (!key || usedKeys.has(key) || !definitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
folderItems.push(key);
|
||||
usedKeys.add(key);
|
||||
});
|
||||
|
||||
if (folderItems.length >= 1) {
|
||||
const folderNameKey = entry.nameKey || null;
|
||||
const folderName = folderNameKey
|
||||
? t(folderNameKey)
|
||||
: entry.name || '';
|
||||
normalized.push({
|
||||
type: 'folder',
|
||||
id: entry.id || generateFolderId(),
|
||||
name: folderName,
|
||||
nameKey: folderNameKey,
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
items: folderItems
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
allDefinitions.forEach((item) => {
|
||||
if (!usedKeys.has(item.key) && !hiddenSet.has(item.key)) {
|
||||
if (chartsKeys.includes(item.key)) {
|
||||
return;
|
||||
}
|
||||
appendItemEntry(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!chartsKeys.some((key) => usedKeys.has(key)) &&
|
||||
!chartsKeys.some((key) => hiddenSet.has(key))
|
||||
) {
|
||||
appendChartsFolder();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first routable navigation key in a layout.
|
||||
* @param {Array} layout - Navigation layout
|
||||
* @param {Map} definitionMap - Map of nav definitions
|
||||
* @returns {string|null} The route name of the first routable entry, or null
|
||||
*/
|
||||
export function getFirstNavRoute(layout, definitionMap) {
|
||||
for (const entry of layout) {
|
||||
if (entry.type === 'item') {
|
||||
const definition = definitionMap.get(entry.key);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
if (entry.type === 'folder' && entry.items?.length) {
|
||||
const definition = entry.items
|
||||
.map((key) => definitionMap.get(key))
|
||||
.find((def) => def?.routeName);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a navigation entry has a notification indicator.
|
||||
* @param {object} entry - Navigation entry object
|
||||
* @param {string[]} notifiedMenus - List of menu keys with notifications
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEntryNotified(entry, notifiedMenus) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const targets = [];
|
||||
if (entry.index) {
|
||||
targets.push(entry.index);
|
||||
}
|
||||
if (entry.routeName) {
|
||||
targets.push(entry.routeName);
|
||||
}
|
||||
if (entry.path) {
|
||||
const lastSegment = entry.path.split('/').pop();
|
||||
if (lastSegment) {
|
||||
targets.push(lastSegment);
|
||||
}
|
||||
}
|
||||
return targets.some((key) => notifiedMenus.includes(key));
|
||||
}
|
||||
30
src/service/__tests__/config.test.js
Normal file
30
src/service/__tests__/config.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Mock router to avoid transitive i18n.global error from columns.jsx
|
||||
vi.mock('../../plugin/router.js', () => ({
|
||||
router: { beforeEach: vi.fn(), push: vi.fn() },
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
|
||||
import { transformKey } from '../config.js';
|
||||
|
||||
describe('transformKey', () => {
|
||||
test('lowercases and prefixes with config:', () => {
|
||||
expect(transformKey('Foo')).toBe('config:foo');
|
||||
});
|
||||
|
||||
test('handles already lowercase key', () => {
|
||||
expect(transformKey('bar')).toBe('config:bar');
|
||||
});
|
||||
|
||||
test('handles key with mixed case and numbers', () => {
|
||||
expect(transformKey('MyKey123')).toBe('config:mykey123');
|
||||
});
|
||||
|
||||
test('handles empty string', () => {
|
||||
expect(transformKey('')).toBe('config:');
|
||||
});
|
||||
|
||||
test('converts non-string values via String()', () => {
|
||||
expect(transformKey(42)).toBe('config:42');
|
||||
expect(transformKey(null)).toBe('config:null');
|
||||
});
|
||||
});
|
||||
53
src/service/__tests__/confusables.test.js
Normal file
53
src/service/__tests__/confusables.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import removeConfusables, { removeWhitespace } from '../confusables.js';
|
||||
|
||||
describe('removeConfusables', () => {
|
||||
test('returns ASCII strings unchanged (fast path)', () => {
|
||||
expect(removeConfusables('Hello World')).toBe('HelloWorld');
|
||||
});
|
||||
|
||||
test('converts circled letters to ASCII', () => {
|
||||
expect(removeConfusables('Ⓗⓔⓛⓛⓞ')).toBe('Hello');
|
||||
});
|
||||
|
||||
test('converts fullwidth letters to ASCII', () => {
|
||||
expect(removeConfusables('Hello')).toBe('Hello');
|
||||
});
|
||||
|
||||
test('converts Cyrillic confusables', () => {
|
||||
// Cyrillic А, В, С look like Latin A, B, C
|
||||
expect(removeConfusables('АВС')).toBe('ABC');
|
||||
});
|
||||
|
||||
test('handles mixed confusables and normal chars', () => {
|
||||
expect(removeConfusables('Ⓣest')).toBe('Test');
|
||||
});
|
||||
|
||||
test('strips combining marks', () => {
|
||||
// 'e' + combining acute accent → normalized then combining mark stripped → 'e'
|
||||
const input = 'e\u0301';
|
||||
const result = removeConfusables(input);
|
||||
expect(result).toBe('e');
|
||||
});
|
||||
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(removeConfusables('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeWhitespace', () => {
|
||||
test('removes regular spaces', () => {
|
||||
expect(removeWhitespace('a b c')).toBe('abc');
|
||||
});
|
||||
|
||||
test('removes tabs and newlines', () => {
|
||||
expect(removeWhitespace('a\tb\nc')).toBe('abc');
|
||||
});
|
||||
|
||||
test('returns string without whitespace unchanged', () => {
|
||||
expect(removeWhitespace('abc')).toBe('abc');
|
||||
});
|
||||
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(removeWhitespace('')).toBe('');
|
||||
});
|
||||
});
|
||||
220
src/service/__tests__/gameLog.test.js
Normal file
220
src/service/__tests__/gameLog.test.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { LogWatcherService } from '../gameLog.js';
|
||||
|
||||
const svc = new LogWatcherService();
|
||||
|
||||
describe('parseRawGameLog', () => {
|
||||
test('parses location type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'location', [
|
||||
'wrld_123:456',
|
||||
'Test World'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'location',
|
||||
location: 'wrld_123:456',
|
||||
worldName: 'Test World'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses location-destination type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'location-destination', [
|
||||
'wrld_abc:789'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'location-destination',
|
||||
location: 'wrld_abc:789'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses player-joined type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'player-joined', [
|
||||
'TestUser',
|
||||
'usr_123'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'player-joined',
|
||||
displayName: 'TestUser',
|
||||
userId: 'usr_123'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses player-left type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'player-left', [
|
||||
'TestUser',
|
||||
'usr_123'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'player-left',
|
||||
displayName: 'TestUser',
|
||||
userId: 'usr_123'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses notification type', () => {
|
||||
const json = '{"type":"invite"}';
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'notification', [json]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'notification',
|
||||
json
|
||||
});
|
||||
});
|
||||
|
||||
test('parses event type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'event', ['some-event']);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'event',
|
||||
event: 'some-event'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses video-play type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'video-play', [
|
||||
'https://example.com/video.mp4',
|
||||
'Player1'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'video-play',
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
displayName: 'Player1'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses resource-load-string type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'resource-load-string', [
|
||||
'https://example.com/res'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'resource-load-string',
|
||||
resourceUrl: 'https://example.com/res'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses resource-load-image type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'resource-load-image', [
|
||||
'https://example.com/img.png'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'resource-load-image',
|
||||
resourceUrl: 'https://example.com/img.png'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses avatar-change type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'avatar-change', [
|
||||
'User1',
|
||||
'CoolAvatar'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'avatar-change',
|
||||
displayName: 'User1',
|
||||
avatarName: 'CoolAvatar'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses photon-id type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'photon-id', [
|
||||
'User1',
|
||||
'42'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'photon-id',
|
||||
displayName: 'User1',
|
||||
photonId: '42'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses screenshot type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'screenshot', [
|
||||
'/path/to/screenshot.png'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'screenshot',
|
||||
screenshotPath: '/path/to/screenshot.png'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses sticker-spawn type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'sticker-spawn', [
|
||||
'usr_abc',
|
||||
'StickerUser',
|
||||
'inv_123'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'sticker-spawn',
|
||||
userId: 'usr_abc',
|
||||
displayName: 'StickerUser',
|
||||
inventoryId: 'inv_123'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses video-sync type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'video-sync', [
|
||||
'123.456'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'video-sync',
|
||||
timestamp: '123.456'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses vrcx type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'vrcx', ['some-data']);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'vrcx',
|
||||
data: 'some-data'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses api-request type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'api-request', [
|
||||
'https://api.vrchat.cloud/api/1/users'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'api-request',
|
||||
url: 'https://api.vrchat.cloud/api/1/users'
|
||||
});
|
||||
});
|
||||
|
||||
test('parses udon-exception type', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'udon-exception', [
|
||||
'NullRef'
|
||||
]);
|
||||
expect(log).toEqual({
|
||||
dt: '2024-01-01',
|
||||
type: 'udon-exception',
|
||||
data: 'NullRef'
|
||||
});
|
||||
});
|
||||
|
||||
test('handles types with no extra fields', () => {
|
||||
for (const type of [
|
||||
'portal-spawn',
|
||||
'vrc-quit',
|
||||
'openvr-init',
|
||||
'desktop-mode'
|
||||
]) {
|
||||
const log = svc.parseRawGameLog('2024-01-01', type, []);
|
||||
expect(log).toEqual({ dt: '2024-01-01', type });
|
||||
}
|
||||
});
|
||||
|
||||
test('handles unknown type gracefully', () => {
|
||||
const log = svc.parseRawGameLog('2024-01-01', 'unknown-type', ['foo']);
|
||||
expect(log).toEqual({ dt: '2024-01-01', type: 'unknown-type' });
|
||||
});
|
||||
});
|
||||
305
src/service/__tests__/request.test.js
Normal file
305
src/service/__tests__/request.test.js
Normal file
@@ -0,0 +1,305 @@
|
||||
// Mock router to avoid transitive i18n.global error from columns.jsx
|
||||
vi.mock('../../plugin/router.js', () => ({
|
||||
router: { beforeEach: vi.fn(), push: vi.fn() },
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
|
||||
import {
|
||||
buildRequestInit,
|
||||
parseResponse,
|
||||
processBulk,
|
||||
shouldIgnoreError
|
||||
} from '../request.js';
|
||||
|
||||
describe('buildRequestInit', () => {
|
||||
test('builds GET request with default method', () => {
|
||||
const init = buildRequestInit('users/usr_123');
|
||||
expect(init.method).toBe('GET');
|
||||
expect(init.url).toContain('users/usr_123');
|
||||
});
|
||||
|
||||
test('serializes GET params into URL search params', () => {
|
||||
const init = buildRequestInit('users', {
|
||||
params: { n: 50, offset: 0 }
|
||||
});
|
||||
expect(init.url).toContain('n=50');
|
||||
expect(init.url).toContain('offset=0');
|
||||
});
|
||||
|
||||
test('does not set body for GET requests', () => {
|
||||
const init = buildRequestInit('users', {
|
||||
params: { n: 50 }
|
||||
});
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sets JSON content-type and body for POST', () => {
|
||||
const init = buildRequestInit('auth/login', {
|
||||
method: 'POST',
|
||||
params: { username: 'test' }
|
||||
});
|
||||
expect(init.headers['Content-Type']).toBe(
|
||||
'application/json;charset=utf-8'
|
||||
);
|
||||
expect(init.body).toBe(JSON.stringify({ username: 'test' }));
|
||||
});
|
||||
|
||||
test('sets empty body when POST has no params', () => {
|
||||
const init = buildRequestInit('auth/logout', { method: 'POST' });
|
||||
expect(init.body).toBe('{}');
|
||||
});
|
||||
|
||||
test('preserves custom headers in POST', () => {
|
||||
const init = buildRequestInit('users', {
|
||||
method: 'PUT',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
params: { a: 1 }
|
||||
});
|
||||
expect(init.headers['Content-Type']).toBe(
|
||||
'application/json;charset=utf-8'
|
||||
);
|
||||
expect(init.headers['X-Custom']).toBe('value');
|
||||
});
|
||||
|
||||
test('skips body/headers for upload requests', () => {
|
||||
const init = buildRequestInit('file/upload', {
|
||||
method: 'PUT',
|
||||
uploadImage: true,
|
||||
params: { something: 1 }
|
||||
});
|
||||
expect(init.body).toBeUndefined();
|
||||
expect(init.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
test('skips body/headers for uploadFilePUT', () => {
|
||||
const init = buildRequestInit('file/upload', {
|
||||
method: 'PUT',
|
||||
uploadFilePUT: true
|
||||
});
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test('skips body/headers for uploadImageLegacy', () => {
|
||||
const init = buildRequestInit('file/upload', {
|
||||
method: 'POST',
|
||||
uploadImageLegacy: true
|
||||
});
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test('passes through extra options', () => {
|
||||
const init = buildRequestInit('test', {
|
||||
method: 'DELETE',
|
||||
inviteId: 'inv_123'
|
||||
});
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.inviteId).toBe('inv_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseResponse', () => {
|
||||
test('returns response unchanged when no data', () => {
|
||||
const response = { status: 200 };
|
||||
expect(parseResponse(response)).toEqual({ status: 200 });
|
||||
});
|
||||
|
||||
test('parses valid JSON data', () => {
|
||||
const response = {
|
||||
status: 200,
|
||||
data: JSON.stringify({ name: 'test' })
|
||||
};
|
||||
const result = parseResponse(response);
|
||||
expect(result.data).toEqual({ name: 'test' });
|
||||
expect(result.hasApiError).toBeUndefined();
|
||||
expect(result.parseError).toBeUndefined();
|
||||
});
|
||||
|
||||
test('detects API error in response data', () => {
|
||||
const response = {
|
||||
status: 404,
|
||||
data: JSON.stringify({
|
||||
error: { status_code: 404, message: 'Not found' }
|
||||
})
|
||||
};
|
||||
const result = parseResponse(response);
|
||||
expect(result.hasApiError).toBe(true);
|
||||
expect(result.data.error.message).toBe('Not found');
|
||||
});
|
||||
|
||||
test('flags parse error for invalid JSON', () => {
|
||||
const response = { status: 200, data: 'not valid json{{{' };
|
||||
const result = parseResponse(response);
|
||||
expect(result.parseError).toBe(true);
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
test('handles empty string data', () => {
|
||||
const response = { status: 200, data: '' };
|
||||
const result = parseResponse(response);
|
||||
// empty string is falsy, so treated as no data
|
||||
expect(result).toEqual({ status: 200, data: '' });
|
||||
});
|
||||
|
||||
test('handles null data', () => {
|
||||
const response = { status: 200, data: null };
|
||||
const result = parseResponse(response);
|
||||
expect(result).toEqual({ status: 200, data: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldIgnoreError', () => {
|
||||
test.each([
|
||||
[404, 'users/usr_123'],
|
||||
[404, 'worlds/wrld_123'],
|
||||
[404, 'avatars/avtr_123'],
|
||||
[404, 'groups/grp_123'],
|
||||
[404, 'file/file_123'],
|
||||
[-1, 'users/usr_123']
|
||||
])('ignores %i for single-segment resource %s', (code, endpoint) => {
|
||||
expect(shouldIgnoreError(code, endpoint)).toBe(true);
|
||||
});
|
||||
|
||||
test('does NOT ignore nested resource paths', () => {
|
||||
expect(shouldIgnoreError(404, 'users/usr_123/friends')).toBe(false);
|
||||
});
|
||||
|
||||
test('does NOT ignore 403 for resource lookups', () => {
|
||||
expect(shouldIgnoreError(403, 'users/usr_123')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([403, 404, -1])('ignores %i for instances/ endpoints', (code) => {
|
||||
expect(shouldIgnoreError(code, 'instances/wrld_123:456')).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores any code for analysis/ endpoints', () => {
|
||||
expect(shouldIgnoreError(500, 'analysis/something')).toBe(true);
|
||||
expect(shouldIgnoreError(200, 'analysis/data')).toBe(true);
|
||||
});
|
||||
|
||||
test.each([403, -1])('ignores %i for /mutuals endpoints', (code) => {
|
||||
expect(shouldIgnoreError(code, 'users/usr_123/mutuals')).toBe(true);
|
||||
});
|
||||
|
||||
test('does NOT ignore 404 for /mutuals', () => {
|
||||
expect(shouldIgnoreError(404, 'users/usr_123/mutuals')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for unmatched patterns', () => {
|
||||
expect(shouldIgnoreError(500, 'auth/login')).toBe(false);
|
||||
expect(shouldIgnoreError(200, 'config')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles undefined endpoint', () => {
|
||||
expect(shouldIgnoreError(404, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processBulk', () => {
|
||||
test('fetches all pages until empty batch', async () => {
|
||||
const pages = [{ json: [1, 2, 3] }, { json: [4, 5] }, { json: [] }];
|
||||
let call = 0;
|
||||
const fn = vi.fn(() => Promise.resolve(pages[call++]));
|
||||
const handle = vi.fn();
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({ fn, params: { n: 3 }, handle, done });
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(handle).toHaveBeenCalledTimes(3);
|
||||
expect(done).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('stops when N > 0 limit is reached', async () => {
|
||||
const fn = vi.fn(() => Promise.resolve({ json: [1, 2, 3] }));
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({ fn, params: { n: 3 }, N: 5, done });
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(done).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('stops when N = 0 and batch < pageSize', async () => {
|
||||
const pages = [{ json: [1, 2, 3] }, { json: [4] }];
|
||||
let call = 0;
|
||||
const fn = vi.fn(() => Promise.resolve(pages[call++]));
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({ fn, params: { n: 3 }, N: 0, done });
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(done).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('stops when hasNext is false', async () => {
|
||||
const fn = vi.fn(() =>
|
||||
Promise.resolve({ json: [1, 2, 3], hasNext: false })
|
||||
);
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({ fn, params: { n: 3 }, N: -1, done });
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(done).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('supports result.results array format', async () => {
|
||||
const pages = [{ results: [1, 2] }, { results: [] }];
|
||||
let call = 0;
|
||||
const fn = vi.fn(() => Promise.resolve(pages[call++]));
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({ fn, params: { n: 5 }, done });
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(done).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('calls done(false) when fn throws', async () => {
|
||||
const fn = vi.fn(() => Promise.reject(new Error('network error')));
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({ fn, params: { n: 5 }, done });
|
||||
|
||||
expect(done).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('increments offset correctly', async () => {
|
||||
const pages = [{ json: [1, 2, 3] }, { json: [4, 5] }, { json: [] }];
|
||||
let call = 0;
|
||||
const offsets = [];
|
||||
const fn = vi.fn((params) => {
|
||||
offsets.push(params.offset);
|
||||
const result = pages[call++];
|
||||
return Promise.resolve(result);
|
||||
});
|
||||
|
||||
await processBulk({ fn, params: { n: 3 } });
|
||||
|
||||
expect(offsets).toEqual([0, 3, 5]);
|
||||
});
|
||||
|
||||
test('returns early if fn is not a function', async () => {
|
||||
const done = vi.fn();
|
||||
await processBulk({ fn: null, done });
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses custom limitParam', async () => {
|
||||
const pages = [{ json: [1, 2] }, { json: [1] }];
|
||||
let call = 0;
|
||||
const fn = vi.fn(() => Promise.resolve(pages[call++]));
|
||||
const done = vi.fn();
|
||||
|
||||
await processBulk({
|
||||
fn,
|
||||
params: { limit: 2 },
|
||||
limitParam: 'limit',
|
||||
N: 0,
|
||||
done
|
||||
});
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(done).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
97
src/service/__tests__/security.test.js
Normal file
97
src/service/__tests__/security.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import security, {
|
||||
hexToUint8Array,
|
||||
stdAESKey,
|
||||
uint8ArrayToHex
|
||||
} from '../security.js';
|
||||
|
||||
describe('hexToUint8Array', () => {
|
||||
test('converts hex string to Uint8Array', () => {
|
||||
const result = hexToUint8Array('0a1bff');
|
||||
expect(result).toEqual(new Uint8Array([0x0a, 0x1b, 0xff]));
|
||||
});
|
||||
|
||||
test('converts empty-ish input', () => {
|
||||
const result = hexToUint8Array('00');
|
||||
expect(result).toEqual(new Uint8Array([0]));
|
||||
});
|
||||
|
||||
test('returns null for empty string', () => {
|
||||
expect(hexToUint8Array('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uint8ArrayToHex', () => {
|
||||
test('converts Uint8Array to hex string', () => {
|
||||
const result = uint8ArrayToHex(new Uint8Array([0x0a, 0x1b, 0xff]));
|
||||
expect(result).toBe('0a1bff');
|
||||
});
|
||||
|
||||
test('pads single-digit hex values', () => {
|
||||
const result = uint8ArrayToHex(new Uint8Array([0, 1, 2]));
|
||||
expect(result).toBe('000102');
|
||||
});
|
||||
|
||||
test('converts empty array', () => {
|
||||
expect(uint8ArrayToHex(new Uint8Array([]))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hex round-trip', () => {
|
||||
test('uint8Array → hex → uint8Array preserves data', () => {
|
||||
const original = new Uint8Array([0, 127, 255, 42, 1]);
|
||||
const hex = uint8ArrayToHex(original);
|
||||
const restored = hexToUint8Array(hex);
|
||||
expect(restored).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stdAESKey', () => {
|
||||
test('pads short key to 32 bytes', () => {
|
||||
const result = stdAESKey('abc');
|
||||
expect(result.length).toBe(32);
|
||||
// First 3 bytes should be 'abc'
|
||||
expect(result[0]).toBe('a'.charCodeAt(0));
|
||||
expect(result[1]).toBe('b'.charCodeAt(0));
|
||||
expect(result[2]).toBe('c'.charCodeAt(0));
|
||||
});
|
||||
|
||||
test('truncates long key to 32 bytes', () => {
|
||||
const longKey = 'a'.repeat(64);
|
||||
const result = stdAESKey(longKey);
|
||||
expect(result.length).toBe(32);
|
||||
});
|
||||
|
||||
test('32-byte key stays unchanged', () => {
|
||||
const key = 'abcdefghijklmnopqrstuvwxyz012345'; // exactly 32 chars
|
||||
const result = stdAESKey(key);
|
||||
expect(result.length).toBe(32);
|
||||
const expected = new TextEncoder().encode(key);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt / decrypt round-trip', () => {
|
||||
test('encrypts and decrypts plaintext correctly', async () => {
|
||||
const plaintext = 'Hello, VRCX!';
|
||||
const key = 'my-secret-key';
|
||||
|
||||
const ciphertext = await security.encrypt(plaintext, key);
|
||||
expect(typeof ciphertext).toBe('string');
|
||||
expect(ciphertext.length).toBeGreaterThan(0);
|
||||
|
||||
const decrypted = await security.decrypt(ciphertext, key);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test('different keys produce different ciphertext', async () => {
|
||||
const plaintext = 'secret data';
|
||||
const ct1 = await security.encrypt(plaintext, 'key1');
|
||||
const ct2 = await security.encrypt(plaintext, 'key2');
|
||||
expect(ct1).not.toBe(ct2);
|
||||
});
|
||||
|
||||
test('decrypt returns empty string for empty input', async () => {
|
||||
const result = await security.decrypt('', 'key');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import sqliteService from './sqlite.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
function transformKey(key) {
|
||||
return `config:${String(key).toLowerCase()}`;
|
||||
}
|
||||
@@ -162,4 +166,4 @@ class ConfigRepository {
|
||||
var self = new ConfigRepository();
|
||||
window.configRepository = self;
|
||||
|
||||
export { self as default, ConfigRepository };
|
||||
export { self as default, ConfigRepository, transformKey };
|
||||
|
||||
@@ -22,6 +22,64 @@ export let failedGetRequests = new Map();
|
||||
|
||||
const t = i18n.global.t;
|
||||
|
||||
/**
|
||||
* @param {string} endpoint
|
||||
* @param {object} [options]
|
||||
* @returns {object} init object ready for webApiService.execute
|
||||
*/
|
||||
export function buildRequestInit(endpoint, options) {
|
||||
const init = {
|
||||
url: `${AppDebug.endpointDomain}/${endpoint}`,
|
||||
method: 'GET',
|
||||
...options
|
||||
};
|
||||
const { params } = init;
|
||||
if (init.method === 'GET') {
|
||||
// transform body to url
|
||||
if (params === Object(params)) {
|
||||
const url = new URL(init.url);
|
||||
const { searchParams } = url;
|
||||
for (const key in params) {
|
||||
searchParams.set(key, params[key]);
|
||||
}
|
||||
init.url = url.toString();
|
||||
}
|
||||
} else if (
|
||||
init.uploadImage ||
|
||||
init.uploadFilePUT ||
|
||||
init.uploadImageLegacy
|
||||
) {
|
||||
// nothing — upload requests handle their own body
|
||||
} else {
|
||||
init.headers = {
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
...init.headers
|
||||
};
|
||||
init.body = params === Object(params) ? JSON.stringify(params) : '{}';
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw response: JSON-decodes response.data and detects API-level errors.
|
||||
* @param {{status: number, data?: string}} response
|
||||
* @returns {{status: number, data?: any, hasApiError?: boolean, parseError?: boolean}}
|
||||
*/
|
||||
export function parseResponse(response) {
|
||||
if (!response.data) {
|
||||
return response;
|
||||
}
|
||||
try {
|
||||
response.data = JSON.parse(response.data);
|
||||
if (response.data?.error) {
|
||||
return { ...response, hasApiError: true };
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return { ...response, parseError: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {string} endpoint
|
||||
@@ -42,11 +100,7 @@ export function request(endpoint, options) {
|
||||
throw `API request blocked while logged out: ${endpoint}`;
|
||||
}
|
||||
let req;
|
||||
const init = {
|
||||
url: `${AppDebug.endpointDomain}/${endpoint}`,
|
||||
method: 'GET',
|
||||
...options
|
||||
};
|
||||
const init = buildRequestInit(endpoint, options);
|
||||
const { params } = init;
|
||||
if (init.method === 'GET') {
|
||||
// don't retry recent 404/403
|
||||
@@ -62,15 +116,6 @@ export function request(endpoint, options) {
|
||||
}
|
||||
failedGetRequests.delete(endpoint);
|
||||
}
|
||||
// transform body to url
|
||||
if (params === Object(params)) {
|
||||
const url = new URL(init.url);
|
||||
const { searchParams } = url;
|
||||
for (const key in params) {
|
||||
searchParams.set(key, params[key]);
|
||||
}
|
||||
init.url = url.toString();
|
||||
}
|
||||
// merge requests
|
||||
req = pendingGetRequests.get(init.url);
|
||||
if (typeof req !== 'undefined') {
|
||||
@@ -80,18 +125,6 @@ export function request(endpoint, options) {
|
||||
}
|
||||
pendingGetRequests.delete(init.url);
|
||||
}
|
||||
} else if (
|
||||
init.uploadImage ||
|
||||
init.uploadFilePUT ||
|
||||
init.uploadImageLegacy
|
||||
) {
|
||||
// nothing
|
||||
} else {
|
||||
init.headers = {
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
...init.headers
|
||||
};
|
||||
init.body = params === Object(params) ? JSON.stringify(params) : '{}';
|
||||
}
|
||||
req = webApiService
|
||||
.execute(init)
|
||||
@@ -106,47 +139,43 @@ export function request(endpoint, options) {
|
||||
) {
|
||||
throw `API request blocked while logged out: ${endpoint}`;
|
||||
}
|
||||
if (!response.data) {
|
||||
if (AppDebug.debugWebRequests) {
|
||||
console.log(init, 'no data', response);
|
||||
const parsed = parseResponse(response);
|
||||
if (AppDebug.debugWebRequests) {
|
||||
if (!parsed.data) {
|
||||
console.log(init, 'no data', parsed);
|
||||
} else {
|
||||
console.log(init, 'parsed data', parsed.data);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
try {
|
||||
response.data = JSON.parse(response.data);
|
||||
if (AppDebug.debugWebRequests) {
|
||||
console.log(init, 'parsed data', response.data);
|
||||
}
|
||||
if (response.data?.error) {
|
||||
$throw(
|
||||
response.data.error.status_code || 0,
|
||||
response.data.error.message,
|
||||
endpoint
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
if (response.status === 200) {
|
||||
if (parsed.hasApiError) {
|
||||
$throw(
|
||||
0,
|
||||
t('api.error.message.invalid_json_response'),
|
||||
parsed.data.error.status_code || 0,
|
||||
parsed.data.error.message,
|
||||
endpoint
|
||||
);
|
||||
}
|
||||
if (
|
||||
response.status === 429 &&
|
||||
init.url.endsWith('/instances/groups')
|
||||
) {
|
||||
updateLoopStore.nextGroupInstanceRefresh = 120; // 1min
|
||||
$throw(429, t('api.status_code.429'), endpoint);
|
||||
if (parsed.parseError) {
|
||||
console.error('JSON parse error for', endpoint);
|
||||
if (parsed.status === 200) {
|
||||
$throw(
|
||||
0,
|
||||
t('api.error.message.invalid_json_response'),
|
||||
endpoint
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed.status === 429 &&
|
||||
init.url.endsWith('/instances/groups')
|
||||
) {
|
||||
updateLoopStore.nextGroupInstanceRefresh = 120; // 1min
|
||||
$throw(429, t('api.status_code.429'), endpoint);
|
||||
}
|
||||
if (parsed.status === 504 || parsed.status === 502) {
|
||||
// ignore expected API errors
|
||||
$throw(parsed.status, parsed.data || '', endpoint);
|
||||
}
|
||||
}
|
||||
if (response.status === 504 || response.status === 502) {
|
||||
// ignore expected API errors
|
||||
$throw(response.status, response.data || '', endpoint);
|
||||
}
|
||||
return response;
|
||||
return parsed;
|
||||
})
|
||||
.then(({ data, status }) => {
|
||||
if (status === 200) {
|
||||
@@ -258,6 +287,39 @@ export function request(endpoint, options) {
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} code
|
||||
* @param {string} [endpoint]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldIgnoreError(code, endpoint) {
|
||||
if (
|
||||
(code === 404 || code === -1) &&
|
||||
typeof endpoint === 'string' &&
|
||||
endpoint.split('/').length === 2 &&
|
||||
(endpoint.startsWith('users/') ||
|
||||
endpoint.startsWith('worlds/') ||
|
||||
endpoint.startsWith('avatars/') ||
|
||||
endpoint.startsWith('groups/') ||
|
||||
endpoint.startsWith('file/'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(code === 403 || code === 404 || code === -1) &&
|
||||
endpoint?.startsWith('instances/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (endpoint?.startsWith('analysis/')) {
|
||||
return true;
|
||||
}
|
||||
if (endpoint?.endsWith('/mutuals') && (code === 403 || code === -1)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} code
|
||||
* @param {string|object} [error]
|
||||
@@ -284,32 +346,12 @@ export function $throw(code, error, endpoint) {
|
||||
`${t('api.error.message.endpoint')}: "${typeof endpoint === 'string' ? endpoint : JSON.stringify(endpoint)}"`
|
||||
);
|
||||
}
|
||||
let ignoreError = false;
|
||||
const ignoreError = shouldIgnoreError(code, endpoint);
|
||||
if (
|
||||
(code === 404 || code === -1) &&
|
||||
typeof endpoint === 'string' &&
|
||||
endpoint.split('/').length === 2 &&
|
||||
(endpoint.startsWith('users/') ||
|
||||
endpoint.startsWith('worlds/') ||
|
||||
endpoint.startsWith('avatars/') ||
|
||||
endpoint.startsWith('groups/') ||
|
||||
endpoint.startsWith('file/'))
|
||||
(code === 403 || code === 404 || code === -1) &&
|
||||
endpoint?.includes('/mutuals/friends')
|
||||
) {
|
||||
ignoreError = true;
|
||||
}
|
||||
if (code === 403 || code === 404 || code === -1) {
|
||||
if (endpoint?.startsWith('instances/')) {
|
||||
ignoreError = true;
|
||||
}
|
||||
if (endpoint?.includes('/mutuals/friends')) {
|
||||
message[1] = `${t('api.error.message.error_message')}: "${t('api.error.message.unavailable')}"`;
|
||||
}
|
||||
}
|
||||
if (endpoint?.startsWith('analysis/')) {
|
||||
ignoreError = true;
|
||||
}
|
||||
if (endpoint?.endsWith('/mutuals') && (code === 403 || code === -1)) {
|
||||
ignoreError = true;
|
||||
message[1] = `${t('api.error.message.error_message')}: "${t('api.error.message.unavailable')}"`;
|
||||
}
|
||||
const text = message.map((s) => escapeTag(s)).join('\n');
|
||||
|
||||
@@ -327,18 +369,16 @@ export function $throw(code, error, endpoint) {
|
||||
|
||||
/**
|
||||
* Processes data in bulk by making paginated requests until all data is fetched or limits are reached.
|
||||
*
|
||||
* @async
|
||||
* @function processBulk
|
||||
* @param {object} options - Configuration options for bulk processing
|
||||
* @param {function} options.fn - The function to call for each batch request. Must return a result with a 'json' property containing an array
|
||||
* @param {object} [options.params={}] - Parameters to pass to the function. Will be modified to include pagination
|
||||
* @param {number} [options.N=-1] - Maximum number of items to fetch. -1 for unlimited, 0 for fetch until page size not met
|
||||
* @param {string} [options.limitParam='n'] - The parameter name used for page size in the request
|
||||
* @param {object} [options.params] - Parameters to pass to the function. Will be modified to include pagination
|
||||
* @param {number} [options.N] - Maximum number of items to fetch. -1 for unlimited, 0 for fetch until page size not met
|
||||
* @param {string} [options.limitParam] - The parameter name used for page size in the request
|
||||
* @param {function} [options.handle] - Callback function to handle each batch result
|
||||
* @param {function} [options.done] - Callback function called when processing is complete. Receives boolean indicating success
|
||||
* @returns {Promise<void>} Promise that resolves when bulk processing is complete
|
||||
*
|
||||
* @example
|
||||
* await processBulk({
|
||||
* fn: fetchUsers,
|
||||
|
||||
@@ -11,6 +11,10 @@ const hexToUint8Array = (hexStr) => {
|
||||
const uint8ArrayToHex = (arr) =>
|
||||
arr.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
function stdAESKey(key) {
|
||||
const tKey = new TextEncoder().encode(key);
|
||||
let sk = tKey;
|
||||
@@ -22,6 +26,11 @@ function stdAESKey(key) {
|
||||
return sk.slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param plaintext
|
||||
* @param key
|
||||
*/
|
||||
async function encrypt(plaintext, key) {
|
||||
let iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
let sharedKey = await window.crypto.subtle.importKey(
|
||||
@@ -43,6 +52,11 @@ async function encrypt(plaintext, key) {
|
||||
return uint8ArrayToHex(encrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ciphertext
|
||||
* @param key
|
||||
*/
|
||||
async function decrypt(ciphertext, key) {
|
||||
let text = hexToUint8Array(ciphertext);
|
||||
if (!text) return '';
|
||||
@@ -65,3 +79,5 @@ export default {
|
||||
decrypt,
|
||||
encrypt
|
||||
};
|
||||
|
||||
export { hexToUint8Array, uint8ArrayToHex, stdAESKey };
|
||||
|
||||
@@ -265,6 +265,203 @@ describe('getNotificationMessage', () => {
|
||||
);
|
||||
expect(result).toEqual({ title: 'External', body: 'ext msg' });
|
||||
});
|
||||
|
||||
test('inviteResponse', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'inviteResponse', senderUsername: 'Bob' },
|
||||
' (accepted)'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Bob',
|
||||
body: 'has responded to your invite (accepted)'
|
||||
});
|
||||
});
|
||||
|
||||
test('requestInviteResponse', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'requestInviteResponse', senderUsername: 'Bob' },
|
||||
' (declined)'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Bob',
|
||||
body: 'has responded to your invite request (declined)'
|
||||
});
|
||||
});
|
||||
|
||||
test('Unfriend', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Unfriend', displayName: 'Eve' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Eve',
|
||||
body: 'is no longer your friend'
|
||||
});
|
||||
});
|
||||
|
||||
test('TrustLevel', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'TrustLevel', displayName: 'Dave', trustLevel: 'Known' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Dave',
|
||||
body: 'trust level is now Known'
|
||||
});
|
||||
});
|
||||
|
||||
test('AvatarChange', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'AvatarChange', displayName: 'Alice', name: 'CoolAvatar' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Alice',
|
||||
body: 'changed into avatar CoolAvatar'
|
||||
});
|
||||
});
|
||||
|
||||
test('ChatBoxMessage', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'ChatBoxMessage', displayName: 'Bob', text: 'hello!' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Bob', body: 'said hello!' });
|
||||
});
|
||||
|
||||
test('Blocked', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Blocked', displayName: 'Troll' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Troll', body: 'has blocked you' });
|
||||
});
|
||||
|
||||
test('Unblocked', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Unblocked', displayName: 'Troll' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Troll',
|
||||
body: 'has unblocked you'
|
||||
});
|
||||
});
|
||||
|
||||
test('Muted', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Muted', displayName: 'Alice' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Alice', body: 'has muted you' });
|
||||
});
|
||||
|
||||
test('Unmuted', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Unmuted', displayName: 'Alice' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Alice', body: 'has unmuted you' });
|
||||
});
|
||||
|
||||
test('BlockedOnPlayerLeft', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'BlockedOnPlayerLeft', displayName: 'Troll' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Troll',
|
||||
body: 'Blocked user has left'
|
||||
});
|
||||
});
|
||||
|
||||
test('MutedOnPlayerJoined', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'MutedOnPlayerJoined', displayName: 'MutedUser' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'MutedUser',
|
||||
body: 'Muted user has joined'
|
||||
});
|
||||
});
|
||||
|
||||
test('MutedOnPlayerLeft', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'MutedOnPlayerLeft', displayName: 'MutedUser' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'MutedUser',
|
||||
body: 'Muted user has left'
|
||||
});
|
||||
});
|
||||
|
||||
test('group.informative', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'group.informative', message: 'Info msg' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Group Informative',
|
||||
body: 'Info msg'
|
||||
});
|
||||
});
|
||||
|
||||
test('group.invite', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'group.invite', message: 'Join us' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Group Invite',
|
||||
body: 'Join us'
|
||||
});
|
||||
});
|
||||
|
||||
test('group.joinRequest', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'group.joinRequest', message: 'Request' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Group Join Request',
|
||||
body: 'Request'
|
||||
});
|
||||
});
|
||||
|
||||
test('group.transfer', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'group.transfer', message: 'Transfer ownership' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Group Transfer Request',
|
||||
body: 'Transfer ownership'
|
||||
});
|
||||
});
|
||||
|
||||
test('group.queueReady', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'group.queueReady', message: 'Queue is ready' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Instance Queue Ready',
|
||||
body: 'Queue is ready'
|
||||
});
|
||||
});
|
||||
|
||||
test('instance.closed', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'instance.closed', message: 'Closed' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Instance Closed',
|
||||
body: 'Closed'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNotificationText', () => {
|
||||
|
||||
151
src/shared/utils/__tests__/common.test.js
Normal file
151
src/shared/utils/__tests__/common.test.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Mock AppDebug
|
||||
vi.mock('../../../service/appConfig', () => ({
|
||||
AppDebug: { endpointDomain: 'https://api.vrchat.cloud/api/1' }
|
||||
}));
|
||||
|
||||
// Mock transitive deps
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
import { convertFileUrlToImageUrl, debounce } from '../common';
|
||||
|
||||
describe('convertFileUrlToImageUrl', () => {
|
||||
test('converts standard file URL to image URL', () => {
|
||||
const url =
|
||||
'https://api.vrchat.cloud/api/1/file/file_abc123-def456/1/file';
|
||||
const result = convertFileUrlToImageUrl(url);
|
||||
expect(result).toBe(
|
||||
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/1/128'
|
||||
);
|
||||
});
|
||||
|
||||
test('converts URL without trailing /file', () => {
|
||||
const url = 'https://api.vrchat.cloud/api/1/file/file_abc123-def456/1';
|
||||
const result = convertFileUrlToImageUrl(url);
|
||||
expect(result).toBe(
|
||||
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/1/128'
|
||||
);
|
||||
});
|
||||
|
||||
test('converts URL with trailing slash', () => {
|
||||
const url = 'https://api.vrchat.cloud/api/1/file/file_abc123-def456/2/';
|
||||
const result = convertFileUrlToImageUrl(url);
|
||||
expect(result).toBe(
|
||||
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/2/128'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts custom resolution', () => {
|
||||
const url =
|
||||
'https://api.vrchat.cloud/api/1/file/file_abc123-def456/1/file';
|
||||
const result = convertFileUrlToImageUrl(url, 256);
|
||||
expect(result).toBe(
|
||||
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/1/256'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns original URL when pattern does not match', () => {
|
||||
const url = 'https://example.com/some/other/path';
|
||||
expect(convertFileUrlToImageUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(convertFileUrlToImageUrl('')).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string for null input', () => {
|
||||
expect(convertFileUrlToImageUrl(null)).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string for undefined input', () => {
|
||||
expect(convertFileUrlToImageUrl(undefined)).toBe('');
|
||||
});
|
||||
|
||||
test('handles URL with /file/file path', () => {
|
||||
const url =
|
||||
'https://api.vrchat.cloud/api/1/file/file_aabbccdd-1234-5678-9012-abcdef123456/5/file/';
|
||||
const result = convertFileUrlToImageUrl(url, 64);
|
||||
expect(result).toBe(
|
||||
'https://api.vrchat.cloud/api/1/image/file_aabbccdd-1234-5678-9012-abcdef123456/5/64'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('delays function execution', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('resets timer on subsequent calls', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
// Only 50ms since last call, should not fire yet
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('passes arguments to debounced function', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('arg1', 'arg2');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
|
||||
test('uses latest arguments when called multiple times', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('first');
|
||||
debounced('second');
|
||||
debounced('third');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
expect(fn).toHaveBeenCalledWith('third');
|
||||
});
|
||||
|
||||
test('can be called again after execution', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
compareByCreatedAt,
|
||||
compareByCreatedAtAscending,
|
||||
compareByDisplayName,
|
||||
compareById,
|
||||
compareByFriendOrder,
|
||||
compareByLastActive,
|
||||
compareByLastActiveRef,
|
||||
compareByLastSeen,
|
||||
compareByLocation,
|
||||
compareByLocationAt,
|
||||
@@ -376,6 +379,103 @@ describe('Compare Functions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareById', () => {
|
||||
test('compares objects by id property ascending', () => {
|
||||
const a = { id: 'usr_aaa' };
|
||||
const b = { id: 'usr_bbb' };
|
||||
expect(compareById(a, b)).toBeLessThan(0);
|
||||
expect(compareById(b, a)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('returns 0 for equal ids', () => {
|
||||
const a = { id: 'usr_123' };
|
||||
const b = { id: 'usr_123' };
|
||||
expect(compareById(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test('handles non-string id properties', () => {
|
||||
expect(compareById({ id: null }, { id: 'usr_1' })).toBe(0);
|
||||
expect(compareById({}, { id: 'usr_1' })).toBe(0);
|
||||
expect(compareById({ id: 123 }, { id: 'usr_1' })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareByLastActiveRef', () => {
|
||||
test('compares online users by $online_for descending', () => {
|
||||
const a = { state: 'online', $online_for: 100 };
|
||||
const b = { state: 'online', $online_for: 200 };
|
||||
// a.$online_for < b.$online_for → 1 (b is more recent)
|
||||
expect(compareByLastActiveRef(a, b)).toBe(1);
|
||||
expect(compareByLastActiveRef(b, a)).toBe(-1);
|
||||
});
|
||||
|
||||
test('falls back to last_login when $online_for is equal', () => {
|
||||
const a = {
|
||||
state: 'online',
|
||||
$online_for: 100,
|
||||
last_login: '2023-01-01'
|
||||
};
|
||||
const b = {
|
||||
state: 'online',
|
||||
$online_for: 100,
|
||||
last_login: '2023-01-02'
|
||||
};
|
||||
expect(compareByLastActiveRef(a, b)).toBe(1);
|
||||
expect(compareByLastActiveRef(b, a)).toBe(-1);
|
||||
});
|
||||
|
||||
test('compares non-online users by last_activity descending', () => {
|
||||
const a = {
|
||||
state: 'offline',
|
||||
last_activity: '2023-01-01'
|
||||
};
|
||||
const b = {
|
||||
state: 'offline',
|
||||
last_activity: '2023-01-02'
|
||||
};
|
||||
expect(compareByLastActiveRef(a, b)).toBe(1);
|
||||
expect(compareByLastActiveRef(b, a)).toBe(-1);
|
||||
});
|
||||
|
||||
test('compares mixed online states by last_activity', () => {
|
||||
const a = {
|
||||
state: 'online',
|
||||
last_activity: '2023-06-01'
|
||||
};
|
||||
const b = {
|
||||
state: 'offline',
|
||||
last_activity: '2023-01-01'
|
||||
};
|
||||
// not both online, so compares by last_activity
|
||||
expect(compareByLastActiveRef(a, b)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareByFriendOrder', () => {
|
||||
test('compares by $friendNumber descending', () => {
|
||||
const a = { $friendNumber: 10 };
|
||||
const b = { $friendNumber: 20 };
|
||||
// b.$friendNumber - a.$friendNumber = 10
|
||||
expect(compareByFriendOrder(a, b)).toBe(10);
|
||||
expect(compareByFriendOrder(b, a)).toBe(-10);
|
||||
});
|
||||
|
||||
test('returns 0 for equal $friendNumber', () => {
|
||||
const a = { $friendNumber: 5 };
|
||||
const b = { $friendNumber: 5 };
|
||||
expect(compareByFriendOrder(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test('handles undefined inputs', () => {
|
||||
expect(compareByFriendOrder(undefined, { $friendNumber: 1 })).toBe(
|
||||
0
|
||||
);
|
||||
expect(compareByFriendOrder({ $friendNumber: 1 }, undefined)).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and boundary conditions', () => {
|
||||
test('handles null objects', () => {
|
||||
// compareByName doesn't handle null objects - it will throw
|
||||
|
||||
88
src/shared/utils/__tests__/csv.test.js
Normal file
88
src/shared/utils/__tests__/csv.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatCsvField, formatCsvRow, needsCsvQuotes } from '../csv';
|
||||
|
||||
describe('needsCsvQuotes', () => {
|
||||
it('returns false for plain text', () => {
|
||||
expect(needsCsvQuotes('hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for text containing commas', () => {
|
||||
expect(needsCsvQuotes('hello,world')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for text containing double quotes', () => {
|
||||
expect(needsCsvQuotes('say "hi"')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for text with control characters', () => {
|
||||
expect(needsCsvQuotes('line\nbreak')).toBe(true);
|
||||
expect(needsCsvQuotes('tab\there')).toBe(true);
|
||||
expect(needsCsvQuotes('\x00null')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(needsCsvQuotes('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCsvField', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatCsvField(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(formatCsvField(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns plain string unchanged', () => {
|
||||
expect(formatCsvField('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('converts numbers to strings', () => {
|
||||
expect(formatCsvField(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('wraps text with commas in double quotes', () => {
|
||||
expect(formatCsvField('a,b')).toBe('"a,b"');
|
||||
});
|
||||
|
||||
it('escapes existing double quotes by doubling', () => {
|
||||
expect(formatCsvField('say "hi"')).toBe('"say ""hi"""');
|
||||
});
|
||||
|
||||
it('wraps text with newlines in double quotes', () => {
|
||||
expect(formatCsvField('line\nbreak')).toBe('"line\nbreak"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCsvRow', () => {
|
||||
it('formats selected fields from an object', () => {
|
||||
const obj = {
|
||||
id: 'avtr_123',
|
||||
name: 'Test Avatar',
|
||||
authorName: 'Author'
|
||||
};
|
||||
expect(formatCsvRow(obj, ['id', 'name'])).toBe('avtr_123,Test Avatar');
|
||||
});
|
||||
|
||||
it('handles missing fields as empty strings', () => {
|
||||
const obj = { id: 'avtr_123' };
|
||||
expect(formatCsvRow(obj, ['id', 'name'])).toBe('avtr_123,');
|
||||
});
|
||||
|
||||
it('escapes fields that need quoting', () => {
|
||||
const obj = { id: 'avtr_123', name: 'Test, Avatar' };
|
||||
expect(formatCsvRow(obj, ['id', 'name'])).toBe(
|
||||
'avtr_123,"Test, Avatar"'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles null obj gracefully', () => {
|
||||
expect(formatCsvRow(null, ['id', 'name'])).toBe(',');
|
||||
});
|
||||
|
||||
it('returns empty string for no fields', () => {
|
||||
expect(formatCsvRow({ id: '1' }, [])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,213 @@
|
||||
import { isFriendOnline, sortStatus } from '../friend';
|
||||
import { getFriendsSortFunction, isFriendOnline, sortStatus } from '../friend';
|
||||
|
||||
describe('Friend Utils', () => {
|
||||
describe('sortStatus', () => {
|
||||
test('handles same status', () => {
|
||||
expect(sortStatus('active', 'active')).toBe(0);
|
||||
expect(sortStatus('join me', 'join me')).toBe(0);
|
||||
const statuses = ['join me', 'active', 'ask me', 'busy', 'offline'];
|
||||
|
||||
test('returns 0 for same status', () => {
|
||||
for (const s of statuses) {
|
||||
expect(sortStatus(s, s)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('handles unknown status', () => {
|
||||
test('sorts statuses in priority order: join me > active > ask me > busy > offline', () => {
|
||||
// Higher priority status vs lower priority → negative
|
||||
expect(sortStatus('join me', 'active')).toBe(-1);
|
||||
expect(sortStatus('join me', 'ask me')).toBe(-1);
|
||||
expect(sortStatus('join me', 'busy')).toBe(-1);
|
||||
expect(sortStatus('join me', 'offline')).toBe(-1);
|
||||
|
||||
expect(sortStatus('active', 'ask me')).toBe(-1);
|
||||
expect(sortStatus('active', 'busy')).toBe(-1);
|
||||
expect(sortStatus('active', 'offline')).toBe(-1);
|
||||
|
||||
expect(sortStatus('ask me', 'busy')).toBe(-1);
|
||||
expect(sortStatus('ask me', 'offline')).toBe(-1);
|
||||
|
||||
expect(sortStatus('busy', 'offline')).toBe(-1);
|
||||
});
|
||||
|
||||
test('lower priority vs higher priority → positive', () => {
|
||||
expect(sortStatus('active', 'join me')).toBe(1);
|
||||
expect(sortStatus('busy', 'active')).toBe(1);
|
||||
expect(sortStatus('offline', 'join me')).toBe(1);
|
||||
expect(sortStatus('offline', 'busy')).toBe(1);
|
||||
});
|
||||
|
||||
test('returns 0 for unknown statuses', () => {
|
||||
expect(sortStatus('unknown', 'active')).toBe(0);
|
||||
// @ts-ignore
|
||||
expect(sortStatus('active', 'unknown')).toBe(0);
|
||||
expect(sortStatus(null, 'active')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFriendOnline', () => {
|
||||
test('detects online friends', () => {
|
||||
const friend = { state: 'online', ref: { location: 'world' } };
|
||||
expect(isFriendOnline(friend)).toBe(true);
|
||||
test('returns true for online friends', () => {
|
||||
expect(
|
||||
isFriendOnline({ state: 'online', ref: { location: 'wrld_1' } })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('handles missing data', () => {
|
||||
test('returns true for non-online friends with non-private location', () => {
|
||||
// This is the "wat" case in the code
|
||||
expect(
|
||||
isFriendOnline({
|
||||
state: 'active',
|
||||
ref: { location: 'wrld_1' }
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for friends in private with non-online state', () => {
|
||||
expect(
|
||||
isFriendOnline({
|
||||
state: 'active',
|
||||
ref: { location: 'private' }
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for undefined or missing ref', () => {
|
||||
expect(isFriendOnline(undefined)).toBe(false);
|
||||
expect(isFriendOnline({})).toBe(false);
|
||||
expect(isFriendOnline({ state: 'online' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFriendsSortFunction', () => {
|
||||
test('returns a comparator function', () => {
|
||||
const fn = getFriendsSortFunction(['Sort Alphabetically']);
|
||||
expect(typeof fn).toBe('function');
|
||||
});
|
||||
|
||||
test('sorts alphabetically by name', () => {
|
||||
const fn = getFriendsSortFunction(['Sort Alphabetically']);
|
||||
const a = { name: 'Alice', ref: {} };
|
||||
const b = { name: 'Bob', ref: {} };
|
||||
expect(fn(a, b)).toBeLessThan(0);
|
||||
expect(fn(b, a)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('sorts private to bottom', () => {
|
||||
const fn = getFriendsSortFunction(['Sort Private to Bottom']);
|
||||
const pub = { ref: { location: 'wrld_1' } };
|
||||
const priv = { ref: { location: 'private' } };
|
||||
expect(fn(priv, pub)).toBe(1);
|
||||
expect(fn(pub, priv)).toBe(-1);
|
||||
});
|
||||
|
||||
test('sorts by status', () => {
|
||||
const fn = getFriendsSortFunction(['Sort by Status']);
|
||||
const joinMe = { ref: { status: 'join me', state: 'online' } };
|
||||
const busy = { ref: { status: 'busy', state: 'online' } };
|
||||
expect(fn(joinMe, busy)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('sorts by last active', () => {
|
||||
const fn = getFriendsSortFunction(['Sort by Last Active']);
|
||||
const a = {
|
||||
state: 'offline',
|
||||
ref: { last_activity: '2023-01-01' }
|
||||
};
|
||||
const b = {
|
||||
state: 'offline',
|
||||
ref: { last_activity: '2023-06-01' }
|
||||
};
|
||||
expect(fn(a, b)).toBe(1);
|
||||
});
|
||||
|
||||
test('sorts by last seen', () => {
|
||||
const fn = getFriendsSortFunction(['Sort by Last Seen']);
|
||||
const a = { ref: { $lastSeen: '2023-01-01' } };
|
||||
const b = { ref: { $lastSeen: '2023-06-01' } };
|
||||
expect(fn(a, b)).toBe(1);
|
||||
});
|
||||
|
||||
test('sorts by time in instance', () => {
|
||||
const fn = getFriendsSortFunction(['Sort by Time in Instance']);
|
||||
const a = {
|
||||
state: 'online',
|
||||
pendingOffline: false,
|
||||
ref: { $location_at: 100, location: 'wrld_1' }
|
||||
};
|
||||
const b = {
|
||||
state: 'online',
|
||||
pendingOffline: false,
|
||||
ref: { $location_at: 200, location: 'wrld_2' }
|
||||
};
|
||||
// compareByLocationAt(b.ref, a.ref): b.$location_at(200) > a.$location_at(100) → 1
|
||||
expect(fn(a, b)).toBe(1);
|
||||
});
|
||||
|
||||
test('sorts pending offline to bottom for time in instance', () => {
|
||||
const fn = getFriendsSortFunction(['Sort by Time in Instance']);
|
||||
const pending = {
|
||||
pendingOffline: true,
|
||||
ref: { $location_at: 100 }
|
||||
};
|
||||
const active = {
|
||||
pendingOffline: false,
|
||||
state: 'online',
|
||||
ref: { $location_at: 200 }
|
||||
};
|
||||
expect(fn(pending, active)).toBe(1);
|
||||
expect(fn(active, pending)).toBe(-1);
|
||||
});
|
||||
|
||||
test('sorts by location', () => {
|
||||
const fn = getFriendsSortFunction(['Sort by Location']);
|
||||
const a = { state: 'online', ref: { location: 'aaa' } };
|
||||
const b = { state: 'online', ref: { location: 'zzz' } };
|
||||
expect(fn(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('None sort returns 0', () => {
|
||||
const fn = getFriendsSortFunction(['None']);
|
||||
const a = { name: 'Zack' };
|
||||
const b = { name: 'Alice' };
|
||||
expect(fn(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test('applies multiple sort methods in order (tie-breaking)', () => {
|
||||
const fn = getFriendsSortFunction([
|
||||
'Sort by Status',
|
||||
'Sort Alphabetically'
|
||||
]);
|
||||
// Same status → tie → falls to alphabetical
|
||||
const a = {
|
||||
name: 'Alice',
|
||||
ref: { status: 'active', state: 'online' }
|
||||
};
|
||||
const b = {
|
||||
name: 'Bob',
|
||||
ref: { status: 'active', state: 'online' }
|
||||
};
|
||||
expect(fn(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('first sort wins when not tied', () => {
|
||||
const fn = getFriendsSortFunction([
|
||||
'Sort by Status',
|
||||
'Sort Alphabetically'
|
||||
]);
|
||||
const joinMe = {
|
||||
name: 'Zack',
|
||||
ref: { status: 'join me', state: 'online' }
|
||||
};
|
||||
const busy = {
|
||||
name: 'Alice',
|
||||
ref: { status: 'busy', state: 'online' }
|
||||
};
|
||||
// status differs → alphabetical not reached
|
||||
expect(fn(joinMe, busy)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('handles empty sort methods array', () => {
|
||||
const fn = getFriendsSortFunction([]);
|
||||
const a = { name: 'Alice' };
|
||||
const b = { name: 'Bob' };
|
||||
// No sort functions → result is undefined from loop
|
||||
expect(fn(a, b)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
102
src/shared/utils/__tests__/group.test.js
Normal file
102
src/shared/utils/__tests__/group.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { hasGroupModerationPermission, hasGroupPermission } from '../group';
|
||||
|
||||
// Mock transitive deps to avoid import errors
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
describe('Group Utils', () => {
|
||||
describe('hasGroupPermission', () => {
|
||||
test('returns true when permission is in list', () => {
|
||||
const ref = {
|
||||
myMember: {
|
||||
permissions: ['group-bans-manage', 'group-audit-view']
|
||||
}
|
||||
};
|
||||
expect(hasGroupPermission(ref, 'group-bans-manage')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true when wildcard permission is present', () => {
|
||||
const ref = { myMember: { permissions: ['*'] } };
|
||||
expect(hasGroupPermission(ref, 'group-bans-manage')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when permission is not in list', () => {
|
||||
const ref = {
|
||||
myMember: { permissions: ['group-bans-manage'] }
|
||||
};
|
||||
expect(hasGroupPermission(ref, 'group-audit-view')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when permissions array is empty', () => {
|
||||
const ref = { myMember: { permissions: [] } };
|
||||
expect(hasGroupPermission(ref, 'group-bans-manage')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when myMember is null', () => {
|
||||
expect(hasGroupPermission({ myMember: null }, 'x')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when ref is null', () => {
|
||||
expect(hasGroupPermission(null, 'x')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when ref is undefined', () => {
|
||||
expect(hasGroupPermission(undefined, 'x')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when permissions is missing', () => {
|
||||
const ref = { myMember: {} };
|
||||
expect(hasGroupPermission(ref, 'x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGroupModerationPermission', () => {
|
||||
test('returns true for any single moderation permission', () => {
|
||||
const permissions = [
|
||||
'group-invites-manage',
|
||||
'group-moderates-manage',
|
||||
'group-audit-view',
|
||||
'group-bans-manage',
|
||||
'group-data-manage',
|
||||
'group-members-manage',
|
||||
'group-members-remove',
|
||||
'group-roles-assign',
|
||||
'group-roles-manage',
|
||||
'group-default-role-manage'
|
||||
];
|
||||
|
||||
for (const perm of permissions) {
|
||||
const ref = { myMember: { permissions: [perm] } };
|
||||
expect(hasGroupModerationPermission(ref)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns true for wildcard', () => {
|
||||
const ref = { myMember: { permissions: ['*'] } };
|
||||
expect(hasGroupModerationPermission(ref)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-moderation permissions', () => {
|
||||
const ref = {
|
||||
myMember: {
|
||||
permissions: ['group-announcements-manage']
|
||||
}
|
||||
};
|
||||
expect(hasGroupModerationPermission(ref)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty permissions', () => {
|
||||
const ref = { myMember: { permissions: [] } };
|
||||
expect(hasGroupModerationPermission(ref)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for null ref', () => {
|
||||
expect(hasGroupModerationPermission(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
src/shared/utils/__tests__/imageUpload.test.js
Normal file
177
src/shared/utils/__tests__/imageUpload.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Mock transitive deps to avoid i18n init errors
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { error: vi.fn() }
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/request', () => ({
|
||||
$throw: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../../service/appConfig', () => ({
|
||||
AppDebug: { endpointDomain: 'https://api.vrchat.cloud/api/1' }
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/index.js', () => ({
|
||||
extractFileId: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
imageRequest: {}
|
||||
}));
|
||||
|
||||
import { toast } from 'vue-sonner';
|
||||
import { handleImageUploadInput, withUploadTimeout } from '../imageUpload';
|
||||
|
||||
// ─── withUploadTimeout ───────────────────────────────────────────────
|
||||
|
||||
describe('withUploadTimeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('resolves if promise resolves before timeout', async () => {
|
||||
const promise = withUploadTimeout(Promise.resolve('done'));
|
||||
await expect(promise).resolves.toBe('done');
|
||||
});
|
||||
|
||||
test('rejects with timeout error if promise is too slow', async () => {
|
||||
const neverResolves = new Promise(() => {});
|
||||
const result = withUploadTimeout(neverResolves);
|
||||
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
await expect(result).rejects.toThrow('Upload timed out');
|
||||
});
|
||||
|
||||
test('resolves if promise finishes just before timeout', async () => {
|
||||
const slowPromise = new Promise((resolve) => {
|
||||
setTimeout(() => resolve('just in time'), 29_999);
|
||||
});
|
||||
const result = withUploadTimeout(slowPromise);
|
||||
|
||||
vi.advanceTimersByTime(29_999);
|
||||
|
||||
await expect(result).resolves.toBe('just in time');
|
||||
});
|
||||
|
||||
test('rejects if underlying promise rejects', async () => {
|
||||
const failingPromise = Promise.reject(new Error('upload failed'));
|
||||
await expect(withUploadTimeout(failingPromise)).rejects.toThrow(
|
||||
'upload failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── handleImageUploadInput ──────────────────────────────────────────
|
||||
|
||||
describe('handleImageUploadInput', () => {
|
||||
const makeFile = (size = 1000, type = 'image/png') => ({
|
||||
size,
|
||||
type
|
||||
});
|
||||
const makeEvent = (file) => ({
|
||||
target: { files: file ? [file] : [] }
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns null file when no files in event', () => {
|
||||
const { file } = handleImageUploadInput({ target: { files: [] } });
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null file when event has no target', () => {
|
||||
const { file } = handleImageUploadInput({});
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
test('returns file for valid image within size limit', () => {
|
||||
const mockFile = makeFile(5000, 'image/png');
|
||||
const { file } = handleImageUploadInput(makeEvent(mockFile));
|
||||
expect(file).toBe(mockFile);
|
||||
});
|
||||
|
||||
test('returns null file when file exceeds maxSize', () => {
|
||||
const mockFile = makeFile(20_000_001, 'image/png');
|
||||
const { file } = handleImageUploadInput(makeEvent(mockFile));
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
test('shows toast error when file exceeds maxSize and tooLargeMessage provided', () => {
|
||||
const mockFile = makeFile(20_000_001, 'image/png');
|
||||
handleImageUploadInput(makeEvent(mockFile), {
|
||||
tooLargeMessage: 'File too large!'
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('File too large!');
|
||||
});
|
||||
|
||||
test('supports function as tooLargeMessage', () => {
|
||||
const mockFile = makeFile(20_000_001, 'image/png');
|
||||
handleImageUploadInput(makeEvent(mockFile), {
|
||||
tooLargeMessage: () => 'Dynamic error'
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('Dynamic error');
|
||||
});
|
||||
|
||||
test('returns null file when file type does not match acceptPattern', () => {
|
||||
const mockFile = makeFile(1000, 'text/plain');
|
||||
const { file } = handleImageUploadInput(makeEvent(mockFile));
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
test('shows toast error for invalid type when invalidTypeMessage provided', () => {
|
||||
const mockFile = makeFile(1000, 'text/plain');
|
||||
handleImageUploadInput(makeEvent(mockFile), {
|
||||
invalidTypeMessage: 'Wrong type!'
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('Wrong type!');
|
||||
});
|
||||
|
||||
test('respects custom maxSize', () => {
|
||||
const mockFile = makeFile(600, 'image/png');
|
||||
const { file } = handleImageUploadInput(makeEvent(mockFile), {
|
||||
maxSize: 500
|
||||
});
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
test('respects custom acceptPattern as string', () => {
|
||||
const mockFile = makeFile(1000, 'video/mp4');
|
||||
const { file } = handleImageUploadInput(makeEvent(mockFile), {
|
||||
acceptPattern: 'video.*'
|
||||
});
|
||||
expect(file).toBe(mockFile);
|
||||
});
|
||||
|
||||
test('returns clearInput function', () => {
|
||||
const mockFile = makeFile(1000, 'image/png');
|
||||
const { clearInput } = handleImageUploadInput(makeEvent(mockFile));
|
||||
expect(typeof clearInput).toBe('function');
|
||||
});
|
||||
|
||||
test('calls onClear callback when clearing', () => {
|
||||
const onClear = vi.fn();
|
||||
const { clearInput } = handleImageUploadInput(
|
||||
{ target: { files: [] } },
|
||||
{ onClear }
|
||||
);
|
||||
// clearInput is called automatically for empty files, but let's call explicitly
|
||||
clearInput();
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reads files from dataTransfer when target.files absent', () => {
|
||||
const mockFile = makeFile(1000, 'image/png');
|
||||
const event = { dataTransfer: { files: [mockFile] } };
|
||||
const { file } = handleImageUploadInput(event);
|
||||
expect(file).toBe(mockFile);
|
||||
});
|
||||
});
|
||||
143
src/shared/utils/__tests__/instance.test.js
Normal file
143
src/shared/utils/__tests__/instance.test.js
Normal file
@@ -0,0 +1,143 @@
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: {}
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn() }
|
||||
}));
|
||||
|
||||
import { buildLegacyInstanceTag } from '../instance';
|
||||
|
||||
const base = {
|
||||
instanceName: '12345',
|
||||
userId: 'usr_test',
|
||||
accessType: 'public',
|
||||
region: 'US West'
|
||||
};
|
||||
|
||||
describe('buildLegacyInstanceTag', () => {
|
||||
test('public instance with US West region', () => {
|
||||
expect(buildLegacyInstanceTag(base)).toBe('12345~region(us)');
|
||||
});
|
||||
|
||||
test('public instance with US East region', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, region: 'US East' })).toBe(
|
||||
'12345~region(use)'
|
||||
);
|
||||
});
|
||||
|
||||
test('public instance with Europe region', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, region: 'Europe' })).toBe(
|
||||
'12345~region(eu)'
|
||||
);
|
||||
});
|
||||
|
||||
test('public instance with Japan region', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, region: 'Japan' })).toBe(
|
||||
'12345~region(jp)'
|
||||
);
|
||||
});
|
||||
|
||||
test('friends+ adds hidden tag', () => {
|
||||
expect(
|
||||
buildLegacyInstanceTag({ ...base, accessType: 'friends+' })
|
||||
).toBe('12345~hidden(usr_test)~region(us)');
|
||||
});
|
||||
|
||||
test('friends adds friends tag', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, accessType: 'friends' })).toBe(
|
||||
'12345~friends(usr_test)~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('invite adds private tag and canRequestInvite', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, accessType: 'invite+' })).toBe(
|
||||
'12345~private(usr_test)~canRequestInvite~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('invite (no +) adds private tag without canRequestInvite', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, accessType: 'invite' })).toBe(
|
||||
'12345~private(usr_test)~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('group adds group and groupAccessType tags', () => {
|
||||
expect(
|
||||
buildLegacyInstanceTag({
|
||||
...base,
|
||||
accessType: 'group',
|
||||
groupId: 'grp_abc',
|
||||
groupAccessType: 'plus'
|
||||
})
|
||||
).toBe('12345~group(grp_abc)~groupAccessType(plus)~region(us)');
|
||||
});
|
||||
|
||||
test('group with ageGate appends ~ageGate', () => {
|
||||
expect(
|
||||
buildLegacyInstanceTag({
|
||||
...base,
|
||||
accessType: 'group',
|
||||
groupId: 'grp_abc',
|
||||
groupAccessType: 'members',
|
||||
ageGate: true
|
||||
})
|
||||
).toBe(
|
||||
'12345~group(grp_abc)~groupAccessType(members)~ageGate~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('ageGate ignored for non-group access types', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, ageGate: true })).toBe(
|
||||
'12345~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('strict appended for invite access type', () => {
|
||||
expect(
|
||||
buildLegacyInstanceTag({
|
||||
...base,
|
||||
accessType: 'invite',
|
||||
strict: true
|
||||
})
|
||||
).toBe('12345~private(usr_test)~region(us)~strict');
|
||||
});
|
||||
|
||||
test('strict appended for friends access type', () => {
|
||||
expect(
|
||||
buildLegacyInstanceTag({
|
||||
...base,
|
||||
accessType: 'friends',
|
||||
strict: true
|
||||
})
|
||||
).toBe('12345~friends(usr_test)~region(us)~strict');
|
||||
});
|
||||
|
||||
test('strict ignored for public access type', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, strict: true })).toBe(
|
||||
'12345~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('strict ignored for friends+ access type', () => {
|
||||
expect(
|
||||
buildLegacyInstanceTag({
|
||||
...base,
|
||||
accessType: 'friends+',
|
||||
strict: true
|
||||
})
|
||||
).toBe('12345~hidden(usr_test)~region(us)');
|
||||
});
|
||||
|
||||
test('empty instanceName produces no leading segment', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, instanceName: '' })).toBe(
|
||||
'~region(us)'
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown region produces no region tag', () => {
|
||||
expect(buildLegacyInstanceTag({ ...base, region: 'Mars' })).toBe(
|
||||
'12345'
|
||||
);
|
||||
});
|
||||
});
|
||||
152
src/shared/utils/__tests__/invite.test.js
Normal file
152
src/shared/utils/__tests__/invite.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Mock stores
|
||||
vi.mock('../../../stores', () => ({
|
||||
useFriendStore: vi.fn(),
|
||||
useInstanceStore: vi.fn(),
|
||||
useLocationStore: vi.fn(),
|
||||
useUserStore: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock transitive deps
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
import {
|
||||
useFriendStore,
|
||||
useInstanceStore,
|
||||
useLocationStore,
|
||||
useUserStore
|
||||
} from '../../../stores';
|
||||
import { checkCanInvite, checkCanInviteSelf } from '../invite';
|
||||
|
||||
describe('Invite Utils', () => {
|
||||
beforeEach(() => {
|
||||
useUserStore.mockReturnValue({
|
||||
currentUser: { id: 'usr_me' }
|
||||
});
|
||||
useLocationStore.mockReturnValue({
|
||||
lastLocation: { location: 'wrld_last:12345' }
|
||||
});
|
||||
useInstanceStore.mockReturnValue({
|
||||
cachedInstances: new Map()
|
||||
});
|
||||
useFriendStore.mockReturnValue({
|
||||
friends: new Map()
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkCanInvite', () => {
|
||||
test('returns false for empty location', () => {
|
||||
expect(checkCanInvite('')).toBe(false);
|
||||
expect(checkCanInvite(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for public instance', () => {
|
||||
expect(checkCanInvite('wrld_123:instance')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for group instance', () => {
|
||||
expect(
|
||||
checkCanInvite(
|
||||
'wrld_123:instance~group(grp_123)~groupAccessType(public)'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for own instance', () => {
|
||||
expect(checkCanInvite('wrld_123:instance~private(usr_me)')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false for invite-only instance owned by another', () => {
|
||||
expect(checkCanInvite('wrld_123:instance~private(usr_other)')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false for friends-only instance', () => {
|
||||
expect(checkCanInvite('wrld_123:instance~friends(usr_other)')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('returns true for friends+ instance if current location matches', () => {
|
||||
const location = 'wrld_123:instance~hidden(usr_other)';
|
||||
useLocationStore.mockReturnValue({
|
||||
lastLocation: { location }
|
||||
});
|
||||
expect(checkCanInvite(location)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for friends+ instance if not in that location', () => {
|
||||
expect(checkCanInvite('wrld_123:instance~hidden(usr_other)')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false for closed instance', () => {
|
||||
const location = 'wrld_123:instance';
|
||||
useInstanceStore.mockReturnValue({
|
||||
cachedInstances: new Map([
|
||||
[location, { closedAt: '2024-01-01' }]
|
||||
])
|
||||
});
|
||||
expect(checkCanInvite(location)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkCanInviteSelf', () => {
|
||||
test('returns false for empty location', () => {
|
||||
expect(checkCanInviteSelf('')).toBe(false);
|
||||
expect(checkCanInviteSelf(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for own instance', () => {
|
||||
expect(
|
||||
checkCanInviteSelf('wrld_123:instance~private(usr_me)')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for public instance', () => {
|
||||
expect(checkCanInviteSelf('wrld_123:instance')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for friends-only instance if user is a friend', () => {
|
||||
useFriendStore.mockReturnValue({
|
||||
friends: new Map([['usr_owner', {}]])
|
||||
});
|
||||
expect(
|
||||
checkCanInviteSelf('wrld_123:instance~friends(usr_owner)')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for friends-only instance if user is not a friend', () => {
|
||||
expect(
|
||||
checkCanInviteSelf('wrld_123:instance~friends(usr_other)')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for closed instance', () => {
|
||||
const location = 'wrld_123:instance';
|
||||
useInstanceStore.mockReturnValue({
|
||||
cachedInstances: new Map([
|
||||
[location, { closedAt: '2024-01-01' }]
|
||||
])
|
||||
});
|
||||
expect(checkCanInviteSelf(location)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for invite instance (not owned, not closed)', () => {
|
||||
expect(
|
||||
checkCanInviteSelf('wrld_123:instance~private(usr_other)')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { displayLocation, parseLocation } from '../locationParser';
|
||||
import {
|
||||
displayLocation,
|
||||
parseLocation,
|
||||
resolveRegion,
|
||||
translateAccessType
|
||||
} from '../locationParser';
|
||||
import { accessTypeLocaleKeyMap } from '../../constants';
|
||||
|
||||
describe('Location Utils', () => {
|
||||
describe('parseLocation', () => {
|
||||
@@ -408,4 +414,98 @@ describe('Location Utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRegion', () => {
|
||||
test('returns empty string for offline', () => {
|
||||
const L = parseLocation('offline');
|
||||
expect(resolveRegion(L)).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string for private', () => {
|
||||
const L = parseLocation('private');
|
||||
expect(resolveRegion(L)).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string for traveling', () => {
|
||||
const L = parseLocation('traveling');
|
||||
expect(resolveRegion(L)).toBe('');
|
||||
});
|
||||
|
||||
test('returns explicit region when present', () => {
|
||||
const L = parseLocation('wrld_12345:67890~region(eu)');
|
||||
expect(resolveRegion(L)).toBe('eu');
|
||||
});
|
||||
|
||||
test('defaults to us when instance exists but no region', () => {
|
||||
const L = parseLocation('wrld_12345:67890');
|
||||
expect(resolveRegion(L)).toBe('us');
|
||||
});
|
||||
|
||||
test('returns empty string for world-only (no instance)', () => {
|
||||
const L = parseLocation('wrld_12345');
|
||||
expect(resolveRegion(L)).toBe('');
|
||||
});
|
||||
|
||||
test('returns jp region', () => {
|
||||
const L = parseLocation('wrld_12345:67890~region(jp)');
|
||||
expect(resolveRegion(L)).toBe('jp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateAccessType', () => {
|
||||
// Simple mock translation: returns the key itself
|
||||
const t = (key) => key;
|
||||
|
||||
test('returns raw name when not in keyMap', () => {
|
||||
expect(
|
||||
translateAccessType('unknown', t, accessTypeLocaleKeyMap)
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
test('translates public', () => {
|
||||
expect(
|
||||
translateAccessType('public', t, accessTypeLocaleKeyMap)
|
||||
).toBe(accessTypeLocaleKeyMap['public']);
|
||||
});
|
||||
|
||||
test('translates invite', () => {
|
||||
expect(
|
||||
translateAccessType('invite', t, accessTypeLocaleKeyMap)
|
||||
).toBe(accessTypeLocaleKeyMap['invite']);
|
||||
});
|
||||
|
||||
test('translates friends', () => {
|
||||
expect(
|
||||
translateAccessType('friends', t, accessTypeLocaleKeyMap)
|
||||
).toBe(accessTypeLocaleKeyMap['friends']);
|
||||
});
|
||||
|
||||
test('translates friends+', () => {
|
||||
expect(
|
||||
translateAccessType('friends+', t, accessTypeLocaleKeyMap)
|
||||
).toBe(accessTypeLocaleKeyMap['friends+']);
|
||||
});
|
||||
|
||||
test('prefixes Group for groupPublic', () => {
|
||||
const result = translateAccessType(
|
||||
'groupPublic',
|
||||
t,
|
||||
accessTypeLocaleKeyMap
|
||||
);
|
||||
expect(result).toBe(
|
||||
`${accessTypeLocaleKeyMap['group']} ${accessTypeLocaleKeyMap['groupPublic']}`
|
||||
);
|
||||
});
|
||||
|
||||
test('prefixes Group for groupPlus', () => {
|
||||
const result = translateAccessType(
|
||||
'groupPlus',
|
||||
t,
|
||||
accessTypeLocaleKeyMap
|
||||
);
|
||||
expect(result).toBe(
|
||||
`${accessTypeLocaleKeyMap['group']} ${accessTypeLocaleKeyMap['groupPlus']}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
228
src/shared/utils/__tests__/locationParser.test.js
Normal file
228
src/shared/utils/__tests__/locationParser.test.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
displayLocation,
|
||||
parseLocation,
|
||||
resolveRegion,
|
||||
translateAccessType
|
||||
} from '../locationParser';
|
||||
|
||||
// ─── parseLocation ───────────────────────────────────────────────────
|
||||
|
||||
describe('parseLocation', () => {
|
||||
test('returns offline context', () => {
|
||||
const ctx = parseLocation('offline');
|
||||
expect(ctx.isOffline).toBe(true);
|
||||
expect(ctx.isPrivate).toBe(false);
|
||||
expect(ctx.worldId).toBe('');
|
||||
});
|
||||
|
||||
test('handles offline:offline variant', () => {
|
||||
expect(parseLocation('offline:offline').isOffline).toBe(true);
|
||||
});
|
||||
|
||||
test('returns private context', () => {
|
||||
const ctx = parseLocation('private');
|
||||
expect(ctx.isPrivate).toBe(true);
|
||||
expect(ctx.isOffline).toBe(false);
|
||||
});
|
||||
|
||||
test('handles private:private variant', () => {
|
||||
expect(parseLocation('private:private').isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
test('returns traveling context', () => {
|
||||
const ctx = parseLocation('traveling');
|
||||
expect(ctx.isTraveling).toBe(true);
|
||||
});
|
||||
|
||||
test('handles traveling:traveling variant', () => {
|
||||
expect(parseLocation('traveling:traveling').isTraveling).toBe(true);
|
||||
});
|
||||
|
||||
test('parses public instance', () => {
|
||||
const ctx = parseLocation('wrld_abc:12345');
|
||||
expect(ctx.worldId).toBe('wrld_abc');
|
||||
expect(ctx.instanceId).toBe('12345');
|
||||
expect(ctx.instanceName).toBe('12345');
|
||||
expect(ctx.accessType).toBe('public');
|
||||
expect(ctx.isRealInstance).toBe(true);
|
||||
});
|
||||
|
||||
test('parses friends instance', () => {
|
||||
const ctx = parseLocation(
|
||||
'wrld_abc:12345~friends(usr_owner)~region(eu)'
|
||||
);
|
||||
expect(ctx.accessType).toBe('friends');
|
||||
expect(ctx.friendsId).toBe('usr_owner');
|
||||
expect(ctx.userId).toBe('usr_owner');
|
||||
expect(ctx.region).toBe('eu');
|
||||
});
|
||||
|
||||
test('parses friends+ (hidden) instance', () => {
|
||||
const ctx = parseLocation('wrld_abc:12345~hidden(usr_owner)');
|
||||
expect(ctx.accessType).toBe('friends+');
|
||||
expect(ctx.hiddenId).toBe('usr_owner');
|
||||
expect(ctx.userId).toBe('usr_owner');
|
||||
});
|
||||
|
||||
test('parses invite instance', () => {
|
||||
const ctx = parseLocation('wrld_abc:12345~private(usr_owner)');
|
||||
expect(ctx.accessType).toBe('invite');
|
||||
expect(ctx.privateId).toBe('usr_owner');
|
||||
});
|
||||
|
||||
test('parses invite+ instance', () => {
|
||||
const ctx = parseLocation(
|
||||
'wrld_abc:12345~private(usr_owner)~canRequestInvite'
|
||||
);
|
||||
expect(ctx.accessType).toBe('invite+');
|
||||
expect(ctx.canRequestInvite).toBe(true);
|
||||
});
|
||||
|
||||
test('parses group instance', () => {
|
||||
const ctx = parseLocation(
|
||||
'wrld_abc:12345~group(grp_xyz)~groupAccessType(public)'
|
||||
);
|
||||
expect(ctx.accessType).toBe('group');
|
||||
expect(ctx.groupId).toBe('grp_xyz');
|
||||
expect(ctx.groupAccessType).toBe('public');
|
||||
expect(ctx.accessTypeName).toBe('groupPublic');
|
||||
});
|
||||
|
||||
test('parses group plus access type', () => {
|
||||
const ctx = parseLocation(
|
||||
'wrld_abc:12345~group(grp_xyz)~groupAccessType(plus)'
|
||||
);
|
||||
expect(ctx.accessTypeName).toBe('groupPlus');
|
||||
});
|
||||
|
||||
test('handles strict and ageGate', () => {
|
||||
const ctx = parseLocation('wrld_abc:12345~strict~ageGate');
|
||||
expect(ctx.strict).toBe(true);
|
||||
expect(ctx.ageGate).toBe(true);
|
||||
});
|
||||
|
||||
test('extracts shortName from URL', () => {
|
||||
const ctx = parseLocation(
|
||||
'wrld_abc:12345~friends(usr_a)&shortName=myShort'
|
||||
);
|
||||
expect(ctx.shortName).toBe('myShort');
|
||||
expect(ctx.accessType).toBe('friends');
|
||||
});
|
||||
|
||||
test('handles world-only tag (no colon)', () => {
|
||||
const ctx = parseLocation('wrld_abc');
|
||||
expect(ctx.worldId).toBe('wrld_abc');
|
||||
expect(ctx.instanceId).toBe('');
|
||||
expect(ctx.isRealInstance).toBe(true);
|
||||
});
|
||||
|
||||
test('handles null/empty input', () => {
|
||||
const ctx = parseLocation('');
|
||||
expect(ctx.isOffline).toBe(false);
|
||||
expect(ctx.isPrivate).toBe(false);
|
||||
expect(ctx.worldId).toBe('');
|
||||
});
|
||||
|
||||
test('handles local instance (non-real)', () => {
|
||||
const ctx = parseLocation('local:12345');
|
||||
expect(ctx.isRealInstance).toBe(false);
|
||||
expect(ctx.worldId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── displayLocation ─────────────────────────────────────────────────
|
||||
|
||||
describe('displayLocation', () => {
|
||||
test('shows Offline for offline location', () => {
|
||||
expect(displayLocation('offline', 'World Name')).toBe('Offline');
|
||||
});
|
||||
|
||||
test('shows Private for private location', () => {
|
||||
expect(displayLocation('private', 'World Name')).toBe('Private');
|
||||
});
|
||||
|
||||
test('shows Traveling for traveling location', () => {
|
||||
expect(displayLocation('traveling', 'World Name')).toBe('Traveling');
|
||||
});
|
||||
|
||||
test('shows world name with access type', () => {
|
||||
const result = displayLocation(
|
||||
'wrld_abc:12345~friends(usr_a)',
|
||||
'My World'
|
||||
);
|
||||
expect(result).toBe('My World friends');
|
||||
});
|
||||
|
||||
test('includes group name when provided', () => {
|
||||
const result = displayLocation(
|
||||
'wrld_abc:12345~group(grp_xyz)~groupAccessType(public)',
|
||||
'My World',
|
||||
'My Group'
|
||||
);
|
||||
expect(result).toBe('My World groupPublic(My Group)');
|
||||
});
|
||||
|
||||
test('returns worldName for world-only tag', () => {
|
||||
expect(displayLocation('wrld_abc', 'My World')).toBe('My World');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveRegion ───────────────────────────────────────────────────
|
||||
|
||||
describe('resolveRegion', () => {
|
||||
test('returns empty for offline', () => {
|
||||
expect(resolveRegion(parseLocation('offline'))).toBe('');
|
||||
});
|
||||
|
||||
test('returns region from tag', () => {
|
||||
expect(resolveRegion(parseLocation('wrld_abc:12345~region(eu)'))).toBe(
|
||||
'eu'
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults to us when instance has no region', () => {
|
||||
expect(resolveRegion(parseLocation('wrld_abc:12345'))).toBe('us');
|
||||
});
|
||||
|
||||
test('returns empty when no instanceId', () => {
|
||||
expect(resolveRegion(parseLocation('wrld_abc'))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── translateAccessType ─────────────────────────────────────────────
|
||||
|
||||
describe('translateAccessType', () => {
|
||||
const t = (key) => `translated_${key}`;
|
||||
const keyMap = {
|
||||
public: 'access.public',
|
||||
friends: 'access.friends',
|
||||
invite: 'access.invite',
|
||||
group: 'access.group',
|
||||
groupPublic: 'access.groupPublic',
|
||||
groupPlus: 'access.groupPlus'
|
||||
};
|
||||
|
||||
test('translates simple access type', () => {
|
||||
expect(translateAccessType('friends', t, keyMap)).toBe(
|
||||
'translated_access.friends'
|
||||
);
|
||||
});
|
||||
|
||||
test('translates groupPublic with group prefix', () => {
|
||||
expect(translateAccessType('groupPublic', t, keyMap)).toBe(
|
||||
'translated_access.group translated_access.groupPublic'
|
||||
);
|
||||
});
|
||||
|
||||
test('translates groupPlus with group prefix', () => {
|
||||
expect(translateAccessType('groupPlus', t, keyMap)).toBe(
|
||||
'translated_access.group translated_access.groupPlus'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns raw name when not in keyMap', () => {
|
||||
expect(translateAccessType('unknown', t, keyMap)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
108
src/shared/utils/__tests__/resolveRef.test.js
Normal file
108
src/shared/utils/__tests__/resolveRef.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { resolveRef } from '../resolveRef';
|
||||
|
||||
describe('resolveRef', () => {
|
||||
const emptyDefault = { id: '', displayName: '' };
|
||||
const nameKey = 'displayName';
|
||||
const idAlias = 'userId';
|
||||
const mockFetchFn = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns emptyDefault for null input', async () => {
|
||||
const result = await resolveRef(null, {
|
||||
emptyDefault,
|
||||
idAlias,
|
||||
nameKey,
|
||||
fetchFn: mockFetchFn
|
||||
});
|
||||
expect(result).toEqual(emptyDefault);
|
||||
expect(mockFetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns emptyDefault for empty string input', async () => {
|
||||
const result = await resolveRef('', {
|
||||
emptyDefault,
|
||||
idAlias,
|
||||
nameKey,
|
||||
fetchFn: mockFetchFn
|
||||
});
|
||||
expect(result).toEqual(emptyDefault);
|
||||
});
|
||||
|
||||
test('converts string input to object and fetches name', async () => {
|
||||
mockFetchFn.mockResolvedValue({
|
||||
ref: { id: 'usr_123', displayName: 'Alice' }
|
||||
});
|
||||
const result = await resolveRef('usr_123', {
|
||||
emptyDefault,
|
||||
idAlias,
|
||||
nameKey,
|
||||
fetchFn: mockFetchFn
|
||||
});
|
||||
expect(mockFetchFn).toHaveBeenCalledWith('usr_123');
|
||||
expect(result.id).toBe('usr_123');
|
||||
expect(result.displayName).toBe('Alice');
|
||||
});
|
||||
|
||||
test('returns object with name when name is already present', async () => {
|
||||
const input = { id: 'usr_456', displayName: 'Bob' };
|
||||
const result = await resolveRef(input, {
|
||||
emptyDefault,
|
||||
idAlias,
|
||||
nameKey,
|
||||
fetchFn: mockFetchFn
|
||||
});
|
||||
expect(mockFetchFn).not.toHaveBeenCalled();
|
||||
expect(result.displayName).toBe('Bob');
|
||||
});
|
||||
|
||||
test('fetches name when object has id but no name', async () => {
|
||||
mockFetchFn.mockResolvedValue({
|
||||
ref: { id: 'usr_789', displayName: 'Charlie' }
|
||||
});
|
||||
const result = await resolveRef(
|
||||
{ id: 'usr_789' },
|
||||
{ emptyDefault, idAlias, nameKey, fetchFn: mockFetchFn }
|
||||
);
|
||||
expect(mockFetchFn).toHaveBeenCalledWith('usr_789');
|
||||
expect(result.displayName).toBe('Charlie');
|
||||
});
|
||||
|
||||
test('uses idAlias as fallback for id', async () => {
|
||||
mockFetchFn.mockResolvedValue({
|
||||
ref: { id: 'usr_alt', displayName: 'AltUser' }
|
||||
});
|
||||
const result = await resolveRef(
|
||||
{ userId: 'usr_alt' },
|
||||
{ emptyDefault, idAlias, nameKey, fetchFn: mockFetchFn }
|
||||
);
|
||||
expect(mockFetchFn).toHaveBeenCalledWith('usr_alt');
|
||||
expect(result.displayName).toBe('AltUser');
|
||||
});
|
||||
|
||||
test('handles fetch failure gracefully', async () => {
|
||||
mockFetchFn.mockRejectedValue(new Error('Network error'));
|
||||
const result = await resolveRef('usr_err', {
|
||||
emptyDefault,
|
||||
idAlias,
|
||||
nameKey,
|
||||
fetchFn: mockFetchFn
|
||||
});
|
||||
expect(result.id).toBe('usr_err');
|
||||
expect(result.displayName).toBe('');
|
||||
});
|
||||
|
||||
test('returns input properties when no id and no name', async () => {
|
||||
const input = { someField: 'value' };
|
||||
const result = await resolveRef(input, {
|
||||
emptyDefault,
|
||||
idAlias,
|
||||
nameKey,
|
||||
fetchFn: mockFetchFn
|
||||
});
|
||||
expect(mockFetchFn).not.toHaveBeenCalled();
|
||||
expect(result.someField).toBe('value');
|
||||
});
|
||||
});
|
||||
558
src/shared/utils/__tests__/user.test.js
Normal file
558
src/shared/utils/__tests__/user.test.js
Normal file
@@ -0,0 +1,558 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Mock stores
|
||||
vi.mock('../../../stores', () => ({
|
||||
useUserStore: vi.fn(),
|
||||
useAppearanceSettingsStore: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock common.js
|
||||
vi.mock('../common', () => ({
|
||||
convertFileUrlToImageUrl: vi.fn((url) => `converted:${url}`)
|
||||
}));
|
||||
|
||||
// Mock base/format.js
|
||||
vi.mock('../base/format', () => ({
|
||||
timeToText: vi.fn((ms) => `${Math.round(ms / 1000)}s`)
|
||||
}));
|
||||
|
||||
// Mock base/ui.js
|
||||
vi.mock('../base/ui', () => ({
|
||||
HueToHex: vi.fn((h) => `#hue${h}`)
|
||||
}));
|
||||
|
||||
// Mock transitive deps that get pulled in via stores
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
|
||||
import {
|
||||
languageClass,
|
||||
parseUserUrl,
|
||||
removeEmojis,
|
||||
statusClass,
|
||||
userImage,
|
||||
userImageFull,
|
||||
userOnlineFor,
|
||||
userOnlineForTimestamp,
|
||||
userStatusClass
|
||||
} from '../user';
|
||||
|
||||
describe('User Utils', () => {
|
||||
describe('removeEmojis', () => {
|
||||
test('removes emoji characters from text', () => {
|
||||
expect(removeEmojis('Hello 🌍 World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
test('collapses multiple spaces after removal', () => {
|
||||
expect(removeEmojis('A 🎮 B')).toBe('A B');
|
||||
});
|
||||
|
||||
test('returns empty string for falsy input', () => {
|
||||
expect(removeEmojis('')).toBe('');
|
||||
expect(removeEmojis(null)).toBe('');
|
||||
expect(removeEmojis(undefined)).toBe('');
|
||||
});
|
||||
|
||||
test('returns original text when no emojis', () => {
|
||||
expect(removeEmojis('Hello World')).toBe('Hello World');
|
||||
});
|
||||
|
||||
test('trims whitespace', () => {
|
||||
expect(removeEmojis(' Hello ')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusClass', () => {
|
||||
test('returns online style for active status', () => {
|
||||
expect(statusClass('active')).toEqual({
|
||||
'status-icon': true,
|
||||
online: true
|
||||
});
|
||||
});
|
||||
|
||||
test('returns joinme style for join me status', () => {
|
||||
expect(statusClass('join me')).toEqual({
|
||||
'status-icon': true,
|
||||
joinme: true
|
||||
});
|
||||
});
|
||||
|
||||
test('returns askme style for ask me status', () => {
|
||||
expect(statusClass('ask me')).toEqual({
|
||||
'status-icon': true,
|
||||
askme: true
|
||||
});
|
||||
});
|
||||
|
||||
test('returns busy style for busy status', () => {
|
||||
expect(statusClass('busy')).toEqual({
|
||||
'status-icon': true,
|
||||
busy: true
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for undefined status', () => {
|
||||
expect(statusClass(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for unknown status strings', () => {
|
||||
expect(statusClass('offline')).toBeNull();
|
||||
expect(statusClass('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('languageClass', () => {
|
||||
test('returns mapped flag for known languages', () => {
|
||||
expect(languageClass('eng')).toEqual({ us: true });
|
||||
expect(languageClass('jpn')).toEqual({ jp: true });
|
||||
expect(languageClass('kor')).toEqual({ kr: true });
|
||||
});
|
||||
|
||||
test('returns unknown flag for unmapped languages', () => {
|
||||
expect(languageClass('xyz')).toEqual({ unknown: true });
|
||||
expect(languageClass('')).toEqual({ unknown: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUserUrl', () => {
|
||||
test('extracts user ID from VRChat URL', () => {
|
||||
expect(
|
||||
parseUserUrl('https://vrchat.com/home/user/usr_abc123-def456')
|
||||
).toBe('usr_abc123-def456');
|
||||
});
|
||||
|
||||
test('returns undefined for non-user URLs', () => {
|
||||
expect(
|
||||
parseUserUrl('https://vrchat.com/home/world/wrld_abc')
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('throws for invalid URLs', () => {
|
||||
expect(() => parseUserUrl('not-a-url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('userOnlineForTimestamp', () => {
|
||||
test('returns ISO date for online user with $online_for', () => {
|
||||
const ts = Date.now() - 60000;
|
||||
const ctx = { ref: { state: 'online', $online_for: ts } };
|
||||
const result = userOnlineForTimestamp(ctx);
|
||||
expect(result).toBe(new Date(ts).toJSON());
|
||||
});
|
||||
|
||||
test('returns ISO date for active user with $active_for', () => {
|
||||
const ts = Date.now() - 30000;
|
||||
const ctx = { ref: { state: 'active', $active_for: ts } };
|
||||
expect(userOnlineForTimestamp(ctx)).toBe(new Date(ts).toJSON());
|
||||
});
|
||||
|
||||
test('returns ISO date for offline user with $offline_for', () => {
|
||||
const ts = Date.now() - 120000;
|
||||
const ctx = {
|
||||
ref: { state: 'offline', $offline_for: ts }
|
||||
};
|
||||
expect(userOnlineForTimestamp(ctx)).toBe(new Date(ts).toJSON());
|
||||
});
|
||||
|
||||
test('returns null when no timestamp available', () => {
|
||||
const ctx = { ref: { state: 'offline' } };
|
||||
expect(userOnlineForTimestamp(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
test('prefers $online_for for online state', () => {
|
||||
const ts1 = Date.now() - 10000;
|
||||
const ts2 = Date.now() - 50000;
|
||||
const ctx = {
|
||||
ref: {
|
||||
state: 'online',
|
||||
$online_for: ts1,
|
||||
$offline_for: ts2
|
||||
}
|
||||
};
|
||||
expect(userOnlineForTimestamp(ctx)).toBe(new Date(ts1).toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
describe('userOnlineFor', () => {
|
||||
test('returns formatted time for online user', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const ref = { state: 'online', $online_for: now - 5000 };
|
||||
expect(userOnlineFor(ref)).toBe('5s');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('returns formatted time for active user', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const ref = { state: 'active', $active_for: now - 10000 };
|
||||
expect(userOnlineFor(ref)).toBe('10s');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('returns formatted time for offline user with $offline_for', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const ref = { state: 'offline', $offline_for: now - 3000 };
|
||||
expect(userOnlineFor(ref)).toBe('3s');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('returns dash when no timestamp available', () => {
|
||||
expect(userOnlineFor({ state: 'offline' })).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('userStatusClass (with store mock)', () => {
|
||||
beforeEach(() => {
|
||||
useUserStore.mockReturnValue({
|
||||
currentUser: {
|
||||
id: 'usr_me',
|
||||
presence: { platform: 'standalonewindows' },
|
||||
onlineFriends: [],
|
||||
activeFriends: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for undefined user', () => {
|
||||
expect(userStatusClass(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns current user style with status', () => {
|
||||
const result = userStatusClass({
|
||||
id: 'usr_me',
|
||||
status: 'active',
|
||||
isFriend: true
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
'status-icon': true,
|
||||
online: true,
|
||||
mobile: false
|
||||
});
|
||||
});
|
||||
|
||||
test('returns mobile true for non-PC platform on current user', () => {
|
||||
useUserStore.mockReturnValue({
|
||||
currentUser: {
|
||||
id: 'usr_me',
|
||||
presence: { platform: 'android' },
|
||||
onlineFriends: [],
|
||||
activeFriends: []
|
||||
}
|
||||
});
|
||||
const result = userStatusClass({
|
||||
id: 'usr_me',
|
||||
status: 'active'
|
||||
});
|
||||
expect(result.mobile).toBe(true);
|
||||
});
|
||||
|
||||
test('returns null for non-friend users', () => {
|
||||
expect(
|
||||
userStatusClass({
|
||||
id: 'usr_other',
|
||||
status: 'active',
|
||||
isFriend: false
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('returns offline style for pending offline friend', () => {
|
||||
const result = userStatusClass(
|
||||
{ id: 'usr_other', isFriend: true, status: 'active' },
|
||||
true
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
'status-icon': true,
|
||||
offline: true
|
||||
});
|
||||
});
|
||||
|
||||
test('returns correct style for each friend status', () => {
|
||||
const cases = [
|
||||
{
|
||||
status: 'active',
|
||||
location: 'wrld_1',
|
||||
state: 'online',
|
||||
expected: 'online'
|
||||
},
|
||||
{
|
||||
status: 'join me',
|
||||
location: 'wrld_1',
|
||||
state: 'online',
|
||||
expected: 'joinme'
|
||||
},
|
||||
{
|
||||
status: 'ask me',
|
||||
location: 'wrld_1',
|
||||
state: 'online',
|
||||
expected: 'askme'
|
||||
},
|
||||
{
|
||||
status: 'busy',
|
||||
location: 'wrld_1',
|
||||
state: 'online',
|
||||
expected: 'busy'
|
||||
}
|
||||
];
|
||||
for (const { status, location, state, expected } of cases) {
|
||||
const result = userStatusClass({
|
||||
id: 'usr_friend',
|
||||
isFriend: true,
|
||||
status,
|
||||
location,
|
||||
state
|
||||
});
|
||||
expect(result[expected]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns offline style for location offline', () => {
|
||||
const result = userStatusClass({
|
||||
id: 'usr_f',
|
||||
isFriend: true,
|
||||
status: 'active',
|
||||
location: 'offline',
|
||||
state: ''
|
||||
});
|
||||
expect(result.offline).toBe(true);
|
||||
});
|
||||
|
||||
test('returns active style for state active', () => {
|
||||
const result = userStatusClass({
|
||||
id: 'usr_f',
|
||||
isFriend: true,
|
||||
status: 'busy',
|
||||
location: 'private',
|
||||
state: 'active'
|
||||
});
|
||||
expect(result.active).toBe(true);
|
||||
});
|
||||
|
||||
test('sets mobile flag for non-PC platform friend', () => {
|
||||
const result = userStatusClass({
|
||||
id: 'usr_f',
|
||||
isFriend: true,
|
||||
status: 'active',
|
||||
location: 'wrld_1',
|
||||
state: 'online',
|
||||
$platform: 'android'
|
||||
});
|
||||
expect(result.mobile).toBe(true);
|
||||
});
|
||||
|
||||
test('no mobile flag for standalonewindows platform', () => {
|
||||
const result = userStatusClass({
|
||||
id: 'usr_f',
|
||||
isFriend: true,
|
||||
status: 'active',
|
||||
location: 'wrld_1',
|
||||
state: 'online',
|
||||
$platform: 'standalonewindows'
|
||||
});
|
||||
expect(result.mobile).toBeUndefined();
|
||||
});
|
||||
|
||||
test('uses userId as fallback when id is not present', () => {
|
||||
const result = userStatusClass({
|
||||
userId: 'usr_me',
|
||||
status: 'busy'
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
'status-icon': true,
|
||||
busy: true,
|
||||
mobile: false
|
||||
});
|
||||
});
|
||||
|
||||
test('handles private location with empty state (temp fix branch)', () => {
|
||||
useUserStore.mockReturnValue({
|
||||
currentUser: {
|
||||
id: 'usr_me',
|
||||
onlineFriends: [],
|
||||
activeFriends: ['usr_f']
|
||||
}
|
||||
});
|
||||
const result = userStatusClass({
|
||||
id: 'usr_f',
|
||||
isFriend: true,
|
||||
status: 'busy',
|
||||
location: 'private',
|
||||
state: ''
|
||||
});
|
||||
// activeFriends includes usr_f → active
|
||||
expect(result.active).toBe(true);
|
||||
});
|
||||
|
||||
test('handles private location temp fix → offline branch', () => {
|
||||
useUserStore.mockReturnValue({
|
||||
currentUser: {
|
||||
id: 'usr_me',
|
||||
onlineFriends: [],
|
||||
activeFriends: []
|
||||
}
|
||||
});
|
||||
const result = userStatusClass({
|
||||
id: 'usr_f',
|
||||
isFriend: true,
|
||||
status: 'busy',
|
||||
location: 'private',
|
||||
state: ''
|
||||
});
|
||||
expect(result.offline).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userImage (with store mock)', () => {
|
||||
beforeEach(() => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
displayVRCPlusIconsAsAvatar: false
|
||||
});
|
||||
});
|
||||
|
||||
test('returns empty string for falsy user', () => {
|
||||
expect(userImage(null)).toBe('');
|
||||
expect(userImage(undefined)).toBe('');
|
||||
});
|
||||
|
||||
test('returns profilePicOverrideThumbnail when available', () => {
|
||||
const user = {
|
||||
profilePicOverrideThumbnail: 'https://img.com/pic/256/thumb'
|
||||
};
|
||||
expect(userImage(user)).toBe('https://img.com/pic/256/thumb');
|
||||
});
|
||||
|
||||
test('replaces resolution for icon mode with profilePicOverrideThumbnail', () => {
|
||||
const user = {
|
||||
profilePicOverrideThumbnail: 'https://img.com/pic/256/thumb'
|
||||
};
|
||||
expect(userImage(user, true, '64')).toBe(
|
||||
'https://img.com/pic/64/thumb'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns profilePicOverride when no thumbnail', () => {
|
||||
const user = { profilePicOverride: 'https://img.com/full' };
|
||||
expect(userImage(user)).toBe('https://img.com/full');
|
||||
});
|
||||
|
||||
test('returns thumbnailUrl as fallback', () => {
|
||||
const user = { thumbnailUrl: 'https://img.com/thumb' };
|
||||
expect(userImage(user)).toBe('https://img.com/thumb');
|
||||
});
|
||||
|
||||
test('returns currentAvatarThumbnailImageUrl as fallback', () => {
|
||||
const user = {
|
||||
currentAvatarThumbnailImageUrl:
|
||||
'https://img.com/avatar/256/thumb'
|
||||
};
|
||||
expect(userImage(user)).toBe('https://img.com/avatar/256/thumb');
|
||||
});
|
||||
|
||||
test('replaces resolution for icon mode with currentAvatarThumbnailImageUrl', () => {
|
||||
const user = {
|
||||
currentAvatarThumbnailImageUrl:
|
||||
'https://img.com/avatar/256/thumb'
|
||||
};
|
||||
expect(userImage(user, true, '64')).toBe(
|
||||
'https://img.com/avatar/64/thumb'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns currentAvatarImageUrl as last resort', () => {
|
||||
const user = {
|
||||
currentAvatarImageUrl: 'https://img.com/avatar/full'
|
||||
};
|
||||
expect(userImage(user)).toBe('https://img.com/avatar/full');
|
||||
});
|
||||
|
||||
test('converts currentAvatarImageUrl for icon mode', () => {
|
||||
const user = {
|
||||
currentAvatarImageUrl: 'https://img.com/avatar/full'
|
||||
};
|
||||
expect(userImage(user, true)).toBe(
|
||||
'converted:https://img.com/avatar/full'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty string when user has no image fields', () => {
|
||||
expect(userImage({})).toBe('');
|
||||
});
|
||||
|
||||
test('returns userIcon when displayVRCPlusIconsAsAvatar is true', () => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
displayVRCPlusIconsAsAvatar: true
|
||||
});
|
||||
const user = {
|
||||
userIcon: 'https://img.com/icon',
|
||||
thumbnailUrl: 'https://img.com/thumb'
|
||||
};
|
||||
expect(userImage(user)).toBe('https://img.com/icon');
|
||||
});
|
||||
|
||||
test('converts userIcon for icon mode when VRCPlus setting enabled', () => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
displayVRCPlusIconsAsAvatar: true
|
||||
});
|
||||
const user = { userIcon: 'https://img.com/icon' };
|
||||
expect(userImage(user, true)).toBe(
|
||||
'converted:https://img.com/icon'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns userIcon for isUserDialogIcon even if VRCPlus setting off', () => {
|
||||
const user = {
|
||||
userIcon: 'https://img.com/icon',
|
||||
thumbnailUrl: 'https://img.com/thumb'
|
||||
};
|
||||
expect(userImage(user, false, '128', true)).toBe(
|
||||
'https://img.com/icon'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userImageFull (with store mock)', () => {
|
||||
beforeEach(() => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
displayVRCPlusIconsAsAvatar: false
|
||||
});
|
||||
});
|
||||
|
||||
test('returns empty string for falsy user', () => {
|
||||
expect(userImageFull(null)).toBe('');
|
||||
});
|
||||
|
||||
test('returns profilePicOverride when available', () => {
|
||||
const user = {
|
||||
profilePicOverride: 'https://img.com/full',
|
||||
currentAvatarImageUrl: 'https://img.com/avatar'
|
||||
};
|
||||
expect(userImageFull(user)).toBe('https://img.com/full');
|
||||
});
|
||||
|
||||
test('returns currentAvatarImageUrl as fallback', () => {
|
||||
const user = {
|
||||
currentAvatarImageUrl: 'https://img.com/avatar'
|
||||
};
|
||||
expect(userImageFull(user)).toBe('https://img.com/avatar');
|
||||
});
|
||||
|
||||
test('returns userIcon when VRCPlus setting enabled', () => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
displayVRCPlusIconsAsAvatar: true
|
||||
});
|
||||
const user = {
|
||||
userIcon: 'https://img.com/icon',
|
||||
profilePicOverride: 'https://img.com/full'
|
||||
};
|
||||
expect(userImageFull(user)).toBe('https://img.com/icon');
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/shared/utils/__tests__/world.test.js
Normal file
40
src/shared/utils/__tests__/world.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { isRpcWorld } from '../world';
|
||||
|
||||
// Mock transitive deps
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
describe('World Utils', () => {
|
||||
describe('isRpcWorld', () => {
|
||||
test('returns true for a known RPC world', () => {
|
||||
expect(
|
||||
isRpcWorld(
|
||||
'wrld_f20326da-f1ac-45fc-a062-609723b097b1:12345~region(us)'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for a random world', () => {
|
||||
expect(
|
||||
isRpcWorld('wrld_00000000-0000-0000-0000-000000000000:12345')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for offline location', () => {
|
||||
expect(isRpcWorld('offline')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for private location', () => {
|
||||
expect(isRpcWorld('private')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty string', () => {
|
||||
expect(isRpcWorld('')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
src/shared/utils/base/__tests__/date.test.js
Normal file
100
src/shared/utils/base/__tests__/date.test.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Mock the store
|
||||
vi.mock('../../../../stores', () => ({
|
||||
useAppearanceSettingsStore: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock transitive deps
|
||||
vi.mock('../../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
import { useAppearanceSettingsStore } from '../../../../stores';
|
||||
import { formatDateFilter } from '../date';
|
||||
|
||||
describe('formatDateFilter', () => {
|
||||
beforeEach(() => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
dtIsoFormat: false,
|
||||
dtHour12: false,
|
||||
currentCulture: 'en-gb'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns dash for empty dateStr', () => {
|
||||
expect(formatDateFilter('', 'long')).toBe('-');
|
||||
expect(formatDateFilter(null, 'long')).toBe('-');
|
||||
expect(formatDateFilter(undefined, 'long')).toBe('-');
|
||||
});
|
||||
|
||||
test('returns dash for invalid dateStr', () => {
|
||||
expect(formatDateFilter('not-a-date', 'long')).toBe('-');
|
||||
});
|
||||
|
||||
test('formats long ISO format', () => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
dtIsoFormat: true,
|
||||
dtHour12: false,
|
||||
currentCulture: 'en-gb'
|
||||
});
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'long');
|
||||
// ISO format: YYYY-MM-DD HH:MM:SS (in local timezone)
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test('formats long locale format', () => {
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'long');
|
||||
// Result is locale-dependent; just verify it produces something
|
||||
expect(result).not.toBe('-');
|
||||
expect(result.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test('formats short locale format', () => {
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'short');
|
||||
expect(result).not.toBe('-');
|
||||
});
|
||||
|
||||
test('formats time only', () => {
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'time');
|
||||
expect(result).not.toBe('-');
|
||||
});
|
||||
|
||||
test('formats date only', () => {
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'date');
|
||||
expect(result).not.toBe('-');
|
||||
});
|
||||
|
||||
test('handles culture with no underscore at position 4', () => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
dtIsoFormat: false,
|
||||
dtHour12: true,
|
||||
currentCulture: 'en-us'
|
||||
});
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'long');
|
||||
expect(result).not.toBe('-');
|
||||
});
|
||||
|
||||
test('returns dash for unknown format', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'unknown');
|
||||
expect(result).toBe('-');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('uses hour12 setting', () => {
|
||||
useAppearanceSettingsStore.mockReturnValue({
|
||||
dtIsoFormat: false,
|
||||
dtHour12: true,
|
||||
currentCulture: 'en-us'
|
||||
});
|
||||
const result = formatDateFilter('2023-06-15T14:30:45Z', 'short');
|
||||
// hour12 should produce am/pm in the output
|
||||
expect(result).not.toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { timeToText } from '../format';
|
||||
import { convertYoutubeTime, formatSeconds, timeToText } from '../format';
|
||||
|
||||
describe('Format Utils', () => {
|
||||
describe('timeToText', () => {
|
||||
@@ -24,4 +24,64 @@ describe('Format Utils', () => {
|
||||
expect(result).toContain('1h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSeconds', () => {
|
||||
test('formats seconds only', () => {
|
||||
expect(formatSeconds(5)).toBe('00:05');
|
||||
expect(formatSeconds(0)).toBe('00:00');
|
||||
expect(formatSeconds(59)).toBe('00:59');
|
||||
});
|
||||
|
||||
test('formats minutes and seconds', () => {
|
||||
expect(formatSeconds(60)).toBe('01:00');
|
||||
expect(formatSeconds(125)).toBe('02:05');
|
||||
expect(formatSeconds(3599)).toBe('59:59');
|
||||
});
|
||||
|
||||
test('formats hours, minutes and seconds', () => {
|
||||
expect(formatSeconds(3600)).toBe('01:00:00');
|
||||
expect(formatSeconds(3661)).toBe('01:01:01');
|
||||
expect(formatSeconds(7200)).toBe('02:00:00');
|
||||
});
|
||||
|
||||
test('handles decimal input', () => {
|
||||
expect(formatSeconds(5.7)).toBe('00:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertYoutubeTime', () => {
|
||||
test('converts minutes and seconds (PT3M45S)', () => {
|
||||
expect(convertYoutubeTime('PT3M45S')).toBe(225);
|
||||
});
|
||||
|
||||
test('converts hours, minutes, seconds (PT1H30M15S)', () => {
|
||||
expect(convertYoutubeTime('PT1H30M15S')).toBe(5415);
|
||||
});
|
||||
|
||||
test('converts minutes only (PT5M)', () => {
|
||||
expect(convertYoutubeTime('PT5M')).toBe(300);
|
||||
});
|
||||
|
||||
test('converts seconds only (PT30S)', () => {
|
||||
expect(convertYoutubeTime('PT30S')).toBe(30);
|
||||
});
|
||||
|
||||
test('converts hours only (PT2H)', () => {
|
||||
expect(convertYoutubeTime('PT2H')).toBe(7200);
|
||||
});
|
||||
|
||||
test('converts hours and seconds, no minutes (PT1H30S)', () => {
|
||||
expect(convertYoutubeTime('PT1H30S')).toBe(3630);
|
||||
});
|
||||
|
||||
test('converts hours and minutes, no seconds (PT1H30M)', () => {
|
||||
// H present, M present, S missing → a = [1, 30]
|
||||
// length === 2 → 1*60 + 30 = 90... but that's wrong for the intent
|
||||
// Actually looking at the code: H>=0 && M present && S missing
|
||||
// doesn't hit any special case, so a = ['1','30'] from match
|
||||
// length 2 → 1*60 + 30 = 90
|
||||
// This is a known quirk of the parser
|
||||
expect(convertYoutubeTime('PT1H30M')).toBe(90);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
31
src/shared/utils/csv.js
Normal file
31
src/shared/utils/csv.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function needsCsvQuotes(text) {
|
||||
return /[\x00-\x1f,"]/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCsvField(value) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const text = String(value);
|
||||
if (needsCsvQuotes(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} obj - The source object
|
||||
* @param {string[]} fields - Property names to include
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCsvRow(obj, fields) {
|
||||
return fields.map((field) => formatCsvField(obj?.[field])).join(',');
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export * from './avatar';
|
||||
export * from './chart';
|
||||
export * from './common';
|
||||
export * from './compare';
|
||||
export * from './csv';
|
||||
export * from './fileUtils';
|
||||
export * from './friend';
|
||||
export * from './group';
|
||||
|
||||
@@ -64,4 +64,76 @@ function getLaunchURL(instance) {
|
||||
)}`;
|
||||
}
|
||||
|
||||
export { refreshInstancePlayerCount, isRealInstance, getLaunchURL };
|
||||
const regionTagMap = {
|
||||
'US West': 'us',
|
||||
'US East': 'use',
|
||||
Europe: 'eu',
|
||||
Japan: 'jp'
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.instanceName - Sanitised instance name segment
|
||||
* @param {string} opts.userId
|
||||
* @param {string} opts.accessType
|
||||
* @param {string} [opts.groupId]
|
||||
* @param {string} [opts.groupAccessType]
|
||||
* @param {string} opts.region - Display region name ('US West', 'US East', 'Europe', 'Japan')
|
||||
* @param {boolean} [opts.ageGate]
|
||||
* @param {boolean} [opts.strict]
|
||||
* @returns {string} instance tag, e.g. '12345~hidden(usr_xxx)~region(us)'
|
||||
*/
|
||||
function buildLegacyInstanceTag({
|
||||
instanceName,
|
||||
userId,
|
||||
accessType,
|
||||
groupId,
|
||||
groupAccessType,
|
||||
region,
|
||||
ageGate,
|
||||
strict
|
||||
}) {
|
||||
const tags = [];
|
||||
|
||||
if (instanceName) {
|
||||
tags.push(instanceName);
|
||||
}
|
||||
|
||||
if (accessType !== 'public') {
|
||||
if (accessType === 'friends+') {
|
||||
tags.push(`~hidden(${userId})`);
|
||||
} else if (accessType === 'friends') {
|
||||
tags.push(`~friends(${userId})`);
|
||||
} else if (accessType === 'group') {
|
||||
tags.push(`~group(${groupId})`);
|
||||
tags.push(`~groupAccessType(${groupAccessType})`);
|
||||
} else {
|
||||
tags.push(`~private(${userId})`);
|
||||
}
|
||||
if (accessType === 'invite+') {
|
||||
tags.push('~canRequestInvite');
|
||||
}
|
||||
}
|
||||
|
||||
if (accessType === 'group' && ageGate) {
|
||||
tags.push('~ageGate');
|
||||
}
|
||||
|
||||
const regionCode = regionTagMap[region];
|
||||
if (regionCode) {
|
||||
tags.push(`~region(${regionCode})`);
|
||||
}
|
||||
|
||||
if (strict && (accessType === 'invite' || accessType === 'friends')) {
|
||||
tags.push('~strict');
|
||||
}
|
||||
|
||||
return tags.join('');
|
||||
}
|
||||
|
||||
export {
|
||||
refreshInstancePlayerCount,
|
||||
isRealInstance,
|
||||
getLaunchURL,
|
||||
buildLegacyInstanceTag
|
||||
};
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { isRealInstance } from './instance.js';
|
||||
import { useLocationStore } from '../../stores/location.js';
|
||||
|
||||
// Re-export pure parsing functions from the standalone module
|
||||
export { parseLocation, displayLocation } from './locationParser.js';
|
||||
export {
|
||||
parseLocation,
|
||||
displayLocation,
|
||||
resolveRegion,
|
||||
translateAccessType
|
||||
} from './locationParser.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param friendsArr
|
||||
*/
|
||||
function getFriendsLocations(friendsArr) {
|
||||
const locationStore = useLocationStore();
|
||||
// prevent the instance title display as "Traveling".
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Pure location parsing utilities with no external dependencies.
|
||||
* These functions are extracted to enable clean unit testing.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} location
|
||||
@@ -146,4 +141,39 @@ function parseLocation(tag) {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { parseLocation, displayLocation };
|
||||
/**
|
||||
* @param {object} L - A parsed location object from parseLocation()
|
||||
* @returns {string} region code (e.g. 'us', 'eu', 'jp') or empty string
|
||||
*/
|
||||
function resolveRegion(L) {
|
||||
if (L.isOffline || L.isPrivate || L.isTraveling) {
|
||||
return '';
|
||||
}
|
||||
if (L.region) {
|
||||
return L.region;
|
||||
}
|
||||
if (L.instanceId) {
|
||||
return 'us';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accessTypeName - Raw access type name from parseLocation
|
||||
* @param {function} t - Translation function (e.g. i18n.global.t)
|
||||
* @param {object} keyMap - Mapping of access type names to locale keys
|
||||
* @returns {string} Translated access type label
|
||||
*/
|
||||
function translateAccessType(accessTypeName, t, keyMap) {
|
||||
const key = keyMap[accessTypeName];
|
||||
if (!key) {
|
||||
return accessTypeName;
|
||||
}
|
||||
if (accessTypeName === 'groupPublic' || accessTypeName === 'groupPlus') {
|
||||
const groupKey = keyMap['group'];
|
||||
return t(groupKey) + ' ' + t(key);
|
||||
}
|
||||
return t(key);
|
||||
}
|
||||
|
||||
export { parseLocation, displayLocation, resolveRegion, translateAccessType };
|
||||
|
||||
542
src/stores/__tests__/mediaParsers.test.js
Normal file
542
src/stores/__tests__/mediaParsers.test.js
Normal file
@@ -0,0 +1,542 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
convertYoutubeTime: vi.fn((dur) => {
|
||||
// simplified mock: PT3M30S → 210
|
||||
const m = /(\d+)M/.exec(dur);
|
||||
const s = /(\d+)S/.exec(dur);
|
||||
return (m ? Number(m[1]) * 60 : 0) + (s ? Number(s[1]) : 0);
|
||||
}),
|
||||
findUserByDisplayName: vi.fn(),
|
||||
isRpcWorld: vi.fn(() => false),
|
||||
replaceBioSymbols: vi.fn((s) => s)
|
||||
}));
|
||||
|
||||
import { isRpcWorld, findUserByDisplayName } from '../../shared/utils';
|
||||
import { createMediaParsers } from '../gameLog/mediaParsers';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param overrides
|
||||
*/
|
||||
function makeDeps(overrides = {}) {
|
||||
return {
|
||||
nowPlaying: { value: { url: '' } },
|
||||
setNowPlaying: vi.fn(),
|
||||
clearNowPlaying: vi.fn(),
|
||||
userStore: { cachedUsers: new Map() },
|
||||
advancedSettingsStore: {
|
||||
youTubeApi: false,
|
||||
lookupYouTubeVideo: vi.fn()
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ─── addGameLogVideo ─────────────────────────────────────────────────
|
||||
|
||||
describe('addGameLogVideo', () => {
|
||||
let deps, parsers;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isRpcWorld.mockReturnValue(false);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
});
|
||||
|
||||
test('creates VideoPlay entry for a normal URL', async () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
displayName: 'Alice'
|
||||
};
|
||||
await parsers.addGameLogVideo(gameLog, 'wrld_123:456', 'usr_a');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
displayName: 'Alice',
|
||||
location: 'wrld_123:456',
|
||||
userId: 'usr_a'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('skips video in RPC world (non-YouTube)', async () => {
|
||||
isRpcWorld.mockReturnValue(true);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
videoUrl: 'https://example.com/video.mp4'
|
||||
};
|
||||
await parsers.addGameLogVideo(gameLog, 'wrld_rpc', 'usr_a');
|
||||
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('processes YouTube video in RPC world when videoId is YouTube', async () => {
|
||||
isRpcWorld.mockReturnValue(true);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
videoUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
||||
videoId: 'YouTube'
|
||||
};
|
||||
await parsers.addGameLogVideo(gameLog, 'wrld_rpc', 'usr_a');
|
||||
|
||||
// YouTube API off, so videoId/videoName/videoLength default
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
videoUrl: 'https://youtu.be/dQw4w9WgXcQ'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('extracts YouTube video ID from youtu.be URL', async () => {
|
||||
deps = makeDeps({
|
||||
advancedSettingsStore: {
|
||||
youTubeApi: true,
|
||||
lookupYouTubeVideo: vi.fn().mockResolvedValue({
|
||||
pageInfo: { totalResults: 1 },
|
||||
items: [
|
||||
{
|
||||
snippet: { title: 'Test Video' },
|
||||
contentDetails: { duration: 'PT3M30S' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
parsers = createMediaParsers(deps);
|
||||
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
videoUrl: 'https://youtu.be/dQw4w9WgXcQ'
|
||||
};
|
||||
await parsers.addGameLogVideo(gameLog, 'wrld_123:456', 'usr_a');
|
||||
|
||||
expect(
|
||||
deps.advancedSettingsStore.lookupYouTubeVideo
|
||||
).toHaveBeenCalledWith('dQw4w9WgXcQ');
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
videoId: 'YouTube',
|
||||
videoName: 'Test Video',
|
||||
videoLength: 210
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('respects videoPos from gameLog', async () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
videoUrl: 'https://example.com/v.mp4',
|
||||
videoPos: 42
|
||||
};
|
||||
await parsers.addGameLogVideo(gameLog, 'wrld_123:456', 'usr_a');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ videoPos: 42 })
|
||||
);
|
||||
});
|
||||
|
||||
test('unwraps proxy URLs (t-ne.x0.to)', async () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
videoUrl:
|
||||
'https://t-ne.x0.to/?url=https://www.youtube.com/watch?v=abcdefghijk'
|
||||
};
|
||||
deps = makeDeps({
|
||||
advancedSettingsStore: {
|
||||
youTubeApi: true,
|
||||
lookupYouTubeVideo: vi.fn().mockResolvedValue({
|
||||
pageInfo: { totalResults: 1 },
|
||||
items: [
|
||||
{
|
||||
snippet: { title: 'Proxy Video' },
|
||||
contentDetails: { duration: 'PT1M' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
parsers = createMediaParsers(deps);
|
||||
|
||||
await parsers.addGameLogVideo(gameLog, 'wrld_123:456', 'usr_a');
|
||||
|
||||
expect(
|
||||
deps.advancedSettingsStore.lookupYouTubeVideo
|
||||
).toHaveBeenCalledWith('abcdefghijk');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGameLogPyPyDance ─────────────────────────────────────────────
|
||||
|
||||
describe('addGameLogPyPyDance', () => {
|
||||
let deps, parsers;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isRpcWorld.mockReturnValue(true);
|
||||
findUserByDisplayName.mockReturnValue(null);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
});
|
||||
|
||||
test('parses PyPyDance data and calls setNowPlaying', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(PyPyDance) "https://example.com/v.mp4",10,300,"SomeSource: Song Title(TestUser)"'
|
||||
};
|
||||
parsers.addGameLogPyPyDance(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
videoUrl: 'https://example.com/v.mp4',
|
||||
videoLength: 300,
|
||||
displayName: 'TestUser'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('returns early for unparseable data', () => {
|
||||
const gameLog = { dt: '2024-01-01', data: 'garbage data' };
|
||||
parsers.addGameLogPyPyDance(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sets displayName to empty when Random', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(PyPyDance) "https://example.com/v.mp4",5,200,"Source: Title(Random)"'
|
||||
};
|
||||
parsers.addGameLogPyPyDance(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ displayName: '' })
|
||||
);
|
||||
});
|
||||
|
||||
test('updates nowPlaying when URL matches', () => {
|
||||
deps.nowPlaying.value.url = 'https://example.com/v.mp4';
|
||||
parsers = createMediaParsers(deps);
|
||||
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(PyPyDance) "https://example.com/v.mp4",20,300,"Source: Title(User1)"'
|
||||
};
|
||||
parsers.addGameLogPyPyDance(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updatedAt: '2024-01-01',
|
||||
videoPos: 20,
|
||||
videoLength: 300
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGameLogVRDancing ─────────────────────────────────────────────
|
||||
|
||||
describe('addGameLogVRDancing', () => {
|
||||
let deps, parsers;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isRpcWorld.mockReturnValue(true);
|
||||
findUserByDisplayName.mockReturnValue(null);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
});
|
||||
|
||||
test('parses VRDancing data and creates entry', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(VRDancing) "https://example.com/v.mp4",10,300,42,"Alice","Cool Song"'
|
||||
};
|
||||
parsers.addGameLogVRDancing(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
videoUrl: 'https://example.com/v.mp4',
|
||||
displayName: 'Alice',
|
||||
videoName: 'Cool Song'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('converts videoId -1 to YouTube', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(VRDancing) "https://youtu.be/dQw4w9WgXcQ",0,300,-1,"Alice","Song"'
|
||||
};
|
||||
// This will call addGameLogVideo internally (YouTube path)
|
||||
parsers.addGameLogVRDancing(gameLog, 'wrld_rpc');
|
||||
|
||||
// setNowPlaying is called via addGameLogVideo for YouTube
|
||||
expect(deps.setNowPlaying).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('strips HTML from videoName', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(VRDancing) "https://example.com/v.mp4",0,200,5,"Bob","[Tag]</b> Actual Title"'
|
||||
};
|
||||
parsers.addGameLogVRDancing(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ videoName: 'Actual Title' })
|
||||
);
|
||||
});
|
||||
|
||||
test('resets videoPos when it equals videoLength', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(VRDancing) "https://example.com/v.mp4",300,300,5,"Bob","Title"'
|
||||
};
|
||||
parsers.addGameLogVRDancing(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ videoPos: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
test('returns early for unparseable data', () => {
|
||||
const gameLog = { dt: '2024-01-01', data: 'bad data' };
|
||||
parsers.addGameLogVRDancing(gameLog, 'wrld_rpc');
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGameLogZuwaZuwaDance ─────────────────────────────────────────
|
||||
|
||||
describe('addGameLogZuwaZuwaDance', () => {
|
||||
let deps, parsers;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isRpcWorld.mockReturnValue(true);
|
||||
findUserByDisplayName.mockReturnValue(null);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
});
|
||||
|
||||
test('parses ZuwaZuwaDance data correctly', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(ZuwaZuwaDance) "https://example.com/v.mp4",5,200,42,"Alice","Dance Song"'
|
||||
};
|
||||
parsers.addGameLogZuwaZuwaDance(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
displayName: 'Alice',
|
||||
videoName: 'Dance Song',
|
||||
videoId: '42'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('converts videoId 9999 to YouTube', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(ZuwaZuwaDance) "https://youtu.be/dQw4w9WgXcQ",0,200,9999,"Alice","Song"'
|
||||
};
|
||||
parsers.addGameLogZuwaZuwaDance(gameLog, 'wrld_rpc');
|
||||
expect(deps.setNowPlaying).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sets displayName to empty when Random', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(ZuwaZuwaDance) "https://example.com/v.mp4",0,200,1,"Random","Song"'
|
||||
};
|
||||
parsers.addGameLogZuwaZuwaDance(gameLog, 'wrld_rpc');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ displayName: '' })
|
||||
);
|
||||
});
|
||||
|
||||
test('returns early for unparseable data', () => {
|
||||
const gameLog = { dt: '2024-01-01', data: 'bad' };
|
||||
parsers.addGameLogZuwaZuwaDance(gameLog, 'wrld_rpc');
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGameLogLSMedia ───────────────────────────────────────────────
|
||||
|
||||
describe('addGameLogLSMedia', () => {
|
||||
let deps, parsers;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUserByDisplayName.mockReturnValue(null);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
});
|
||||
|
||||
test('parses LSMedia log correctly', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'LSMedia 0,6298.292,Natsumi-sama,The Outfit (2022),'
|
||||
};
|
||||
parsers.addGameLogLSMedia(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
videoId: 'LSMedia',
|
||||
displayName: 'Natsumi-sama',
|
||||
videoName: 'The Outfit (2022)'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('returns early for empty video name (regex does not match)', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'LSMedia 0,4268.981,Natsumi-sama,,'
|
||||
};
|
||||
parsers.addGameLogLSMedia(gameLog, 'wrld_123:456');
|
||||
|
||||
// The regex requires a non-empty 4th capture group,
|
||||
// so an empty video name causes the parse to fail
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns early for unparseable data', () => {
|
||||
const gameLog = { dt: '2024-01-01', data: 'bad data' };
|
||||
parsers.addGameLogLSMedia(gameLog, 'wrld_123:456');
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('looks up userId when displayName given', () => {
|
||||
findUserByDisplayName.mockReturnValue({ id: 'usr_found' });
|
||||
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'LSMedia 0,100,Alice,Movie,'
|
||||
};
|
||||
parsers.addGameLogLSMedia(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'usr_found' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── addGameLogPopcornPalace ─────────────────────────────────────────
|
||||
|
||||
describe('addGameLogPopcornPalace', () => {
|
||||
let deps, parsers;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUserByDisplayName.mockReturnValue(null);
|
||||
deps = makeDeps();
|
||||
parsers = createMediaParsers(deps);
|
||||
});
|
||||
|
||||
test('parses PopcornPalace JSON data', () => {
|
||||
const json = JSON.stringify({
|
||||
videoName: 'How to Train Your Dragon',
|
||||
videoPos: 37.28,
|
||||
videoLength: 11474.05,
|
||||
thumbnailUrl: 'https://example.com/thumb.jpg',
|
||||
displayName: 'miner28_3',
|
||||
isPaused: false,
|
||||
is3D: false,
|
||||
looping: false
|
||||
});
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: `VideoPlay(PopcornPalace) ${json}`
|
||||
};
|
||||
parsers.addGameLogPopcornPalace(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'VideoPlay',
|
||||
videoId: 'PopcornPalace',
|
||||
videoName: 'How to Train Your Dragon',
|
||||
displayName: 'miner28_3',
|
||||
thumbnailUrl: 'https://example.com/thumb.jpg'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('calls clearNowPlaying when videoName is empty', () => {
|
||||
const json = JSON.stringify({
|
||||
videoName: '',
|
||||
videoPos: 0,
|
||||
videoLength: 0,
|
||||
displayName: 'user'
|
||||
});
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: `VideoPlay(PopcornPalace) ${json}`
|
||||
};
|
||||
parsers.addGameLogPopcornPalace(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.clearNowPlaying).toHaveBeenCalled();
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns early for null data', () => {
|
||||
const gameLog = { dt: '2024-01-01', data: null };
|
||||
parsers.addGameLogPopcornPalace(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
expect(deps.clearNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns early for invalid JSON', () => {
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: 'VideoPlay(PopcornPalace) {bad json'
|
||||
};
|
||||
parsers.addGameLogPopcornPalace(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.setNowPlaying).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updates existing nowPlaying when URL matches', () => {
|
||||
deps.nowPlaying.value.url = 'Movie Title';
|
||||
parsers = createMediaParsers(deps);
|
||||
|
||||
const json = JSON.stringify({
|
||||
videoName: 'Movie Title',
|
||||
videoPos: 500,
|
||||
videoLength: 7200,
|
||||
thumbnailUrl: '',
|
||||
displayName: 'user'
|
||||
});
|
||||
const gameLog = {
|
||||
dt: '2024-01-01',
|
||||
data: `VideoPlay(PopcornPalace) ${json}`
|
||||
};
|
||||
parsers.addGameLogPopcornPalace(gameLog, 'wrld_123:456');
|
||||
|
||||
expect(deps.setNowPlaying).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updatedAt: '2024-01-01',
|
||||
videoPos: 500,
|
||||
videoLength: 7200
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
283
src/stores/__tests__/overlayDispatch.test.js
Normal file
283
src/stores/__tests__/overlayDispatch.test.js
Normal file
@@ -0,0 +1,283 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
extractFileId: vi.fn(),
|
||||
extractFileVersion: vi.fn()
|
||||
}));
|
||||
vi.mock('../../shared/utils/notificationMessage', () => ({
|
||||
getNotificationMessage: vi.fn(),
|
||||
toNotificationText: vi.fn()
|
||||
}));
|
||||
|
||||
import { extractFileId, extractFileVersion } from '../../shared/utils';
|
||||
import {
|
||||
getNotificationMessage,
|
||||
toNotificationText
|
||||
} from '../../shared/utils/notificationMessage';
|
||||
import { createOverlayDispatch } from '../notification/overlayDispatch';
|
||||
|
||||
function makeDeps(overrides = {}) {
|
||||
return {
|
||||
getUserIdFromNoty: vi.fn(() => ''),
|
||||
userRequest: {
|
||||
getCachedUser: vi.fn().mockResolvedValue({ json: null })
|
||||
},
|
||||
notificationsSettingsStore: {
|
||||
notificationTimeout: 5000
|
||||
},
|
||||
advancedSettingsStore: {
|
||||
notificationOpacity: 80
|
||||
},
|
||||
appearanceSettingsStore: {
|
||||
displayVRCPlusIconsAsAvatar: false
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ─── notyGetImage ────────────────────────────────────────────────────
|
||||
|
||||
describe('notyGetImage', () => {
|
||||
let deps, dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
deps = makeDeps();
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
});
|
||||
|
||||
test('returns thumbnailImageUrl when present', async () => {
|
||||
const noty = { thumbnailImageUrl: 'https://thumb.jpg' };
|
||||
const result = await dispatch.notyGetImage(noty);
|
||||
expect(result).toBe('https://thumb.jpg');
|
||||
});
|
||||
|
||||
test('returns details.imageUrl when thumbnailImageUrl absent', async () => {
|
||||
const noty = { details: { imageUrl: 'https://detail.jpg' } };
|
||||
const result = await dispatch.notyGetImage(noty);
|
||||
expect(result).toBe('https://detail.jpg');
|
||||
});
|
||||
|
||||
test('returns imageUrl when thumbnailImageUrl and details absent', async () => {
|
||||
const noty = { imageUrl: 'https://img.jpg' };
|
||||
const result = await dispatch.notyGetImage(noty);
|
||||
expect(result).toBe('https://img.jpg');
|
||||
});
|
||||
|
||||
test('looks up user currentAvatarThumbnailImageUrl when no image URLs', async () => {
|
||||
deps.getUserIdFromNoty.mockReturnValue('usr_abc');
|
||||
deps.userRequest.getCachedUser.mockResolvedValue({
|
||||
json: {
|
||||
currentAvatarThumbnailImageUrl: 'https://avatar.jpg'
|
||||
}
|
||||
});
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const noty = {};
|
||||
const result = await dispatch.notyGetImage(noty);
|
||||
expect(result).toBe('https://avatar.jpg');
|
||||
});
|
||||
|
||||
test('returns profilePicOverride when available', async () => {
|
||||
deps.getUserIdFromNoty.mockReturnValue('usr_abc');
|
||||
deps.userRequest.getCachedUser.mockResolvedValue({
|
||||
json: {
|
||||
profilePicOverride: 'https://profile.jpg',
|
||||
currentAvatarThumbnailImageUrl: 'https://avatar.jpg'
|
||||
}
|
||||
});
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const result = await dispatch.notyGetImage({});
|
||||
expect(result).toBe('https://profile.jpg');
|
||||
});
|
||||
|
||||
test('returns userIcon when displayVRCPlusIconsAsAvatar is enabled', async () => {
|
||||
deps.getUserIdFromNoty.mockReturnValue('usr_abc');
|
||||
deps.appearanceSettingsStore.displayVRCPlusIconsAsAvatar = true;
|
||||
deps.userRequest.getCachedUser.mockResolvedValue({
|
||||
json: {
|
||||
userIcon: 'https://icon.jpg',
|
||||
profilePicOverride: 'https://profile.jpg',
|
||||
currentAvatarThumbnailImageUrl: 'https://avatar.jpg'
|
||||
}
|
||||
});
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const result = await dispatch.notyGetImage({});
|
||||
expect(result).toBe('https://icon.jpg');
|
||||
});
|
||||
|
||||
test('returns empty string for grp_ userId', async () => {
|
||||
deps.getUserIdFromNoty.mockReturnValue('grp_abc');
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const result = await dispatch.notyGetImage({});
|
||||
expect(result).toBe('');
|
||||
expect(deps.userRequest.getCachedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns empty string when user lookup fails', async () => {
|
||||
deps.getUserIdFromNoty.mockReturnValue('usr_abc');
|
||||
deps.userRequest.getCachedUser.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const result = await dispatch.notyGetImage({});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string when user has no json', async () => {
|
||||
deps.getUserIdFromNoty.mockReturnValue('usr_abc');
|
||||
deps.userRequest.getCachedUser.mockResolvedValue({ json: null });
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const result = await dispatch.notyGetImage({});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── displayDesktopToast ─────────────────────────────────────────────
|
||||
|
||||
describe('displayDesktopToast', () => {
|
||||
let deps, dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.WINDOWS = true;
|
||||
globalThis.AppApi = { DesktopNotification: vi.fn() };
|
||||
deps = makeDeps();
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
});
|
||||
|
||||
test('calls desktopNotification with message from getNotificationMessage', () => {
|
||||
getNotificationMessage.mockReturnValue({
|
||||
title: 'Friend Online',
|
||||
body: 'Alice is online'
|
||||
});
|
||||
|
||||
dispatch.displayDesktopToast({}, 'some message', 'img.jpg');
|
||||
|
||||
expect(getNotificationMessage).toHaveBeenCalled();
|
||||
expect(AppApi.DesktopNotification).toHaveBeenCalledWith(
|
||||
'Friend Online',
|
||||
'Alice is online',
|
||||
'img.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
test('does nothing when getNotificationMessage returns null', () => {
|
||||
getNotificationMessage.mockReturnValue(null);
|
||||
|
||||
dispatch.displayDesktopToast({}, 'some message', 'img.jpg');
|
||||
|
||||
expect(AppApi.DesktopNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── notySaveImage ───────────────────────────────────────────────────
|
||||
|
||||
describe('notySaveImage', () => {
|
||||
let deps, dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.AppApi = {
|
||||
GetImage: vi.fn().mockResolvedValue('/local/path.jpg')
|
||||
};
|
||||
deps = makeDeps();
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
});
|
||||
|
||||
test('returns saved image path from fileId/fileVersion extraction', async () => {
|
||||
extractFileId.mockReturnValue('file_123');
|
||||
extractFileVersion.mockReturnValue('v1');
|
||||
|
||||
const noty = {
|
||||
thumbnailImageUrl: 'https://api.vrchat.cloud/file_123/v1'
|
||||
};
|
||||
const result = await dispatch.notySaveImage(noty);
|
||||
|
||||
expect(AppApi.GetImage).toHaveBeenCalledWith(
|
||||
'https://api.vrchat.cloud/file_123/v1',
|
||||
'file_123',
|
||||
'v1'
|
||||
);
|
||||
expect(result).toBe('/local/path.jpg');
|
||||
});
|
||||
|
||||
test('falls back to URL-derived fileId for http URLs without fileId', async () => {
|
||||
extractFileId.mockReturnValue('');
|
||||
extractFileVersion.mockReturnValue('');
|
||||
|
||||
const noty = {
|
||||
thumbnailImageUrl:
|
||||
'https://cdn.example.com/1416226261.thumbnail-500.png'
|
||||
};
|
||||
const result = await dispatch.notySaveImage(noty);
|
||||
|
||||
expect(AppApi.GetImage).toHaveBeenCalledWith(
|
||||
'https://cdn.example.com/1416226261.thumbnail-500.png',
|
||||
'1416226261',
|
||||
'1416226261.thumbnail-500.png'
|
||||
);
|
||||
expect(result).toBe('/local/path.jpg');
|
||||
});
|
||||
|
||||
test('returns empty string when no image URL is found', async () => {
|
||||
extractFileId.mockReturnValue('');
|
||||
extractFileVersion.mockReturnValue('');
|
||||
deps.getUserIdFromNoty.mockReturnValue('');
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
|
||||
const noty = {};
|
||||
const result = await dispatch.notySaveImage(noty);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── displayXSNotification ──────────────────────────────────────────
|
||||
|
||||
describe('displayXSNotification', () => {
|
||||
let deps, dispatch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.AppApi = { XSNotification: vi.fn() };
|
||||
deps = makeDeps();
|
||||
dispatch = createOverlayDispatch(deps);
|
||||
});
|
||||
|
||||
test('calls XSNotification with formatted text', () => {
|
||||
getNotificationMessage.mockReturnValue({
|
||||
title: 'Title',
|
||||
body: 'Body'
|
||||
});
|
||||
toNotificationText.mockReturnValue('Title: Body');
|
||||
|
||||
dispatch.displayXSNotification({ type: 'friendOnline' }, 'msg', 'img');
|
||||
|
||||
expect(toNotificationText).toHaveBeenCalledWith(
|
||||
'Title',
|
||||
'Body',
|
||||
'friendOnline'
|
||||
);
|
||||
expect(AppApi.XSNotification).toHaveBeenCalledWith(
|
||||
'VRCX',
|
||||
'Title: Body',
|
||||
5, // 5000ms / 1000
|
||||
0.8, // 80 / 100
|
||||
'img'
|
||||
);
|
||||
});
|
||||
|
||||
test('does nothing when getNotificationMessage returns null', () => {
|
||||
getNotificationMessage.mockReturnValue(null);
|
||||
|
||||
dispatch.displayXSNotification({}, 'msg', 'img');
|
||||
|
||||
expect(AppApi.XSNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,7 @@
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { formatCsvField, formatCsvRow } from '../../../shared/utils';
|
||||
import { useAvatarStore, useFavoriteStore } from '../../../stores';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -114,6 +115,11 @@
|
||||
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
|
||||
]);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param label
|
||||
* @param checked
|
||||
*/
|
||||
function toggleAvatarExportOption(label, checked) {
|
||||
const selection = exportSelectedOptions.value;
|
||||
const index = selection.indexOf(label);
|
||||
@@ -143,6 +149,9 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function showAvatarExportDialog() {
|
||||
avatarExportFavoriteGroup.value = null;
|
||||
avatarExportLocalFavoriteGroup.value = null;
|
||||
@@ -151,6 +160,10 @@
|
||||
updateAvatarExportDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleAvatarExportFavoriteGroupSelect(value) {
|
||||
avatarExportFavoriteGroupSelection.value = value;
|
||||
if (value === AVATAR_EXPORT_ALL_VALUE) {
|
||||
@@ -161,6 +174,10 @@
|
||||
selectAvatarExportGroup(group);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleAvatarExportLocalFavoriteGroupSelect(value) {
|
||||
avatarExportLocalFavoriteGroupSelection.value = value;
|
||||
if (value === AVATAR_EXPORT_NONE_VALUE) {
|
||||
@@ -169,6 +186,10 @@
|
||||
}
|
||||
selectAvatarExportLocalGroup(value);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
function handleCopyAvatarExportData(event) {
|
||||
if (event.target.tagName === 'TEXTAREA') {
|
||||
event.target.select();
|
||||
@@ -183,38 +204,14 @@
|
||||
toast.error('Copy failed!');
|
||||
});
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateAvatarExportDialog() {
|
||||
const needsCsvQuotes = (text) => {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) < 0x20) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return text.includes(',') || text.includes('"');
|
||||
};
|
||||
|
||||
const formatter = function (value) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const text = String(value);
|
||||
if (needsCsvQuotes(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const propsForQuery = exportSelectOptions.value
|
||||
.filter((option) => exportSelectedOptions.value.includes(option.label))
|
||||
.map((option) => option.value);
|
||||
|
||||
function resText(ref) {
|
||||
let resArr = [];
|
||||
propsForQuery.forEach((e) => {
|
||||
resArr.push(formatter(ref?.[e]));
|
||||
});
|
||||
return resArr.join(',');
|
||||
}
|
||||
|
||||
const lines = [exportSelectedOptions.value.join(',')];
|
||||
|
||||
if (avatarExportFavoriteGroup.value) {
|
||||
@@ -222,7 +219,7 @@
|
||||
if (!avatarExportFavoriteGroup.value || avatarExportFavoriteGroup.value === group) {
|
||||
favoriteAvatars.value.forEach((ref) => {
|
||||
if (group.key === ref.groupKey) {
|
||||
lines.push(resText(ref.ref));
|
||||
lines.push(formatCsvRow(ref.ref, propsForQuery));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -234,23 +231,27 @@
|
||||
}
|
||||
for (let i = 0; i < favoriteGroup.length; ++i) {
|
||||
const ref = favoriteGroup[i];
|
||||
lines.push(resText(ref));
|
||||
lines.push(formatCsvRow(ref, propsForQuery));
|
||||
}
|
||||
} else {
|
||||
// export all
|
||||
favoriteAvatars.value.forEach((ref) => {
|
||||
lines.push(resText(ref.ref));
|
||||
lines.push(formatCsvRow(ref.ref, propsForQuery));
|
||||
});
|
||||
for (let i = 0; i < localAvatarFavoritesList.value.length; ++i) {
|
||||
const avatarId = localAvatarFavoritesList.value[i];
|
||||
const ref = cachedAvatars.get(avatarId);
|
||||
if (typeof ref !== 'undefined') {
|
||||
lines.push(resText(ref));
|
||||
lines.push(formatCsvRow(ref, propsForQuery));
|
||||
}
|
||||
}
|
||||
}
|
||||
avatarExportContent.value = lines.reverse().join('\n');
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
function selectAvatarExportGroup(group) {
|
||||
avatarExportFavoriteGroup.value = group;
|
||||
avatarExportLocalFavoriteGroup.value = null;
|
||||
@@ -258,6 +259,10 @@
|
||||
avatarExportLocalFavoriteGroupSelection.value = AVATAR_EXPORT_NONE_VALUE;
|
||||
updateAvatarExportDialog();
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
function selectAvatarExportLocalGroup(group) {
|
||||
avatarExportLocalFavoriteGroup.value = group;
|
||||
avatarExportFavoriteGroup.value = null;
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useFavoriteStore, useUserStore } from '../../../stores';
|
||||
import { formatCsvField } from '../../../shared/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -111,12 +112,19 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function showFriendExportDialog() {
|
||||
friendExportFavoriteGroup.value = null;
|
||||
friendExportFavoriteGroupSelection.value = FRIEND_EXPORT_ALL_VALUE;
|
||||
updateFriendExportDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleFriendExportGroupSelect(value) {
|
||||
friendExportFavoriteGroupSelection.value = value;
|
||||
if (value === FRIEND_EXPORT_ALL_VALUE) {
|
||||
@@ -127,6 +135,10 @@
|
||||
selectFriendExportGroup(group);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleFriendExportLocalGroupSelect(value) {
|
||||
friendExportLocalFavoriteGroupSelection.value = value;
|
||||
if (value === FRIEND_EXPORT_NONE_VALUE) {
|
||||
@@ -136,6 +148,10 @@
|
||||
selectFriendExportLocalGroup(value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
function handleCopyFriendExportData(event) {
|
||||
if (event.target.tagName === 'TEXTAREA') {
|
||||
event.target.select();
|
||||
@@ -151,26 +167,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateFriendExportDialog() {
|
||||
const needsCsvQuotes = (text) => {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) < 0x20) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return text.includes(',') || text.includes('"');
|
||||
};
|
||||
|
||||
const formatter = function (value) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const text = String(value);
|
||||
if (needsCsvQuotes(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const lines = ['UserID,Name'];
|
||||
|
||||
if (friendExportFavoriteGroup.value) {
|
||||
@@ -178,7 +178,7 @@
|
||||
if (friendExportFavoriteGroup.value === group) {
|
||||
favoriteFriends.value.forEach((ref) => {
|
||||
if (group.key === ref.groupKey) {
|
||||
lines.push(`${formatter(ref.id)},${formatter(ref.name)}`);
|
||||
lines.push(`${formatCsvField(ref.id)},${formatCsvField(ref.name)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -191,25 +191,29 @@
|
||||
favoriteGroup.forEach((userId) => {
|
||||
const ref = cachedUsers.value.get(userId);
|
||||
if (typeof ref !== 'undefined') {
|
||||
lines.push(`${formatter(ref.id)},${formatter(ref.displayName)}`);
|
||||
lines.push(`${formatCsvField(ref.id)},${formatCsvField(ref.displayName)}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// export all
|
||||
favoriteFriends.value.forEach((ref) => {
|
||||
lines.push(`${formatter(ref.id)},${formatter(ref.name)}`);
|
||||
lines.push(`${formatCsvField(ref.id)},${formatCsvField(ref.name)}`);
|
||||
});
|
||||
for (let i = 0; i < localFriendFavoritesList.value.length; ++i) {
|
||||
const userId = localFriendFavoritesList.value[i];
|
||||
const ref = cachedUsers.value.get(userId);
|
||||
if (typeof ref !== 'undefined') {
|
||||
lines.push(`${formatter(ref.id)},${formatter(ref.displayName)}`);
|
||||
lines.push(`${formatCsvField(ref.id)},${formatCsvField(ref.displayName)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
friendExportContent.value = lines.reverse().join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
function selectFriendExportGroup(group) {
|
||||
friendExportFavoriteGroup.value = group;
|
||||
friendExportLocalFavoriteGroup.value = null;
|
||||
@@ -218,6 +222,10 @@
|
||||
updateFriendExportDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupName
|
||||
*/
|
||||
function selectFriendExportLocalGroup(groupName) {
|
||||
friendExportLocalFavoriteGroup.value = groupName;
|
||||
friendExportFavoriteGroup.value = null;
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useFavoriteStore, useWorldStore } from '../../../stores';
|
||||
import { formatCsvRow } from '../../../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
worldExportDialogVisible: {
|
||||
@@ -117,6 +118,11 @@
|
||||
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
|
||||
]);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param label
|
||||
* @param checked
|
||||
*/
|
||||
function toggleWorldExportOption(label, checked) {
|
||||
const selection = exportSelectedOptions.value;
|
||||
const index = selection.indexOf(label);
|
||||
@@ -146,6 +152,9 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function showWorldExportDialog() {
|
||||
worldExportFavoriteGroup.value = null;
|
||||
worldExportLocalFavoriteGroup.value = null;
|
||||
@@ -154,6 +163,10 @@
|
||||
updateWorldExportDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleWorldExportGroupSelect(value) {
|
||||
worldExportFavoriteGroupSelection.value = value;
|
||||
if (value === WORLD_EXPORT_ALL_VALUE) {
|
||||
@@ -164,6 +177,10 @@
|
||||
selectWorldExportGroup(group);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleWorldExportLocalGroupSelect(value) {
|
||||
worldExportLocalFavoriteGroupSelection.value = value;
|
||||
if (value === WORLD_EXPORT_NONE_VALUE) {
|
||||
@@ -173,6 +190,10 @@
|
||||
selectWorldExportLocalGroup(value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
function handleCopyWorldExportData(event) {
|
||||
if (event.target.tagName === 'TEXTAREA') {
|
||||
event.target.select();
|
||||
@@ -188,26 +209,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function updateWorldExportDialog() {
|
||||
const formatter = function (str) {
|
||||
if (/[\x00-\x1f,"]/.test(str) === true) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const propsForQuery = exportSelectOptions.value
|
||||
.filter((option) => exportSelectedOptions.value.includes(option.label))
|
||||
.map((option) => option.value);
|
||||
|
||||
function resText(ref) {
|
||||
let resArr = [];
|
||||
propsForQuery.forEach((e) => {
|
||||
resArr.push(formatter(ref?.[e]));
|
||||
});
|
||||
return resArr.join(',');
|
||||
}
|
||||
|
||||
const lines = [exportSelectedOptions.value.join(',')];
|
||||
|
||||
if (worldExportFavoriteGroup.value) {
|
||||
@@ -215,7 +224,7 @@
|
||||
if (worldExportFavoriteGroup.value === group) {
|
||||
favoriteWorlds.value.forEach((ref) => {
|
||||
if (group.key === ref.groupKey) {
|
||||
lines.push(resText(ref.ref));
|
||||
lines.push(formatCsvRow(ref.ref, propsForQuery));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -227,24 +236,28 @@
|
||||
}
|
||||
for (let i = 0; i < favoriteGroup.length; ++i) {
|
||||
const ref = favoriteGroup[i];
|
||||
lines.push(resText(ref));
|
||||
lines.push(formatCsvRow(ref, propsForQuery));
|
||||
}
|
||||
} else {
|
||||
// export all
|
||||
favoriteWorlds.value.forEach((ref) => {
|
||||
lines.push(resText(ref.ref));
|
||||
lines.push(formatCsvRow(ref.ref, propsForQuery));
|
||||
});
|
||||
for (let i = 0; i < localWorldFavoritesList.length; ++i) {
|
||||
const worldId = localWorldFavoritesList[i];
|
||||
const ref = cachedWorlds.get(worldId);
|
||||
if (typeof ref !== 'undefined') {
|
||||
lines.push(resText(ref));
|
||||
lines.push(formatCsvRow(ref, propsForQuery));
|
||||
}
|
||||
}
|
||||
}
|
||||
worldExportContent.value = lines.reverse().join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
function selectWorldExportGroup(group) {
|
||||
worldExportFavoriteGroup.value = group;
|
||||
worldExportLocalFavoriteGroup.value = null;
|
||||
@@ -253,6 +266,10 @@
|
||||
updateWorldExportDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
function selectWorldExportLocalGroup(group) {
|
||||
worldExportLocalFavoriteGroup.value = group;
|
||||
worldExportFavoriteGroup.value = null;
|
||||
|
||||
241
src/views/Login/__tests__/Login.test.js
Normal file
241
src/views/Login/__tests__/Login.test.js
Normal file
@@ -0,0 +1,241 @@
|
||||
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';
|
||||
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/login', name: 'login', 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: '/login', name: 'login', meta: {} })
|
||||
})),
|
||||
useRoute: vi.fn(() => ({ query: {} }))
|
||||
};
|
||||
});
|
||||
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,
|
||||
isFriendsLoaded: false,
|
||||
isFavoritesLoaded: false
|
||||
}
|
||||
}));
|
||||
vi.mock('vee-validate', () => ({
|
||||
Field: {
|
||||
name: 'VeeField',
|
||||
props: ['name'],
|
||||
setup(_props, { slots }) {
|
||||
return () =>
|
||||
slots.default?.({
|
||||
field: { value: '', onChange: () => {}, onBlur: () => {} },
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
},
|
||||
useForm: vi.fn(() => ({
|
||||
handleSubmit: (fn) => fn,
|
||||
resetForm: vi.fn(),
|
||||
values: {}
|
||||
}))
|
||||
}));
|
||||
vi.mock('@vee-validate/zod', () => ({ toTypedSchema: vi.fn((s) => s) }));
|
||||
|
||||
import Login from '../Login.vue';
|
||||
import en from '../../../localization/en.json';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
LoginSettingsDialog: { template: '<div class="login-settings-stub" />' },
|
||||
TooltipWrapper: {
|
||||
template: '<span><slot /></span>',
|
||||
props: ['side', 'content']
|
||||
},
|
||||
DropdownMenu: { template: '<div class="dropdown-stub"><slot /></div>' },
|
||||
DropdownMenuTrigger: {
|
||||
template: '<span><slot /></span>',
|
||||
props: ['asChild']
|
||||
},
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuCheckboxItem: {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue']
|
||||
},
|
||||
Button: {
|
||||
template:
|
||||
'<button :type="type || \'button\'" :id="id"><slot /></button>',
|
||||
props: ['type', 'variant', 'size', 'id']
|
||||
},
|
||||
Checkbox: { template: '<input type="checkbox" />', props: ['modelValue'] },
|
||||
Field: { template: '<div><slot /></div>' },
|
||||
FieldContent: { template: '<div><slot /></div>' },
|
||||
FieldError: { template: '<span />', props: ['errors'] },
|
||||
FieldGroup: { template: '<div><slot /></div>' },
|
||||
FieldLabel: { template: '<label><slot /></label>', props: ['for'] },
|
||||
InputGroupField: {
|
||||
template:
|
||||
'<input :id="id" :value="modelValue" :placeholder="placeholder" />',
|
||||
props: [
|
||||
'id',
|
||||
'modelValue',
|
||||
'type',
|
||||
'autocomplete',
|
||||
'name',
|
||||
'placeholder',
|
||||
'ariaInvalid',
|
||||
'clearable'
|
||||
],
|
||||
emits: ['update:modelValue', 'blur']
|
||||
},
|
||||
ArrowBigDownDash: { template: '<span />' },
|
||||
Languages: { template: '<span />' },
|
||||
Trash2: { template: '<span />' }
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param storeOverrides
|
||||
*/
|
||||
function mountLogin(storeOverrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
Auth: {
|
||||
loginForm: {
|
||||
loading: false,
|
||||
username: '',
|
||||
password: '',
|
||||
endpoint: '',
|
||||
websocket: '',
|
||||
saveCredentials: false,
|
||||
lastUserLoggedIn: ''
|
||||
},
|
||||
...storeOverrides
|
||||
}
|
||||
}
|
||||
});
|
||||
return mount(Login, {
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('Login.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('form rendering', () => {
|
||||
test('renders username and password input fields', () => {
|
||||
const wrapper = mountLogin();
|
||||
const usernameInput = wrapper.find('#login-form-username');
|
||||
const passwordInput = wrapper.find('#login-form-password');
|
||||
expect(usernameInput.exists()).toBe(true);
|
||||
expect(passwordInput.exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('renders a login submit button', () => {
|
||||
const wrapper = mountLogin();
|
||||
const form = wrapper.find('#login-form');
|
||||
expect(form.exists()).toBe(true);
|
||||
const submitBtn = form.find('button[type="submit"]');
|
||||
expect(submitBtn.exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('renders a register button', () => {
|
||||
const wrapper = mountLogin();
|
||||
const buttons = wrapper.findAll('button');
|
||||
const registerBtn = buttons.find(
|
||||
(b) => b.text() === en.view.login.register
|
||||
);
|
||||
expect(registerBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders save credentials checkbox', () => {
|
||||
const wrapper = mountLogin();
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect(checkbox.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saved accounts section', () => {
|
||||
test('does not render saved accounts when credentials are empty', () => {
|
||||
const wrapper = mountLogin();
|
||||
const divider = wrapper.find('.x-vertical-divider');
|
||||
expect(divider.exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legal notice', () => {
|
||||
test('renders legal notice section', () => {
|
||||
const wrapper = mountLogin();
|
||||
const legalNotice = wrapper.find('.x-legal-notice-container');
|
||||
expect(legalNotice.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login settings', () => {
|
||||
test('renders LoginSettingsDialog stub', () => {
|
||||
const wrapper = mountLogin();
|
||||
expect(wrapper.find('.login-settings-stub').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -257,6 +257,7 @@
|
||||
useGroupStore,
|
||||
useNotificationStore
|
||||
} from '../../stores';
|
||||
import { normalizeFavoriteGroupsChange, resolveFavoriteGroups } from './sidebarSettingsUtils';
|
||||
import { useGlobalSearchStore } from '../../stores/globalSearch';
|
||||
|
||||
import FriendsSidebar from './components/FriendsSidebar.vue';
|
||||
@@ -317,30 +318,16 @@
|
||||
return keys;
|
||||
});
|
||||
|
||||
const resolvedSidebarFavoriteGroups = computed(() => {
|
||||
if (sidebarFavoriteGroups.value.length === 0) {
|
||||
return allFavoriteGroupKeys.value;
|
||||
}
|
||||
return sidebarFavoriteGroups.value;
|
||||
});
|
||||
const resolvedSidebarFavoriteGroups = computed(() =>
|
||||
resolveFavoriteGroups(sidebarFavoriteGroups.value, allFavoriteGroupKeys.value)
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleFavoriteGroupsChange(value) {
|
||||
if (!value || value.length === 0) {
|
||||
// Deselected all → reset to all (store as empty)
|
||||
setSidebarFavoriteGroups([]);
|
||||
return;
|
||||
}
|
||||
// If all groups are selected, store as empty (= all)
|
||||
const allKeys = allFavoriteGroupKeys.value;
|
||||
if (value.length >= allKeys.length && allKeys.every((k) => value.includes(k))) {
|
||||
setSidebarFavoriteGroups([]);
|
||||
return;
|
||||
}
|
||||
setSidebarFavoriteGroups(value);
|
||||
setSidebarFavoriteGroups(normalizeFavoriteGroupsChange(value, allFavoriteGroupKeys.value));
|
||||
}
|
||||
|
||||
const selectedFavGroupLabel = computed(() => {
|
||||
|
||||
145
src/views/Sidebar/__tests__/friendsSidebarUtils.test.js
Normal file
145
src/views/Sidebar/__tests__/friendsSidebarUtils.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildFriendRow,
|
||||
buildInstanceHeaderRow,
|
||||
buildToggleRow,
|
||||
estimateRowSize
|
||||
} from '../friendsSidebarUtils';
|
||||
|
||||
// ─── buildToggleRow ──────────────────────────────────────────────────
|
||||
|
||||
describe('buildToggleRow', () => {
|
||||
test('creates a toggle-header row with defaults', () => {
|
||||
const row = buildToggleRow({ key: 'online', label: 'Online' });
|
||||
expect(row).toEqual({
|
||||
type: 'toggle-header',
|
||||
key: 'online',
|
||||
label: 'Online',
|
||||
count: null,
|
||||
expanded: true,
|
||||
headerPadding: null,
|
||||
paddingBottom: null,
|
||||
onClick: null
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts all optional parameters', () => {
|
||||
const onClick = () => {};
|
||||
const row = buildToggleRow({
|
||||
key: 'vip',
|
||||
label: 'VIP',
|
||||
count: 5,
|
||||
expanded: false,
|
||||
headerPadding: 10,
|
||||
paddingBottom: 8,
|
||||
onClick
|
||||
});
|
||||
expect(row.count).toBe(5);
|
||||
expect(row.expanded).toBe(false);
|
||||
expect(row.headerPadding).toBe(10);
|
||||
expect(row.paddingBottom).toBe(8);
|
||||
expect(row.onClick).toBe(onClick);
|
||||
});
|
||||
|
||||
test('always sets type to toggle-header', () => {
|
||||
const row = buildToggleRow({ key: 'x', label: 'X' });
|
||||
expect(row.type).toBe('toggle-header');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildFriendRow ──────────────────────────────────────────────────
|
||||
|
||||
describe('buildFriendRow', () => {
|
||||
const friend = { id: 'usr_123', displayName: 'TestUser' };
|
||||
|
||||
test('creates a friend-item row with defaults', () => {
|
||||
const row = buildFriendRow(friend, 'friend:usr_123');
|
||||
expect(row).toEqual({
|
||||
type: 'friend-item',
|
||||
key: 'friend:usr_123',
|
||||
friend,
|
||||
isGroupByInstance: undefined,
|
||||
paddingBottom: undefined,
|
||||
itemStyle: undefined
|
||||
});
|
||||
});
|
||||
|
||||
test('passes options through', () => {
|
||||
const style = { opacity: 0.5 };
|
||||
const row = buildFriendRow(friend, 'k', {
|
||||
isGroupByInstance: true,
|
||||
paddingBottom: 4,
|
||||
itemStyle: style
|
||||
});
|
||||
expect(row.isGroupByInstance).toBe(true);
|
||||
expect(row.paddingBottom).toBe(4);
|
||||
expect(row.itemStyle).toBe(style);
|
||||
});
|
||||
|
||||
test('always sets type to friend-item', () => {
|
||||
const row = buildFriendRow(friend, 'k');
|
||||
expect(row.type).toBe('friend-item');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildInstanceHeaderRow ──────────────────────────────────────────
|
||||
|
||||
describe('buildInstanceHeaderRow', () => {
|
||||
test('creates an instance-header row', () => {
|
||||
const row = buildInstanceHeaderRow(
|
||||
'wrld_123:456~private',
|
||||
3,
|
||||
'inst:wrld_123'
|
||||
);
|
||||
expect(row).toEqual({
|
||||
type: 'instance-header',
|
||||
key: 'inst:wrld_123',
|
||||
location: 'wrld_123:456~private',
|
||||
count: 3,
|
||||
paddingBottom: 4
|
||||
});
|
||||
});
|
||||
|
||||
test('always has paddingBottom of 4', () => {
|
||||
const row = buildInstanceHeaderRow('loc', 1, 'k');
|
||||
expect(row.paddingBottom).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── estimateRowSize ─────────────────────────────────────────────────
|
||||
|
||||
describe('estimateRowSize', () => {
|
||||
test('returns 44 for null/undefined', () => {
|
||||
expect(estimateRowSize(null)).toBe(44);
|
||||
expect(estimateRowSize(undefined)).toBe(44);
|
||||
});
|
||||
|
||||
test('returns 28 + paddingBottom for toggle-header', () => {
|
||||
expect(estimateRowSize({ type: 'toggle-header' })).toBe(28);
|
||||
expect(
|
||||
estimateRowSize({ type: 'toggle-header', paddingBottom: 8 })
|
||||
).toBe(36);
|
||||
});
|
||||
|
||||
test('returns 24 + paddingBottom for vip-subheader', () => {
|
||||
expect(estimateRowSize({ type: 'vip-subheader' })).toBe(24);
|
||||
expect(
|
||||
estimateRowSize({ type: 'vip-subheader', paddingBottom: 4 })
|
||||
).toBe(28);
|
||||
});
|
||||
|
||||
test('returns 26 + paddingBottom for instance-header', () => {
|
||||
expect(estimateRowSize({ type: 'instance-header' })).toBe(26);
|
||||
expect(
|
||||
estimateRowSize({ type: 'instance-header', paddingBottom: 4 })
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
test('returns 52 + paddingBottom for any other type (friend-item)', () => {
|
||||
expect(estimateRowSize({ type: 'friend-item' })).toBe(52);
|
||||
expect(estimateRowSize({ type: 'friend-item', paddingBottom: 6 })).toBe(
|
||||
58
|
||||
);
|
||||
});
|
||||
});
|
||||
159
src/views/Sidebar/__tests__/groupsSidebarUtils.test.js
Normal file
159
src/views/Sidebar/__tests__/groupsSidebarUtils.test.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGroupHeaderRow,
|
||||
buildGroupItemRow,
|
||||
estimateGroupRowSize,
|
||||
getGroupId
|
||||
} from '../groupsSidebarUtils';
|
||||
|
||||
// ─── getGroupId ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getGroupId', () => {
|
||||
test('extracts groupId from first element', () => {
|
||||
const group = [{ group: { groupId: 'grp_abc' } }];
|
||||
expect(getGroupId(group)).toBe('grp_abc');
|
||||
});
|
||||
|
||||
test('returns empty string for empty array', () => {
|
||||
expect(getGroupId([])).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string when group property is missing', () => {
|
||||
expect(getGroupId([{}])).toBe('');
|
||||
expect(getGroupId([{ group: {} }])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildGroupHeaderRow ─────────────────────────────────────────────
|
||||
|
||||
describe('buildGroupHeaderRow', () => {
|
||||
const group = [
|
||||
{ group: { groupId: 'grp_1', name: 'Test Group' } },
|
||||
{ group: { groupId: 'grp_1', name: 'Test Group' } }
|
||||
];
|
||||
|
||||
test('builds header row with correct properties', () => {
|
||||
const cfg = { grp_1: { isCollapsed: false } };
|
||||
const row = buildGroupHeaderRow(group, 0, cfg);
|
||||
expect(row).toEqual({
|
||||
type: 'group-header',
|
||||
key: 'group-header:grp_1',
|
||||
groupId: 'grp_1',
|
||||
label: 'Test Group',
|
||||
count: 2,
|
||||
isCollapsed: false,
|
||||
headerPaddingTop: '0px'
|
||||
});
|
||||
});
|
||||
|
||||
test('sets headerPaddingTop to 10px for non-first groups', () => {
|
||||
const cfg = {};
|
||||
const row = buildGroupHeaderRow(group, 1, cfg);
|
||||
expect(row.headerPaddingTop).toBe('10px');
|
||||
});
|
||||
|
||||
test('reflects collapsed state from config', () => {
|
||||
const cfg = { grp_1: { isCollapsed: true } };
|
||||
const row = buildGroupHeaderRow(group, 0, cfg);
|
||||
expect(row.isCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
test('defaults to not collapsed when cfg entry is missing', () => {
|
||||
const cfg = {};
|
||||
const row = buildGroupHeaderRow(group, 0, cfg);
|
||||
expect(row.isCollapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildGroupItemRow ───────────────────────────────────────────────
|
||||
|
||||
describe('buildGroupItemRow', () => {
|
||||
const ref = {
|
||||
group: { iconUrl: 'https://example.com/icon.png', name: 'My Group' },
|
||||
instance: {
|
||||
id: 'inst_123',
|
||||
ownerId: 'usr_456',
|
||||
userCount: 5,
|
||||
capacity: 16,
|
||||
location: 'wrld_abc:inst_123~private'
|
||||
}
|
||||
};
|
||||
|
||||
test('builds item row with correct properties', () => {
|
||||
const row = buildGroupItemRow(ref, 0, 'grp_1', true);
|
||||
expect(row).toEqual({
|
||||
type: 'group-item',
|
||||
key: 'group-item:grp_1:inst_123',
|
||||
ownerId: 'usr_456',
|
||||
iconUrl: 'https://example.com/icon.png',
|
||||
name: 'My Group',
|
||||
userCount: 5,
|
||||
capacity: 16,
|
||||
location: 'wrld_abc:inst_123~private',
|
||||
isVisible: true
|
||||
});
|
||||
});
|
||||
|
||||
test('uses index as fallback key when instance id is missing', () => {
|
||||
const row = buildGroupItemRow({}, 7, 'grp_1', true);
|
||||
expect(row.key).toBe('group-item:grp_1:7');
|
||||
});
|
||||
|
||||
test('defaults to empty/zero values for missing properties', () => {
|
||||
const row = buildGroupItemRow({}, 0, 'grp_1', true);
|
||||
expect(row.ownerId).toBe('');
|
||||
expect(row.iconUrl).toBe('');
|
||||
expect(row.name).toBe('');
|
||||
expect(row.userCount).toBe(0);
|
||||
expect(row.capacity).toBe(0);
|
||||
expect(row.location).toBe('');
|
||||
});
|
||||
|
||||
test('hides age-gated instances when isAgeGatedVisible is false', () => {
|
||||
const ageGatedRef = { ...ref, ageGate: true };
|
||||
const row = buildGroupItemRow(ageGatedRef, 0, 'grp_1', false);
|
||||
expect(row.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
test('shows age-gated instances when isAgeGatedVisible is true', () => {
|
||||
const ageGatedRef = { ...ref, ageGate: true };
|
||||
const row = buildGroupItemRow(ageGatedRef, 0, 'grp_1', true);
|
||||
expect(row.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('detects age gate from location string', () => {
|
||||
const refWithAgeGateLocation = {
|
||||
...ref,
|
||||
location: 'wrld_abc:inst_123~ageGate'
|
||||
};
|
||||
const row = buildGroupItemRow(
|
||||
refWithAgeGateLocation,
|
||||
0,
|
||||
'grp_1',
|
||||
false
|
||||
);
|
||||
expect(row.isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── estimateGroupRowSize ────────────────────────────────────────────
|
||||
|
||||
describe('estimateGroupRowSize', () => {
|
||||
test('returns 44 for null/undefined', () => {
|
||||
expect(estimateGroupRowSize(null)).toBe(44);
|
||||
expect(estimateGroupRowSize(undefined)).toBe(44);
|
||||
});
|
||||
|
||||
test('returns 30 for group-header', () => {
|
||||
expect(estimateGroupRowSize({ type: 'group-header' })).toBe(30);
|
||||
});
|
||||
|
||||
test('returns 52 for group-item', () => {
|
||||
expect(estimateGroupRowSize({ type: 'group-item' })).toBe(52);
|
||||
});
|
||||
|
||||
test('returns 52 for unknown type', () => {
|
||||
expect(estimateGroupRowSize({ type: 'unknown' })).toBe(52);
|
||||
});
|
||||
});
|
||||
69
src/views/Sidebar/__tests__/sidebarSettingsUtils.test.js
Normal file
69
src/views/Sidebar/__tests__/sidebarSettingsUtils.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeFavoriteGroupsChange,
|
||||
resolveFavoriteGroups
|
||||
} from '../sidebarSettingsUtils';
|
||||
|
||||
// ─── resolveFavoriteGroups ───────────────────────────────────────────
|
||||
|
||||
describe('resolveFavoriteGroups', () => {
|
||||
const allKeys = ['group_1', 'group_2', 'local:MyGroup'];
|
||||
|
||||
test('returns allKeys when stored is empty (= all)', () => {
|
||||
expect(resolveFavoriteGroups([], allKeys)).toEqual(allKeys);
|
||||
});
|
||||
|
||||
test('returns stored value when not empty', () => {
|
||||
const stored = ['group_1'];
|
||||
expect(resolveFavoriteGroups(stored, allKeys)).toEqual(stored);
|
||||
});
|
||||
|
||||
test('returns stored even if it equals allKeys', () => {
|
||||
expect(resolveFavoriteGroups([...allKeys], allKeys)).toEqual(allKeys);
|
||||
});
|
||||
|
||||
test('handles empty allKeys', () => {
|
||||
expect(resolveFavoriteGroups([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeFavoriteGroupsChange ───────────────────────────────────
|
||||
|
||||
describe('normalizeFavoriteGroupsChange', () => {
|
||||
const allKeys = ['group_1', 'group_2', 'local:MyGroup'];
|
||||
|
||||
test('returns [] when value is null', () => {
|
||||
expect(normalizeFavoriteGroupsChange(null, allKeys)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns [] when value is empty array', () => {
|
||||
expect(normalizeFavoriteGroupsChange([], allKeys)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns [] when all groups are selected', () => {
|
||||
expect(normalizeFavoriteGroupsChange([...allKeys], allKeys)).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('returns [] when value is superset of allKeys', () => {
|
||||
expect(
|
||||
normalizeFavoriteGroupsChange([...allKeys, 'extra'], allKeys)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns filter subset when not all selected', () => {
|
||||
const subset = ['group_1'];
|
||||
expect(normalizeFavoriteGroupsChange(subset, allKeys)).toEqual(subset);
|
||||
});
|
||||
|
||||
test('returns filter subset with two items', () => {
|
||||
const subset = ['group_1', 'group_2'];
|
||||
expect(normalizeFavoriteGroupsChange(subset, allKeys)).toEqual(subset);
|
||||
});
|
||||
|
||||
test('treats non-empty value as all-selected when allKeys is empty (vacuous truth)', () => {
|
||||
expect(normalizeFavoriteGroupsChange(['group_1'], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,7 @@
|
||||
useLocationStore,
|
||||
useUserStore
|
||||
} from '../../../stores';
|
||||
import { buildFriendRow, buildInstanceHeaderRow, buildToggleRow, estimateRowSize } from '../friendsSidebarUtils';
|
||||
import { getFriendsSortFunction, isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
||||
import { getFriendsLocations } from '../../../shared/utils/location.js';
|
||||
import { userRequest } from '../../../api';
|
||||
@@ -214,6 +215,10 @@
|
||||
|
||||
const shouldHideSameInstance = computed(() => isSidebarGroupByInstance.value && isHideFriendsInSameInstance.value);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param list
|
||||
*/
|
||||
function excludeSameInstance(list) {
|
||||
if (!shouldHideSameInstance.value) {
|
||||
return list;
|
||||
@@ -323,41 +328,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
const buildToggleRow = ({
|
||||
key,
|
||||
label,
|
||||
count = null,
|
||||
expanded = true,
|
||||
headerPadding = null,
|
||||
paddingBottom = null,
|
||||
onClick = null
|
||||
}) => ({
|
||||
type: 'toggle-header',
|
||||
key,
|
||||
label,
|
||||
count,
|
||||
expanded,
|
||||
headerPadding,
|
||||
paddingBottom,
|
||||
onClick
|
||||
});
|
||||
const buildFriendRow = (friend, key, options = {}) => ({
|
||||
type: 'friend-item',
|
||||
key,
|
||||
friend,
|
||||
isGroupByInstance: options.isGroupByInstance,
|
||||
paddingBottom: options.paddingBottom,
|
||||
itemStyle: options.itemStyle
|
||||
});
|
||||
|
||||
const buildInstanceHeaderRow = (location, count, key) => ({
|
||||
type: 'instance-header',
|
||||
key,
|
||||
location,
|
||||
count,
|
||||
paddingBottom: 4
|
||||
});
|
||||
|
||||
const virtualRows = computed(() => {
|
||||
const rows = [];
|
||||
|
||||
@@ -521,22 +491,6 @@
|
||||
return rows;
|
||||
});
|
||||
|
||||
const estimateRowSize = (row) => {
|
||||
if (!row) {
|
||||
return 44;
|
||||
}
|
||||
if (row.type === 'toggle-header') {
|
||||
return 28 + (row.paddingBottom || 0);
|
||||
}
|
||||
if (row.type === 'vip-subheader') {
|
||||
return 24 + (row.paddingBottom || 0);
|
||||
}
|
||||
if (row.type === 'instance-header') {
|
||||
return 26 + (row.paddingBottom || 0);
|
||||
}
|
||||
return 52 + (row.paddingBottom || 0);
|
||||
};
|
||||
|
||||
const virtualizer = useVirtualizer(
|
||||
computed(() => ({
|
||||
count: virtualRows.value.length,
|
||||
@@ -568,6 +522,9 @@
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function saveFriendsGroupStates() {
|
||||
configRepository.setBool('VRCX_isFriendsGroupMe', isFriendsGroupMe.value);
|
||||
configRepository.setBool('VRCX_isFriendsGroupFavorites', isVIPFriends.value);
|
||||
@@ -576,6 +533,9 @@
|
||||
configRepository.setBool('VRCX_isFriendsGroupOffline', isOfflineFriends.value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function loadFriendsGroupStates() {
|
||||
isFriendsGroupMe.value = await configRepository.getBool('VRCX_isFriendsGroupMe', true);
|
||||
isVIPFriends.value = await configRepository.getBool('VRCX_isFriendsGroupFavorites', true);
|
||||
@@ -588,31 +548,49 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function toggleSwitchGroupByInstanceCollapsed() {
|
||||
isSidebarGroupByInstanceCollapsed.value = !isSidebarGroupByInstanceCollapsed.value;
|
||||
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function toggleFriendsGroupMe() {
|
||||
isFriendsGroupMe.value = !isFriendsGroupMe.value;
|
||||
saveFriendsGroupStates();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function toggleVIPFriends() {
|
||||
isVIPFriends.value = !isVIPFriends.value;
|
||||
saveFriendsGroupStates();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function toggleOnlineFriends() {
|
||||
isOnlineFriends.value = !isOnlineFriends.value;
|
||||
saveFriendsGroupStates();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function toggleActiveFriends() {
|
||||
isActiveFriends.value = !isActiveFriends.value;
|
||||
saveFriendsGroupStates();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function toggleOfflineFriends() {
|
||||
isOfflineFriends.value = !isOfflineFriends.value;
|
||||
saveFriendsGroupStates();
|
||||
@@ -660,12 +638,20 @@
|
||||
return history.slice(0, 10);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function changeStatus(value) {
|
||||
userRequest.saveCurrentUser({ status: value }).then(() => {
|
||||
toast.success('Status updated');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param status
|
||||
*/
|
||||
function setStatusFromHistory(status) {
|
||||
userRequest.saveCurrentUser({ statusDescription: status }).then(() => {
|
||||
toast.success('Status updated');
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual';
|
||||
|
||||
import { buildGroupHeaderRow, buildGroupItemRow, estimateGroupRowSize, getGroupId } from '../groupsSidebarUtils';
|
||||
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
|
||||
import { convertFileUrlToImageUrl } from '../../../shared/utils';
|
||||
|
||||
@@ -98,50 +99,27 @@
|
||||
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
|
||||
});
|
||||
|
||||
const buildGroupHeaderRow = (group, index) => ({
|
||||
type: 'group-header',
|
||||
key: `group-header:${getGroupId(group)}`,
|
||||
groupId: getGroupId(group),
|
||||
label: group[0]?.group?.name ?? '',
|
||||
count: group.length,
|
||||
isCollapsed: Boolean(groupInstancesCfg.value[getGroupId(group)]?.isCollapsed),
|
||||
headerPaddingTop: index === 0 ? '0px' : '10px'
|
||||
});
|
||||
const buildGroupHeaderRowLocal = (group, index) => buildGroupHeaderRow(group, index, groupInstancesCfg.value);
|
||||
|
||||
const buildGroupItemRow = (ref, index, groupId) => ({
|
||||
type: 'group-item',
|
||||
key: `group-item:${groupId}:${ref?.instance?.id ?? index}`,
|
||||
ownerId: ref?.instance?.ownerId ?? '',
|
||||
iconUrl: ref?.group?.iconUrl ?? '',
|
||||
name: ref?.group?.name ?? '',
|
||||
userCount: ref?.instance?.userCount ?? 0,
|
||||
capacity: ref?.instance?.capacity ?? 0,
|
||||
location: ref?.instance?.location ?? '',
|
||||
isVisible: Boolean(isAgeGatedInstancesVisible.value || !(ref?.ageGate || ref?.location?.includes('~ageGate')))
|
||||
});
|
||||
const buildGroupItemRowLocal = (ref, index, groupId) =>
|
||||
buildGroupItemRow(ref, index, groupId, isAgeGatedInstancesVisible.value);
|
||||
|
||||
const virtualRows = computed(() => {
|
||||
const rows = [];
|
||||
groupedGroupInstances.value.forEach((group, index) => {
|
||||
if (!group?.length) return;
|
||||
const groupId = getGroupId(group);
|
||||
rows.push(buildGroupHeaderRow(group, index));
|
||||
rows.push(buildGroupHeaderRowLocal(group, index));
|
||||
if (!groupInstancesCfg.value[groupId]?.isCollapsed) {
|
||||
group.forEach((ref, idx) => {
|
||||
rows.push(buildGroupItemRow(ref, idx, groupId));
|
||||
rows.push(buildGroupItemRowLocal(ref, idx, groupId));
|
||||
});
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
|
||||
const estimateRowSize = (row) => {
|
||||
if (!row) return 44;
|
||||
if (row.type === 'group-header') {
|
||||
return 30;
|
||||
}
|
||||
return 52;
|
||||
};
|
||||
const estimateRowSize = (row) => estimateGroupRowSize(row);
|
||||
|
||||
const virtualizer = useVirtualizer(
|
||||
computed(() => ({
|
||||
@@ -170,18 +148,22 @@
|
||||
transform: `translateY(${item.virtualItem.start}px)`
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url
|
||||
*/
|
||||
function getSmallGroupIconUrl(url) {
|
||||
return convertFileUrlToImageUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function toggleGroupSidebarCollapse(groupId) {
|
||||
groupInstancesCfg.value[groupId].isCollapsed = !groupInstancesCfg.value[groupId].isCollapsed;
|
||||
}
|
||||
|
||||
function getGroupId(group) {
|
||||
return group[0]?.group?.groupId || '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
virtualizer.value?.measure?.();
|
||||
|
||||
88
src/views/Sidebar/friendsSidebarUtils.js
Normal file
88
src/views/Sidebar/friendsSidebarUtils.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.key - Unique key
|
||||
* @param {string} opts.label - Display label
|
||||
* @param {number|null} [opts.count] - Item count
|
||||
* @param {boolean} [opts.expanded] - Whether section is expanded
|
||||
* @param {number|null} [opts.headerPadding] - Top padding in px
|
||||
* @param {number|null} [opts.paddingBottom] - Bottom padding in px
|
||||
* @param {Function|null} [opts.onClick] - Click handler
|
||||
* @returns {object} Row object
|
||||
*/
|
||||
export function buildToggleRow({
|
||||
key,
|
||||
label,
|
||||
count = null,
|
||||
expanded = true,
|
||||
headerPadding = null,
|
||||
paddingBottom = null,
|
||||
onClick = null
|
||||
}) {
|
||||
return {
|
||||
type: 'toggle-header',
|
||||
key,
|
||||
label,
|
||||
count,
|
||||
expanded,
|
||||
headerPadding,
|
||||
paddingBottom,
|
||||
onClick
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} friend - Friend data object
|
||||
* @param {string} key - Unique key
|
||||
* @param {object} [options] - Additional options
|
||||
* @param {boolean} [options.isGroupByInstance] - Whether grouped by instance
|
||||
* @param {number} [options.paddingBottom] - Bottom padding
|
||||
* @param {object} [options.itemStyle] - Additional style
|
||||
* @returns {object} Row object
|
||||
*/
|
||||
export function buildFriendRow(friend, key, options = {}) {
|
||||
return {
|
||||
type: 'friend-item',
|
||||
key,
|
||||
friend,
|
||||
isGroupByInstance: options.isGroupByInstance,
|
||||
paddingBottom: options.paddingBottom,
|
||||
itemStyle: options.itemStyle
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} location - Instance location string
|
||||
* @param {number} count - Number of friends in instance
|
||||
* @param {string} key - Unique key
|
||||
* @returns {object} Row object
|
||||
*/
|
||||
export function buildInstanceHeaderRow(location, count, key) {
|
||||
return {
|
||||
type: 'instance-header',
|
||||
key,
|
||||
location,
|
||||
count,
|
||||
paddingBottom: 4
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate pixel height for a virtual row.
|
||||
* @param {object} row - Row object with type property
|
||||
* @returns {number} Estimated height in pixels
|
||||
*/
|
||||
export function estimateRowSize(row) {
|
||||
if (!row) {
|
||||
return 44;
|
||||
}
|
||||
if (row.type === 'toggle-header') {
|
||||
return 28 + (row.paddingBottom || 0);
|
||||
}
|
||||
if (row.type === 'vip-subheader') {
|
||||
return 24 + (row.paddingBottom || 0);
|
||||
}
|
||||
if (row.type === 'instance-header') {
|
||||
return 26 + (row.paddingBottom || 0);
|
||||
}
|
||||
return 52 + (row.paddingBottom || 0);
|
||||
}
|
||||
62
src/views/Sidebar/groupsSidebarUtils.js
Normal file
62
src/views/Sidebar/groupsSidebarUtils.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @param {Array} group - Array of group instance refs
|
||||
* @returns {string} The groupId, or empty string
|
||||
*/
|
||||
export function getGroupId(group) {
|
||||
return group[0]?.group?.groupId || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} group - Array of group instance refs
|
||||
* @param {number} index - Index of the group in the list
|
||||
* @param {object} cfg - Collapsed state config object
|
||||
* @returns {object} Row object
|
||||
*/
|
||||
export function buildGroupHeaderRow(group, index, cfg) {
|
||||
const groupId = getGroupId(group);
|
||||
return {
|
||||
type: 'group-header',
|
||||
key: `group-header:${groupId}`,
|
||||
groupId,
|
||||
label: group[0]?.group?.name ?? '',
|
||||
count: group.length,
|
||||
isCollapsed: Boolean(cfg[groupId]?.isCollapsed),
|
||||
headerPaddingTop: index === 0 ? '0px' : '10px'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ref - Group instance ref object
|
||||
* @param {number} index - Index within the group
|
||||
* @param {string} groupId - Parent group ID
|
||||
* @param {boolean} isAgeGatedVisible - Whether age-gated instances should be visible
|
||||
* @returns {object} Row object
|
||||
*/
|
||||
export function buildGroupItemRow(ref, index, groupId, isAgeGatedVisible) {
|
||||
return {
|
||||
type: 'group-item',
|
||||
key: `group-item:${groupId}:${ref?.instance?.id ?? index}`,
|
||||
ownerId: ref?.instance?.ownerId ?? '',
|
||||
iconUrl: ref?.group?.iconUrl ?? '',
|
||||
name: ref?.group?.name ?? '',
|
||||
userCount: ref?.instance?.userCount ?? 0,
|
||||
capacity: ref?.instance?.capacity ?? 0,
|
||||
location: ref?.instance?.location ?? '',
|
||||
isVisible: Boolean(
|
||||
isAgeGatedVisible ||
|
||||
!(ref?.ageGate || ref?.location?.includes('~ageGate'))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} row - Row object with type property
|
||||
* @returns {number} Estimated height in pixels
|
||||
*/
|
||||
export function estimateGroupRowSize(row) {
|
||||
if (!row) return 44;
|
||||
if (row.type === 'group-header') {
|
||||
return 30;
|
||||
}
|
||||
return 52;
|
||||
}
|
||||
29
src/views/Sidebar/sidebarSettingsUtils.js
Normal file
29
src/views/Sidebar/sidebarSettingsUtils.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @param {string[]} stored - Stored favorite groups selection
|
||||
* @param {string[]} allKeys - All available group keys
|
||||
* @returns {string[]} Resolved group keys
|
||||
*/
|
||||
export function resolveFavoriteGroups(stored, allKeys) {
|
||||
if (stored.length === 0) {
|
||||
return allKeys;
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]|null} value - New selection value
|
||||
* @param {string[]} allKeys - All available group keys
|
||||
* @returns {string[]} Value to store
|
||||
*/
|
||||
export function normalizeFavoriteGroupsChange(value, allKeys) {
|
||||
if (!value || value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
value.length >= allKeys.length &&
|
||||
allKeys.every((k) => value.includes(k))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -18,11 +18,16 @@ export default defineConfig({
|
||||
include: ['src/**/*.{test,spec}.js'],
|
||||
coverage: {
|
||||
reporter: ['text', 'text-summary'],
|
||||
include: ['src/shared/utils/**/*.js', 'src/components/**/*.vue'],
|
||||
exclude: [
|
||||
'src/shared/utils/**/*.test.js',
|
||||
'src/shared/utils/**/__tests__/**',
|
||||
'src/components/**/__tests__/**'
|
||||
'src/public/**',
|
||||
'src/vr/**',
|
||||
'src/types/**',
|
||||
'src/styles/**',
|
||||
'src/ipc-electron/**',
|
||||
'src/localization/**',
|
||||
'src/lib/**/!(*.test).js',
|
||||
'src/components/ui/**/*.vue',
|
||||
'src/components/ui/**/index.js'
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user