mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 22:46:06 +02:00
add test
This commit is contained in:
@@ -80,6 +80,11 @@ export default defineConfig([
|
|||||||
config: 'flat/recommended'
|
config: 'flat/recommended'
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
ignores: [
|
||||||
|
'**/__tests__/**',
|
||||||
|
'**/*.spec.{js,mjs,cjs,vue}',
|
||||||
|
'**/*.test.{js,mjs,cjs,vue}'
|
||||||
|
],
|
||||||
plugins: { 'pretty-import': prettyImport },
|
plugins: { 'pretty-import': prettyImport },
|
||||||
rules: {
|
rules: {
|
||||||
'pretty-import/separate-type-imports': 'warn',
|
'pretty-import/separate-type-imports': 'warn',
|
||||||
|
|||||||
+44
-22
@@ -35,6 +35,7 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { getGroupName, getWorldName, parseLocation, resolveRegion, translateAccessType } from '../shared/utils';
|
||||||
import {
|
import {
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useGroupStore,
|
useGroupStore,
|
||||||
@@ -42,7 +43,6 @@
|
|||||||
useSearchStore,
|
useSearchStore,
|
||||||
useWorldStore
|
useWorldStore
|
||||||
} from '../stores';
|
} from '../stores';
|
||||||
import { getGroupName, getWorldName, parseLocation } from '../shared/utils';
|
|
||||||
import { Spinner } from './ui/spinner';
|
import { Spinner } from './ui/spinner';
|
||||||
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
||||||
|
|
||||||
@@ -118,6 +118,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function currentInstanceId() {
|
function currentInstanceId() {
|
||||||
if (typeof props.traveling !== 'undefined' && props.location === 'traveling') {
|
if (typeof props.traveling !== 'undefined' && props.location === 'traveling') {
|
||||||
return props.traveling;
|
return props.traveling;
|
||||||
@@ -125,6 +128,9 @@
|
|||||||
return props.location;
|
return props.location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function resetState() {
|
function resetState() {
|
||||||
text.value = '';
|
text.value = '';
|
||||||
region.value = '';
|
region.value = '';
|
||||||
@@ -135,6 +141,9 @@
|
|||||||
instanceName.value = '';
|
instanceName.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function parse() {
|
function parse() {
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
return;
|
return;
|
||||||
@@ -159,6 +168,10 @@
|
|||||||
strict.value = L.strict;
|
strict.value = L.strict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param L
|
||||||
|
*/
|
||||||
function applyInstanceRef(L) {
|
function applyInstanceRef(L) {
|
||||||
const instanceRef = cachedInstances.get(L.tag);
|
const instanceRef = cachedInstances.get(L.tag);
|
||||||
if (typeof instanceRef === 'undefined') {
|
if (typeof instanceRef === 'undefined') {
|
||||||
@@ -173,6 +186,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param L
|
||||||
|
* @param instanceId
|
||||||
|
*/
|
||||||
function updateGroupName(L, instanceId) {
|
function updateGroupName(L, instanceId) {
|
||||||
if (props.grouphint) {
|
if (props.grouphint) {
|
||||||
groupName.value = props.grouphint;
|
groupName.value = props.grouphint;
|
||||||
@@ -189,18 +207,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param L
|
||||||
|
*/
|
||||||
function updateRegion(L) {
|
function updateRegion(L) {
|
||||||
region.value = '';
|
region.value = resolveRegion(L);
|
||||||
if (!L.isOffline && !L.isPrivate && !L.isTraveling) {
|
|
||||||
region.value = L.region;
|
|
||||||
if (!L.region && L.instanceId) {
|
|
||||||
region.value = 'us';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param accessTypeName
|
||||||
|
*/
|
||||||
|
function getAccessTypeLabel(accessTypeName) {
|
||||||
|
return translateAccessType(accessTypeName, t, accessTypeLocaleKeyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param L
|
||||||
|
*/
|
||||||
function setText(L) {
|
function setText(L) {
|
||||||
const accessTypeLabel = translateAccessType(L.accessTypeName);
|
const accessTypeLabel = getAccessTypeLabel(L.accessTypeName);
|
||||||
|
|
||||||
if (L.isOffline) {
|
if (L.isOffline) {
|
||||||
text.value = t('location.offline');
|
text.value = t('location.offline');
|
||||||
@@ -225,7 +253,7 @@
|
|||||||
getWorldName(L.worldId).then((name) => {
|
getWorldName(L.worldId).then((name) => {
|
||||||
if (!isDisposed && name && currentInstanceId() === L.tag) {
|
if (!isDisposed && name && currentInstanceId() === L.tag) {
|
||||||
if (L.instanceId) {
|
if (L.instanceId) {
|
||||||
text.value = `${name} · ${translateAccessType(L.accessTypeName)}`;
|
text.value = `${name} · ${getAccessTypeLabel(L.accessTypeName)}`;
|
||||||
} else {
|
} else {
|
||||||
text.value = name;
|
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() {
|
function handleShowWorldDialog() {
|
||||||
if (props.link) {
|
if (props.link) {
|
||||||
let instanceId = currentInstanceId();
|
let instanceId = currentInstanceId();
|
||||||
@@ -266,6 +285,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function handleShowGroupDialog() {
|
function handleShowGroupDialog() {
|
||||||
let location = currentInstanceId();
|
let location = currentInstanceId();
|
||||||
if (!location) {
|
if (!location) {
|
||||||
|
|||||||
+14
-148
@@ -356,6 +356,7 @@
|
|||||||
useUiStore,
|
useUiStore,
|
||||||
useVRCXUpdaterStore
|
useVRCXUpdaterStore
|
||||||
} from '../stores';
|
} from '../stores';
|
||||||
|
import { getFirstNavRoute, isEntryNotified, normalizeHiddenKeys, sanitizeLayout } from './navMenuUtils';
|
||||||
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
|
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
|
||||||
import { openExternalLink } from '../shared/utils';
|
import { openExternalLink } from '../shared/utils';
|
||||||
|
|
||||||
@@ -418,23 +419,6 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
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 VRCXUpdaterStore = useVRCXUpdaterStore();
|
||||||
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
||||||
@@ -498,7 +482,7 @@
|
|||||||
const currentRouteName = currentRoute?.name;
|
const currentRouteName = currentRoute?.name;
|
||||||
const navKey = currentRoute?.meta?.navKey || currentRouteName;
|
const navKey = currentRoute?.meta?.navKey || currentRouteName;
|
||||||
if (!navKey) {
|
if (!navKey) {
|
||||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
return getFirstNavRouteLocal(navLayout.value) || 'feed';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of navLayout.value) {
|
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] || '-');
|
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
|
||||||
@@ -553,90 +537,8 @@
|
|||||||
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeLayout = (layout, hiddenKeys = []) => {
|
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
|
||||||
const usedKeys = new Set();
|
return sanitizeLayout(layout, hiddenKeys, navDefinitionMap, navDefinitions, t, generateFolderId);
|
||||||
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 themeDisplayName = (themeKey) => {
|
const themeDisplayName = (themeKey) => {
|
||||||
@@ -693,10 +595,10 @@
|
|||||||
|
|
||||||
const customNavDialogVisible = ref(false);
|
const customNavDialogVisible = ref(false);
|
||||||
const navHiddenKeys = ref([]);
|
const navHiddenKeys = ref([]);
|
||||||
const defaultNavLayout = computed(() => sanitizeLayout(createDefaultNavLayout(), []));
|
const defaultNavLayout = computed(() => sanitizeLayoutLocal(createDefaultNavLayout(), []));
|
||||||
|
|
||||||
const saveNavLayout = async (layout, hiddenKeys = []) => {
|
const saveNavLayout = async (layout, hiddenKeys = []) => {
|
||||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
|
||||||
try {
|
try {
|
||||||
await configRepository.setString(
|
await configRepository.setString(
|
||||||
'VRCX_customNavMenuLayoutList',
|
'VRCX_customNavMenuLayoutList',
|
||||||
@@ -715,8 +617,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
|
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
|
||||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
|
||||||
const sanitized = sanitizeLayout(layout, normalizedHiddenKeys);
|
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
|
||||||
navLayout.value = sanitized;
|
navLayout.value = sanitized;
|
||||||
navHiddenKeys.value = normalizedHiddenKeys;
|
navHiddenKeys.value = normalizedHiddenKeys;
|
||||||
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||||
@@ -740,9 +642,9 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load custom nav', error);
|
console.error('Failed to load custom nav', error);
|
||||||
} finally {
|
} finally {
|
||||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData);
|
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData, navDefinitionMap);
|
||||||
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
||||||
const sanitized = sanitizeLayout(fallbackLayout, normalizedHiddenKeys);
|
const sanitized = sanitizeLayoutLocal(fallbackLayout, normalizedHiddenKeys);
|
||||||
navLayout.value = sanitized;
|
navLayout.value = sanitized;
|
||||||
navHiddenKeys.value = normalizedHiddenKeys;
|
navHiddenKeys.value = normalizedHiddenKeys;
|
||||||
if (
|
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) => {
|
const isNavItemNotified = (item) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return false;
|
return false;
|
||||||
@@ -792,7 +674,7 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (item.children?.length) {
|
if (item.children?.length) {
|
||||||
return item.children.some((entry) => isEntryNotified(entry));
|
return item.children.some((entry) => isEntryNotified(entry, notifiedMenus.value));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -828,30 +710,14 @@
|
|||||||
*
|
*
|
||||||
* @param layout
|
* @param layout
|
||||||
*/
|
*/
|
||||||
function getFirstNavRoute(layout) {
|
const getFirstNavRouteLocal = (layout) => getFirstNavRoute(layout, navDefinitionMap);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasNavigatedToInitialRoute = false;
|
let hasNavigatedToInitialRoute = false;
|
||||||
const navigateToFirstNavEntry = () => {
|
const navigateToFirstNavEntry = () => {
|
||||||
if (hasNavigatedToInitialRoute) {
|
if (hasNavigatedToInitialRoute) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstRoute = getFirstNavRoute(navLayout.value);
|
const firstRoute = getFirstNavRouteLocal(navLayout.value);
|
||||||
if (!firstRoute) {
|
if (!firstRoute) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { copyToClipboard } from '../../../shared/utils';
|
import { copyToClipboard, formatCsvField } from '../../../shared/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -77,6 +77,11 @@
|
|||||||
|
|
||||||
const checkedExportBansOptions = ref(['userId', 'displayName', 'roles', 'managerNotes', 'joinedAt', 'bannedAt']);
|
const checkedExportBansOptions = ref(['userId', 'displayName', 'roles', 'managerNotes', 'joinedAt', 'bannedAt']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param label
|
||||||
|
* @param checked
|
||||||
|
*/
|
||||||
function toggleExportOption(label, checked) {
|
function toggleExportOption(label, checked) {
|
||||||
const selection = checkedExportBansOptions.value;
|
const selection = checkedExportBansOptions.value;
|
||||||
const index = selection.indexOf(label);
|
const index = selection.indexOf(label);
|
||||||
@@ -88,6 +93,11 @@
|
|||||||
updateExportContent();
|
updateExportContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param item
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
function getRowValue(item, key) {
|
function getRowValue(item, key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'displayName':
|
case 'displayName':
|
||||||
@@ -101,9 +111,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateExportContent() {
|
function updateExportContent() {
|
||||||
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
|
|
||||||
|
|
||||||
const sortedCheckedOptions = exportBansOptions
|
const sortedCheckedOptions = exportBansOptions
|
||||||
.filter((option) => checkedExportBansOptions.value.includes(option.label))
|
.filter((option) => checkedExportBansOptions.value.includes(option.label))
|
||||||
.map((option) => option.label);
|
.map((option) => option.label);
|
||||||
@@ -111,16 +122,22 @@
|
|||||||
const header = `${sortedCheckedOptions.join(',')}\n`;
|
const header = `${sortedCheckedOptions.join(',')}\n`;
|
||||||
|
|
||||||
const content = props.groupBansModerationTable.data
|
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');
|
.join('\n');
|
||||||
|
|
||||||
exportContent.value = header + content;
|
exportContent.value = header + content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function handleCopyExportContent() {
|
function handleCopyExportContent() {
|
||||||
copyToClipboard(exportContent.value);
|
copyToClipboard(exportContent.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function setIsGroupBansExportDialogVisible() {
|
function setIsGroupBansExportDialogVisible() {
|
||||||
emit('update:isGroupBansExportDialogVisible', false);
|
emit('update:isGroupBansExportDialogVisible', false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { copyToClipboard } from '../../../shared/utils';
|
import { copyToClipboard, formatCsvField } from '../../../shared/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -83,6 +83,11 @@
|
|||||||
'data'
|
'data'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param label
|
||||||
|
* @param checked
|
||||||
|
*/
|
||||||
function toggleGroupLogsExportOption(label, checked) {
|
function toggleGroupLogsExportOption(label, checked) {
|
||||||
const selection = checkedGroupLogsExportLogsOptions.value;
|
const selection = checkedGroupLogsExportLogsOptions.value;
|
||||||
const index = selection.indexOf(label);
|
const index = selection.indexOf(label);
|
||||||
@@ -94,9 +99,10 @@
|
|||||||
updateGroupLogsExportContent();
|
updateGroupLogsExportContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateGroupLogsExportContent() {
|
function updateGroupLogsExportContent() {
|
||||||
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
|
|
||||||
|
|
||||||
const sortedCheckedOptions = checkGroupsLogsExportLogsOptions
|
const sortedCheckedOptions = checkGroupsLogsExportLogsOptions
|
||||||
.filter((option) => checkedGroupLogsExportLogsOptions.value.includes(option.label))
|
.filter((option) => checkedGroupLogsExportLogsOptions.value.includes(option.label))
|
||||||
.map((option) => option.label);
|
.map((option) => option.label);
|
||||||
@@ -106,18 +112,24 @@
|
|||||||
const content = props.groupLogsModerationTable.data
|
const content = props.groupLogsModerationTable.data
|
||||||
.map((item) =>
|
.map((item) =>
|
||||||
sortedCheckedOptions
|
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(',')
|
||||||
)
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
groupLogsExportContent.value = header + content; // Update ref
|
groupLogsExportContent.value = header + content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function handleCopyGroupLogsExportContent() {
|
function handleCopyGroupLogsExportContent() {
|
||||||
copyToClipboard(groupLogsExportContent.value);
|
copyToClipboard(groupLogsExportContent.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function setIsGroupLogsExportDialogVisible() {
|
function setIsGroupLogsExportDialogVisible() {
|
||||||
emit('update:isGroupLogsExportDialogVisible', false);
|
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 { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildLegacyInstanceTag,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
getLaunchURL,
|
getLaunchURL,
|
||||||
hasGroupPermission,
|
hasGroupPermission,
|
||||||
@@ -690,6 +691,10 @@
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param userId
|
||||||
|
*/
|
||||||
function resolveUserDisplayName(userId) {
|
function resolveUserDisplayName(userId) {
|
||||||
if (currentUser.value?.id && currentUser.value.id === userId) {
|
if (currentUser.value?.id && currentUser.value.id === userId) {
|
||||||
return currentUser.value.displayName;
|
return currentUser.value.displayName;
|
||||||
@@ -742,6 +747,10 @@
|
|||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleRoleIdsChange(value) {
|
function handleRoleIdsChange(value) {
|
||||||
const next = Array.isArray(value) ? value.map((v) => String(v ?? '')).filter(Boolean) : [];
|
const next = Array.isArray(value) ? value.map((v) => String(v ?? '')).filter(Boolean) : [];
|
||||||
newInstanceDialog.value.roleIds = next;
|
newInstanceDialog.value.roleIds = next;
|
||||||
@@ -757,10 +766,17 @@
|
|||||||
|
|
||||||
initializeNewInstanceDialog();
|
initializeNewInstanceDialog();
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function closeInviteDialog() {
|
function closeInviteDialog() {
|
||||||
inviteDialog.value.visible = false;
|
inviteDialog.value.visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
function showInviteDialog(tag) {
|
function showInviteDialog(tag) {
|
||||||
if (!isRealInstance(tag)) {
|
if (!isRealInstance(tag)) {
|
||||||
return;
|
return;
|
||||||
@@ -788,11 +804,20 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param location
|
||||||
|
* @param shortName
|
||||||
|
*/
|
||||||
function handleAttachGame(location, shortName) {
|
function handleAttachGame(location, shortName) {
|
||||||
tryOpenInstanceInVrc(location, shortName);
|
tryOpenInstanceInVrc(location, shortName);
|
||||||
closeInviteDialog();
|
closeInviteDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
async function initNewInstanceDialog(tag) {
|
async function initNewInstanceDialog(tag) {
|
||||||
if (!isRealInstance(tag)) {
|
if (!isRealInstance(tag)) {
|
||||||
return;
|
return;
|
||||||
@@ -823,6 +848,9 @@
|
|||||||
updateNewInstanceDialog();
|
updateNewInstanceDialog();
|
||||||
D.visible = true;
|
D.visible = true;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function initializeNewInstanceDialog() {
|
function initializeNewInstanceDialog() {
|
||||||
configRepository
|
configRepository
|
||||||
.getBool('instanceDialogQueueEnabled', true)
|
.getBool('instanceDialogQueueEnabled', true)
|
||||||
@@ -860,6 +888,9 @@
|
|||||||
.getString('instanceDialogDisplayName', '')
|
.getString('instanceDialogDisplayName', '')
|
||||||
.then((value) => (newInstanceDialog.value.displayName = value));
|
.then((value) => (newInstanceDialog.value.displayName = value));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function saveNewInstanceDialog() {
|
function saveNewInstanceDialog() {
|
||||||
const {
|
const {
|
||||||
accessType,
|
accessType,
|
||||||
@@ -883,6 +914,10 @@
|
|||||||
configRepository.setBool('instanceDialogAgeGate', ageGate);
|
configRepository.setBool('instanceDialogAgeGate', ageGate);
|
||||||
configRepository.setString('instanceDialogDisplayName', displayName);
|
configRepository.setString('instanceDialogDisplayName', displayName);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param tabName
|
||||||
|
*/
|
||||||
function newInstanceTabClick(tabName) {
|
function newInstanceTabClick(tabName) {
|
||||||
if (tabName === 'Normal') {
|
if (tabName === 'Normal') {
|
||||||
buildInstance();
|
buildInstance();
|
||||||
@@ -890,6 +925,10 @@
|
|||||||
buildLegacyInstance();
|
buildLegacyInstance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param noChanges
|
||||||
|
*/
|
||||||
function updateNewInstanceDialog(noChanges) {
|
function updateNewInstanceDialog(noChanges) {
|
||||||
const D = newInstanceDialog.value;
|
const D = newInstanceDialog.value;
|
||||||
if (D.instanceId) {
|
if (D.instanceId) {
|
||||||
@@ -905,6 +944,10 @@
|
|||||||
}
|
}
|
||||||
D.url = getLaunchURL(L);
|
D.url = getLaunchURL(L);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param location
|
||||||
|
*/
|
||||||
function selfInvite(location) {
|
function selfInvite(location) {
|
||||||
const L = parseLocation(location);
|
const L = parseLocation(location);
|
||||||
if (!L.isRealInstance) {
|
if (!L.isRealInstance) {
|
||||||
@@ -920,6 +963,9 @@
|
|||||||
return args;
|
return args;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
async function handleCreateNewInstance() {
|
async function handleCreateNewInstance() {
|
||||||
const args = await createNewInstance(newInstanceDialog.value.worldId, newInstanceDialog.value);
|
const args = await createNewInstance(newInstanceDialog.value.worldId, newInstanceDialog.value);
|
||||||
|
|
||||||
@@ -931,6 +977,9 @@
|
|||||||
updateNewInstanceDialog();
|
updateNewInstanceDialog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function buildInstance() {
|
function buildInstance() {
|
||||||
const D = newInstanceDialog.value;
|
const D = newInstanceDialog.value;
|
||||||
D.instanceCreated = false;
|
D.instanceCreated = false;
|
||||||
@@ -965,56 +1014,37 @@
|
|||||||
}
|
}
|
||||||
saveNewInstanceDialog();
|
saveNewInstanceDialog();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function buildLegacyInstance() {
|
function buildLegacyInstance() {
|
||||||
const D = newInstanceDialog.value;
|
const D = newInstanceDialog.value;
|
||||||
D.instanceCreated = false;
|
D.instanceCreated = false;
|
||||||
D.shortName = '';
|
D.shortName = '';
|
||||||
D.secureOrShortName = '';
|
D.secureOrShortName = '';
|
||||||
const tags = [];
|
|
||||||
if (D.instanceName) {
|
if (D.instanceName) {
|
||||||
D.instanceName = D.instanceName.replace(/[^A-Za-z0-9]/g, '');
|
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) {
|
if (!D.userId) {
|
||||||
D.userId = currentUser.value.id;
|
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') {
|
if (D.accessType !== 'invite' && D.accessType !== 'friends') {
|
||||||
D.strict = false;
|
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) {
|
if (D.groupId && D.groupId !== D.lastSelectedGroupId) {
|
||||||
D.roleIds = [];
|
D.roleIds = [];
|
||||||
const ref = cachedGroups.get(D.groupId);
|
const ref = cachedGroups.get(D.groupId);
|
||||||
@@ -1038,10 +1068,13 @@
|
|||||||
D.groupRef = {};
|
D.groupRef = {};
|
||||||
D.lastSelectedGroupId = '';
|
D.lastSelectedGroupId = '';
|
||||||
}
|
}
|
||||||
D.instanceId = tags.join('');
|
|
||||||
updateNewInstanceDialog(false);
|
updateNewInstanceDialog(false);
|
||||||
saveNewInstanceDialog();
|
saveNewInstanceDialog();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param location
|
||||||
|
*/
|
||||||
async function copyInstanceUrl(location) {
|
async function copyInstanceUrl(location) {
|
||||||
const L = parseLocation(location);
|
const L = parseLocation(location);
|
||||||
const args = await instanceRequest.getInstanceShortName({
|
const args = await instanceRequest.getInstanceShortName({
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
import sqliteService from './sqlite.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
function transformKey(key) {
|
function transformKey(key) {
|
||||||
return `config:${String(key).toLowerCase()}`;
|
return `config:${String(key).toLowerCase()}`;
|
||||||
}
|
}
|
||||||
@@ -162,4 +166,4 @@ class ConfigRepository {
|
|||||||
var self = new ConfigRepository();
|
var self = new ConfigRepository();
|
||||||
window.configRepository = self;
|
window.configRepository = self;
|
||||||
|
|
||||||
export { self as default, ConfigRepository };
|
export { self as default, ConfigRepository, transformKey };
|
||||||
|
|||||||
+114
-74
@@ -22,6 +22,64 @@ export let failedGetRequests = new Map();
|
|||||||
|
|
||||||
const t = i18n.global.t;
|
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
|
* @template T
|
||||||
* @param {string} endpoint
|
* @param {string} endpoint
|
||||||
@@ -42,11 +100,7 @@ export function request(endpoint, options) {
|
|||||||
throw `API request blocked while logged out: ${endpoint}`;
|
throw `API request blocked while logged out: ${endpoint}`;
|
||||||
}
|
}
|
||||||
let req;
|
let req;
|
||||||
const init = {
|
const init = buildRequestInit(endpoint, options);
|
||||||
url: `${AppDebug.endpointDomain}/${endpoint}`,
|
|
||||||
method: 'GET',
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
const { params } = init;
|
const { params } = init;
|
||||||
if (init.method === 'GET') {
|
if (init.method === 'GET') {
|
||||||
// don't retry recent 404/403
|
// don't retry recent 404/403
|
||||||
@@ -62,15 +116,6 @@ export function request(endpoint, options) {
|
|||||||
}
|
}
|
||||||
failedGetRequests.delete(endpoint);
|
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
|
// merge requests
|
||||||
req = pendingGetRequests.get(init.url);
|
req = pendingGetRequests.get(init.url);
|
||||||
if (typeof req !== 'undefined') {
|
if (typeof req !== 'undefined') {
|
||||||
@@ -80,18 +125,6 @@ export function request(endpoint, options) {
|
|||||||
}
|
}
|
||||||
pendingGetRequests.delete(init.url);
|
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
|
req = webApiService
|
||||||
.execute(init)
|
.execute(init)
|
||||||
@@ -106,29 +139,24 @@ export function request(endpoint, options) {
|
|||||||
) {
|
) {
|
||||||
throw `API request blocked while logged out: ${endpoint}`;
|
throw `API request blocked while logged out: ${endpoint}`;
|
||||||
}
|
}
|
||||||
if (!response.data) {
|
const parsed = parseResponse(response);
|
||||||
if (AppDebug.debugWebRequests) {
|
if (AppDebug.debugWebRequests) {
|
||||||
console.log(init, 'no data', response);
|
if (!parsed.data) {
|
||||||
|
console.log(init, 'no data', parsed);
|
||||||
|
} else {
|
||||||
|
console.log(init, 'parsed data', parsed.data);
|
||||||
}
|
}
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
try {
|
if (parsed.hasApiError) {
|
||||||
response.data = JSON.parse(response.data);
|
|
||||||
if (AppDebug.debugWebRequests) {
|
|
||||||
console.log(init, 'parsed data', response.data);
|
|
||||||
}
|
|
||||||
if (response.data?.error) {
|
|
||||||
$throw(
|
$throw(
|
||||||
response.data.error.status_code || 0,
|
parsed.data.error.status_code || 0,
|
||||||
response.data.error.message,
|
parsed.data.error.message,
|
||||||
endpoint
|
endpoint
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return response;
|
if (parsed.parseError) {
|
||||||
} catch (e) {
|
console.error('JSON parse error for', endpoint);
|
||||||
console.error(e);
|
if (parsed.status === 200) {
|
||||||
}
|
|
||||||
if (response.status === 200) {
|
|
||||||
$throw(
|
$throw(
|
||||||
0,
|
0,
|
||||||
t('api.error.message.invalid_json_response'),
|
t('api.error.message.invalid_json_response'),
|
||||||
@@ -136,17 +164,18 @@ export function request(endpoint, options) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
response.status === 429 &&
|
parsed.status === 429 &&
|
||||||
init.url.endsWith('/instances/groups')
|
init.url.endsWith('/instances/groups')
|
||||||
) {
|
) {
|
||||||
updateLoopStore.nextGroupInstanceRefresh = 120; // 1min
|
updateLoopStore.nextGroupInstanceRefresh = 120; // 1min
|
||||||
$throw(429, t('api.status_code.429'), endpoint);
|
$throw(429, t('api.status_code.429'), endpoint);
|
||||||
}
|
}
|
||||||
if (response.status === 504 || response.status === 502) {
|
if (parsed.status === 504 || parsed.status === 502) {
|
||||||
// ignore expected API errors
|
// ignore expected API errors
|
||||||
$throw(response.status, response.data || '', endpoint);
|
$throw(parsed.status, parsed.data || '', endpoint);
|
||||||
}
|
}
|
||||||
return response;
|
}
|
||||||
|
return parsed;
|
||||||
})
|
})
|
||||||
.then(({ data, status }) => {
|
.then(({ data, status }) => {
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
@@ -258,6 +287,39 @@ export function request(endpoint, options) {
|
|||||||
return req;
|
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 {number} code
|
||||||
* @param {string|object} [error]
|
* @param {string|object} [error]
|
||||||
@@ -284,33 +346,13 @@ export function $throw(code, error, endpoint) {
|
|||||||
`${t('api.error.message.endpoint')}: "${typeof endpoint === 'string' ? endpoint : JSON.stringify(endpoint)}"`
|
`${t('api.error.message.endpoint')}: "${typeof endpoint === 'string' ? endpoint : JSON.stringify(endpoint)}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let ignoreError = false;
|
const ignoreError = shouldIgnoreError(code, endpoint);
|
||||||
if (
|
if (
|
||||||
(code === 404 || code === -1) &&
|
(code === 403 || code === 404 || code === -1) &&
|
||||||
typeof endpoint === 'string' &&
|
endpoint?.includes('/mutuals/friends')
|
||||||
endpoint.split('/').length === 2 &&
|
|
||||||
(endpoint.startsWith('users/') ||
|
|
||||||
endpoint.startsWith('worlds/') ||
|
|
||||||
endpoint.startsWith('avatars/') ||
|
|
||||||
endpoint.startsWith('groups/') ||
|
|
||||||
endpoint.startsWith('file/'))
|
|
||||||
) {
|
) {
|
||||||
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')}"`;
|
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;
|
|
||||||
}
|
|
||||||
const text = message.map((s) => escapeTag(s)).join('\n');
|
const text = message.map((s) => escapeTag(s)).join('\n');
|
||||||
|
|
||||||
if (text.length && !ignoreError) {
|
if (text.length && !ignoreError) {
|
||||||
@@ -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.
|
* Processes data in bulk by making paginated requests until all data is fetched or limits are reached.
|
||||||
*
|
|
||||||
* @async
|
* @async
|
||||||
* @function processBulk
|
* @function processBulk
|
||||||
* @param {object} options - Configuration options for bulk processing
|
* @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 {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 {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 {number} [options.N] - 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 {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.handle] - Callback function to handle each batch result
|
||||||
* @param {function} [options.done] - Callback function called when processing is complete. Receives boolean indicating success
|
* @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
|
* @returns {Promise<void>} Promise that resolves when bulk processing is complete
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* await processBulk({
|
* await processBulk({
|
||||||
* fn: fetchUsers,
|
* fn: fetchUsers,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const hexToUint8Array = (hexStr) => {
|
|||||||
const uint8ArrayToHex = (arr) =>
|
const uint8ArrayToHex = (arr) =>
|
||||||
arr.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
arr.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
function stdAESKey(key) {
|
function stdAESKey(key) {
|
||||||
const tKey = new TextEncoder().encode(key);
|
const tKey = new TextEncoder().encode(key);
|
||||||
let sk = tKey;
|
let sk = tKey;
|
||||||
@@ -22,6 +26,11 @@ function stdAESKey(key) {
|
|||||||
return sk.slice(0, 32);
|
return sk.slice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param plaintext
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
async function encrypt(plaintext, key) {
|
async function encrypt(plaintext, key) {
|
||||||
let iv = window.crypto.getRandomValues(new Uint8Array(12));
|
let iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
let sharedKey = await window.crypto.subtle.importKey(
|
let sharedKey = await window.crypto.subtle.importKey(
|
||||||
@@ -43,6 +52,11 @@ async function encrypt(plaintext, key) {
|
|||||||
return uint8ArrayToHex(encrypted);
|
return uint8ArrayToHex(encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param ciphertext
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
async function decrypt(ciphertext, key) {
|
async function decrypt(ciphertext, key) {
|
||||||
let text = hexToUint8Array(ciphertext);
|
let text = hexToUint8Array(ciphertext);
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -65,3 +79,5 @@ export default {
|
|||||||
decrypt,
|
decrypt,
|
||||||
encrypt
|
encrypt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { hexToUint8Array, uint8ArrayToHex, stdAESKey };
|
||||||
|
|||||||
@@ -265,6 +265,203 @@ describe('getNotificationMessage', () => {
|
|||||||
);
|
);
|
||||||
expect(result).toEqual({ title: 'External', body: 'ext msg' });
|
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', () => {
|
describe('toNotificationText', () => {
|
||||||
|
|||||||
@@ -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,
|
compareByCreatedAt,
|
||||||
compareByCreatedAtAscending,
|
compareByCreatedAtAscending,
|
||||||
compareByDisplayName,
|
compareByDisplayName,
|
||||||
|
compareById,
|
||||||
|
compareByFriendOrder,
|
||||||
compareByLastActive,
|
compareByLastActive,
|
||||||
|
compareByLastActiveRef,
|
||||||
compareByLastSeen,
|
compareByLastSeen,
|
||||||
compareByLocation,
|
compareByLocation,
|
||||||
compareByLocationAt,
|
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', () => {
|
describe('edge cases and boundary conditions', () => {
|
||||||
test('handles null objects', () => {
|
test('handles null objects', () => {
|
||||||
// compareByName doesn't handle null objects - it will throw
|
// compareByName doesn't handle null objects - it will throw
|
||||||
|
|||||||
@@ -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('Friend Utils', () => {
|
||||||
describe('sortStatus', () => {
|
describe('sortStatus', () => {
|
||||||
test('handles same status', () => {
|
const statuses = ['join me', 'active', 'ask me', 'busy', 'offline'];
|
||||||
expect(sortStatus('active', 'active')).toBe(0);
|
|
||||||
expect(sortStatus('join me', 'join me')).toBe(0);
|
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);
|
expect(sortStatus('unknown', 'active')).toBe(0);
|
||||||
// @ts-ignore
|
expect(sortStatus('active', 'unknown')).toBe(0);
|
||||||
expect(sortStatus(null, 'active')).toBe(0);
|
expect(sortStatus(null, 'active')).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isFriendOnline', () => {
|
describe('isFriendOnline', () => {
|
||||||
test('detects online friends', () => {
|
test('returns true for online friends', () => {
|
||||||
const friend = { state: 'online', ref: { location: 'world' } };
|
expect(
|
||||||
expect(isFriendOnline(friend)).toBe(true);
|
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({})).toBe(false);
|
||||||
expect(isFriendOnline({ state: 'online' })).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('Location Utils', () => {
|
||||||
describe('parseLocation', () => {
|
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']}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('Format Utils', () => {
|
||||||
describe('timeToText', () => {
|
describe('timeToText', () => {
|
||||||
@@ -24,4 +24,64 @@ describe('Format Utils', () => {
|
|||||||
expect(result).toContain('1h');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 './chart';
|
||||||
export * from './common';
|
export * from './common';
|
||||||
export * from './compare';
|
export * from './compare';
|
||||||
|
export * from './csv';
|
||||||
export * from './fileUtils';
|
export * from './fileUtils';
|
||||||
export * from './friend';
|
export * from './friend';
|
||||||
export * from './group';
|
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 { isRealInstance } from './instance.js';
|
||||||
import { useLocationStore } from '../../stores/location.js';
|
import { useLocationStore } from '../../stores/location.js';
|
||||||
|
|
||||||
// Re-export pure parsing functions from the standalone module
|
export {
|
||||||
export { parseLocation, displayLocation } from './locationParser.js';
|
parseLocation,
|
||||||
|
displayLocation,
|
||||||
|
resolveRegion,
|
||||||
|
translateAccessType
|
||||||
|
} from './locationParser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param friendsArr
|
||||||
|
*/
|
||||||
function getFriendsLocations(friendsArr) {
|
function getFriendsLocations(friendsArr) {
|
||||||
const locationStore = useLocationStore();
|
const locationStore = useLocationStore();
|
||||||
// prevent the instance title display as "Traveling".
|
// 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
|
* @param {string} location
|
||||||
@@ -146,4 +141,39 @@ function parseLocation(tag) {
|
|||||||
return ctx;
|
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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { toast } from 'vue-sonner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { formatCsvField, formatCsvRow } from '../../../shared/utils';
|
||||||
import { useAvatarStore, useFavoriteStore } from '../../../stores';
|
import { useAvatarStore, useFavoriteStore } from '../../../stores';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -114,6 +115,11 @@
|
|||||||
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
|
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param label
|
||||||
|
* @param checked
|
||||||
|
*/
|
||||||
function toggleAvatarExportOption(label, checked) {
|
function toggleAvatarExportOption(label, checked) {
|
||||||
const selection = exportSelectedOptions.value;
|
const selection = exportSelectedOptions.value;
|
||||||
const index = selection.indexOf(label);
|
const index = selection.indexOf(label);
|
||||||
@@ -143,6 +149,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function showAvatarExportDialog() {
|
function showAvatarExportDialog() {
|
||||||
avatarExportFavoriteGroup.value = null;
|
avatarExportFavoriteGroup.value = null;
|
||||||
avatarExportLocalFavoriteGroup.value = null;
|
avatarExportLocalFavoriteGroup.value = null;
|
||||||
@@ -151,6 +160,10 @@
|
|||||||
updateAvatarExportDialog();
|
updateAvatarExportDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleAvatarExportFavoriteGroupSelect(value) {
|
function handleAvatarExportFavoriteGroupSelect(value) {
|
||||||
avatarExportFavoriteGroupSelection.value = value;
|
avatarExportFavoriteGroupSelection.value = value;
|
||||||
if (value === AVATAR_EXPORT_ALL_VALUE) {
|
if (value === AVATAR_EXPORT_ALL_VALUE) {
|
||||||
@@ -161,6 +174,10 @@
|
|||||||
selectAvatarExportGroup(group);
|
selectAvatarExportGroup(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleAvatarExportLocalFavoriteGroupSelect(value) {
|
function handleAvatarExportLocalFavoriteGroupSelect(value) {
|
||||||
avatarExportLocalFavoriteGroupSelection.value = value;
|
avatarExportLocalFavoriteGroupSelection.value = value;
|
||||||
if (value === AVATAR_EXPORT_NONE_VALUE) {
|
if (value === AVATAR_EXPORT_NONE_VALUE) {
|
||||||
@@ -169,6 +186,10 @@
|
|||||||
}
|
}
|
||||||
selectAvatarExportLocalGroup(value);
|
selectAvatarExportLocalGroup(value);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
function handleCopyAvatarExportData(event) {
|
function handleCopyAvatarExportData(event) {
|
||||||
if (event.target.tagName === 'TEXTAREA') {
|
if (event.target.tagName === 'TEXTAREA') {
|
||||||
event.target.select();
|
event.target.select();
|
||||||
@@ -183,38 +204,14 @@
|
|||||||
toast.error('Copy failed!');
|
toast.error('Copy failed!');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateAvatarExportDialog() {
|
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
|
const propsForQuery = exportSelectOptions.value
|
||||||
.filter((option) => exportSelectedOptions.value.includes(option.label))
|
.filter((option) => exportSelectedOptions.value.includes(option.label))
|
||||||
.map((option) => option.value);
|
.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(',')];
|
const lines = [exportSelectedOptions.value.join(',')];
|
||||||
|
|
||||||
if (avatarExportFavoriteGroup.value) {
|
if (avatarExportFavoriteGroup.value) {
|
||||||
@@ -222,7 +219,7 @@
|
|||||||
if (!avatarExportFavoriteGroup.value || avatarExportFavoriteGroup.value === group) {
|
if (!avatarExportFavoriteGroup.value || avatarExportFavoriteGroup.value === group) {
|
||||||
favoriteAvatars.value.forEach((ref) => {
|
favoriteAvatars.value.forEach((ref) => {
|
||||||
if (group.key === ref.groupKey) {
|
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) {
|
for (let i = 0; i < favoriteGroup.length; ++i) {
|
||||||
const ref = favoriteGroup[i];
|
const ref = favoriteGroup[i];
|
||||||
lines.push(resText(ref));
|
lines.push(formatCsvRow(ref, propsForQuery));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// export all
|
// export all
|
||||||
favoriteAvatars.value.forEach((ref) => {
|
favoriteAvatars.value.forEach((ref) => {
|
||||||
lines.push(resText(ref.ref));
|
lines.push(formatCsvRow(ref.ref, propsForQuery));
|
||||||
});
|
});
|
||||||
for (let i = 0; i < localAvatarFavoritesList.value.length; ++i) {
|
for (let i = 0; i < localAvatarFavoritesList.value.length; ++i) {
|
||||||
const avatarId = localAvatarFavoritesList.value[i];
|
const avatarId = localAvatarFavoritesList.value[i];
|
||||||
const ref = cachedAvatars.get(avatarId);
|
const ref = cachedAvatars.get(avatarId);
|
||||||
if (typeof ref !== 'undefined') {
|
if (typeof ref !== 'undefined') {
|
||||||
lines.push(resText(ref));
|
lines.push(formatCsvRow(ref, propsForQuery));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avatarExportContent.value = lines.reverse().join('\n');
|
avatarExportContent.value = lines.reverse().join('\n');
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
function selectAvatarExportGroup(group) {
|
function selectAvatarExportGroup(group) {
|
||||||
avatarExportFavoriteGroup.value = group;
|
avatarExportFavoriteGroup.value = group;
|
||||||
avatarExportLocalFavoriteGroup.value = null;
|
avatarExportLocalFavoriteGroup.value = null;
|
||||||
@@ -258,6 +259,10 @@
|
|||||||
avatarExportLocalFavoriteGroupSelection.value = AVATAR_EXPORT_NONE_VALUE;
|
avatarExportLocalFavoriteGroupSelection.value = AVATAR_EXPORT_NONE_VALUE;
|
||||||
updateAvatarExportDialog();
|
updateAvatarExportDialog();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
function selectAvatarExportLocalGroup(group) {
|
function selectAvatarExportLocalGroup(group) {
|
||||||
avatarExportLocalFavoriteGroup.value = group;
|
avatarExportLocalFavoriteGroup.value = group;
|
||||||
avatarExportFavoriteGroup.value = null;
|
avatarExportFavoriteGroup.value = null;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { useFavoriteStore, useUserStore } from '../../../stores';
|
import { useFavoriteStore, useUserStore } from '../../../stores';
|
||||||
|
import { formatCsvField } from '../../../shared/utils';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -111,12 +112,19 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function showFriendExportDialog() {
|
function showFriendExportDialog() {
|
||||||
friendExportFavoriteGroup.value = null;
|
friendExportFavoriteGroup.value = null;
|
||||||
friendExportFavoriteGroupSelection.value = FRIEND_EXPORT_ALL_VALUE;
|
friendExportFavoriteGroupSelection.value = FRIEND_EXPORT_ALL_VALUE;
|
||||||
updateFriendExportDialog();
|
updateFriendExportDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleFriendExportGroupSelect(value) {
|
function handleFriendExportGroupSelect(value) {
|
||||||
friendExportFavoriteGroupSelection.value = value;
|
friendExportFavoriteGroupSelection.value = value;
|
||||||
if (value === FRIEND_EXPORT_ALL_VALUE) {
|
if (value === FRIEND_EXPORT_ALL_VALUE) {
|
||||||
@@ -127,6 +135,10 @@
|
|||||||
selectFriendExportGroup(group);
|
selectFriendExportGroup(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleFriendExportLocalGroupSelect(value) {
|
function handleFriendExportLocalGroupSelect(value) {
|
||||||
friendExportLocalFavoriteGroupSelection.value = value;
|
friendExportLocalFavoriteGroupSelection.value = value;
|
||||||
if (value === FRIEND_EXPORT_NONE_VALUE) {
|
if (value === FRIEND_EXPORT_NONE_VALUE) {
|
||||||
@@ -136,6 +148,10 @@
|
|||||||
selectFriendExportLocalGroup(value);
|
selectFriendExportLocalGroup(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
function handleCopyFriendExportData(event) {
|
function handleCopyFriendExportData(event) {
|
||||||
if (event.target.tagName === 'TEXTAREA') {
|
if (event.target.tagName === 'TEXTAREA') {
|
||||||
event.target.select();
|
event.target.select();
|
||||||
@@ -151,26 +167,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateFriendExportDialog() {
|
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'];
|
const lines = ['UserID,Name'];
|
||||||
|
|
||||||
if (friendExportFavoriteGroup.value) {
|
if (friendExportFavoriteGroup.value) {
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
if (friendExportFavoriteGroup.value === group) {
|
if (friendExportFavoriteGroup.value === group) {
|
||||||
favoriteFriends.value.forEach((ref) => {
|
favoriteFriends.value.forEach((ref) => {
|
||||||
if (group.key === ref.groupKey) {
|
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) => {
|
favoriteGroup.forEach((userId) => {
|
||||||
const ref = cachedUsers.value.get(userId);
|
const ref = cachedUsers.value.get(userId);
|
||||||
if (typeof ref !== 'undefined') {
|
if (typeof ref !== 'undefined') {
|
||||||
lines.push(`${formatter(ref.id)},${formatter(ref.displayName)}`);
|
lines.push(`${formatCsvField(ref.id)},${formatCsvField(ref.displayName)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// export all
|
// export all
|
||||||
favoriteFriends.value.forEach((ref) => {
|
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) {
|
for (let i = 0; i < localFriendFavoritesList.value.length; ++i) {
|
||||||
const userId = localFriendFavoritesList.value[i];
|
const userId = localFriendFavoritesList.value[i];
|
||||||
const ref = cachedUsers.value.get(userId);
|
const ref = cachedUsers.value.get(userId);
|
||||||
if (typeof ref !== 'undefined') {
|
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');
|
friendExportContent.value = lines.reverse().join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
function selectFriendExportGroup(group) {
|
function selectFriendExportGroup(group) {
|
||||||
friendExportFavoriteGroup.value = group;
|
friendExportFavoriteGroup.value = group;
|
||||||
friendExportLocalFavoriteGroup.value = null;
|
friendExportLocalFavoriteGroup.value = null;
|
||||||
@@ -218,6 +222,10 @@
|
|||||||
updateFriendExportDialog();
|
updateFriendExportDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param groupName
|
||||||
|
*/
|
||||||
function selectFriendExportLocalGroup(groupName) {
|
function selectFriendExportLocalGroup(groupName) {
|
||||||
friendExportLocalFavoriteGroup.value = groupName;
|
friendExportLocalFavoriteGroup.value = groupName;
|
||||||
friendExportFavoriteGroup.value = null;
|
friendExportFavoriteGroup.value = null;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { useFavoriteStore, useWorldStore } from '../../../stores';
|
import { useFavoriteStore, useWorldStore } from '../../../stores';
|
||||||
|
import { formatCsvRow } from '../../../shared/utils';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
worldExportDialogVisible: {
|
worldExportDialogVisible: {
|
||||||
@@ -117,6 +118,11 @@
|
|||||||
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
|
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param label
|
||||||
|
* @param checked
|
||||||
|
*/
|
||||||
function toggleWorldExportOption(label, checked) {
|
function toggleWorldExportOption(label, checked) {
|
||||||
const selection = exportSelectedOptions.value;
|
const selection = exportSelectedOptions.value;
|
||||||
const index = selection.indexOf(label);
|
const index = selection.indexOf(label);
|
||||||
@@ -146,6 +152,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function showWorldExportDialog() {
|
function showWorldExportDialog() {
|
||||||
worldExportFavoriteGroup.value = null;
|
worldExportFavoriteGroup.value = null;
|
||||||
worldExportLocalFavoriteGroup.value = null;
|
worldExportLocalFavoriteGroup.value = null;
|
||||||
@@ -154,6 +163,10 @@
|
|||||||
updateWorldExportDialog();
|
updateWorldExportDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleWorldExportGroupSelect(value) {
|
function handleWorldExportGroupSelect(value) {
|
||||||
worldExportFavoriteGroupSelection.value = value;
|
worldExportFavoriteGroupSelection.value = value;
|
||||||
if (value === WORLD_EXPORT_ALL_VALUE) {
|
if (value === WORLD_EXPORT_ALL_VALUE) {
|
||||||
@@ -164,6 +177,10 @@
|
|||||||
selectWorldExportGroup(group);
|
selectWorldExportGroup(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function handleWorldExportLocalGroupSelect(value) {
|
function handleWorldExportLocalGroupSelect(value) {
|
||||||
worldExportLocalFavoriteGroupSelection.value = value;
|
worldExportLocalFavoriteGroupSelection.value = value;
|
||||||
if (value === WORLD_EXPORT_NONE_VALUE) {
|
if (value === WORLD_EXPORT_NONE_VALUE) {
|
||||||
@@ -173,6 +190,10 @@
|
|||||||
selectWorldExportLocalGroup(value);
|
selectWorldExportLocalGroup(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
function handleCopyWorldExportData(event) {
|
function handleCopyWorldExportData(event) {
|
||||||
if (event.target.tagName === 'TEXTAREA') {
|
if (event.target.tagName === 'TEXTAREA') {
|
||||||
event.target.select();
|
event.target.select();
|
||||||
@@ -188,26 +209,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function updateWorldExportDialog() {
|
function updateWorldExportDialog() {
|
||||||
const formatter = function (str) {
|
|
||||||
if (/[\x00-\x1f,"]/.test(str) === true) {
|
|
||||||
return `"${str.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
const propsForQuery = exportSelectOptions.value
|
const propsForQuery = exportSelectOptions.value
|
||||||
.filter((option) => exportSelectedOptions.value.includes(option.label))
|
.filter((option) => exportSelectedOptions.value.includes(option.label))
|
||||||
.map((option) => option.value);
|
.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(',')];
|
const lines = [exportSelectedOptions.value.join(',')];
|
||||||
|
|
||||||
if (worldExportFavoriteGroup.value) {
|
if (worldExportFavoriteGroup.value) {
|
||||||
@@ -215,7 +224,7 @@
|
|||||||
if (worldExportFavoriteGroup.value === group) {
|
if (worldExportFavoriteGroup.value === group) {
|
||||||
favoriteWorlds.value.forEach((ref) => {
|
favoriteWorlds.value.forEach((ref) => {
|
||||||
if (group.key === ref.groupKey) {
|
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) {
|
for (let i = 0; i < favoriteGroup.length; ++i) {
|
||||||
const ref = favoriteGroup[i];
|
const ref = favoriteGroup[i];
|
||||||
lines.push(resText(ref));
|
lines.push(formatCsvRow(ref, propsForQuery));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// export all
|
// export all
|
||||||
favoriteWorlds.value.forEach((ref) => {
|
favoriteWorlds.value.forEach((ref) => {
|
||||||
lines.push(resText(ref.ref));
|
lines.push(formatCsvRow(ref.ref, propsForQuery));
|
||||||
});
|
});
|
||||||
for (let i = 0; i < localWorldFavoritesList.length; ++i) {
|
for (let i = 0; i < localWorldFavoritesList.length; ++i) {
|
||||||
const worldId = localWorldFavoritesList[i];
|
const worldId = localWorldFavoritesList[i];
|
||||||
const ref = cachedWorlds.get(worldId);
|
const ref = cachedWorlds.get(worldId);
|
||||||
if (typeof ref !== 'undefined') {
|
if (typeof ref !== 'undefined') {
|
||||||
lines.push(resText(ref));
|
lines.push(formatCsvRow(ref, propsForQuery));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
worldExportContent.value = lines.reverse().join('\n');
|
worldExportContent.value = lines.reverse().join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
function selectWorldExportGroup(group) {
|
function selectWorldExportGroup(group) {
|
||||||
worldExportFavoriteGroup.value = group;
|
worldExportFavoriteGroup.value = group;
|
||||||
worldExportLocalFavoriteGroup.value = null;
|
worldExportLocalFavoriteGroup.value = null;
|
||||||
@@ -253,6 +266,10 @@
|
|||||||
updateWorldExportDialog();
|
updateWorldExportDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
function selectWorldExportLocalGroup(group) {
|
function selectWorldExportLocalGroup(group) {
|
||||||
worldExportLocalFavoriteGroup.value = group;
|
worldExportLocalFavoriteGroup.value = group;
|
||||||
worldExportFavoriteGroup.value = null;
|
worldExportFavoriteGroup.value = null;
|
||||||
|
|||||||
@@ -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,
|
useGroupStore,
|
||||||
useNotificationStore
|
useNotificationStore
|
||||||
} from '../../stores';
|
} from '../../stores';
|
||||||
|
import { normalizeFavoriteGroupsChange, resolveFavoriteGroups } from './sidebarSettingsUtils';
|
||||||
import { useGlobalSearchStore } from '../../stores/globalSearch';
|
import { useGlobalSearchStore } from '../../stores/globalSearch';
|
||||||
|
|
||||||
import FriendsSidebar from './components/FriendsSidebar.vue';
|
import FriendsSidebar from './components/FriendsSidebar.vue';
|
||||||
@@ -317,30 +318,16 @@
|
|||||||
return keys;
|
return keys;
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedSidebarFavoriteGroups = computed(() => {
|
const resolvedSidebarFavoriteGroups = computed(() =>
|
||||||
if (sidebarFavoriteGroups.value.length === 0) {
|
resolveFavoriteGroups(sidebarFavoriteGroups.value, allFavoriteGroupKeys.value)
|
||||||
return allFavoriteGroupKeys.value;
|
);
|
||||||
}
|
|
||||||
return sidebarFavoriteGroups.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
function handleFavoriteGroupsChange(value) {
|
function handleFavoriteGroupsChange(value) {
|
||||||
if (!value || value.length === 0) {
|
setSidebarFavoriteGroups(normalizeFavoriteGroupsChange(value, allFavoriteGroupKeys.value));
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedFavGroupLabel = computed(() => {
|
const selectedFavGroupLabel = computed(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
useLocationStore,
|
||||||
useUserStore
|
useUserStore
|
||||||
} from '../../../stores';
|
} from '../../../stores';
|
||||||
|
import { buildFriendRow, buildInstanceHeaderRow, buildToggleRow, estimateRowSize } from '../friendsSidebarUtils';
|
||||||
import { getFriendsSortFunction, isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
import { getFriendsSortFunction, isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
||||||
import { getFriendsLocations } from '../../../shared/utils/location.js';
|
import { getFriendsLocations } from '../../../shared/utils/location.js';
|
||||||
import { userRequest } from '../../../api';
|
import { userRequest } from '../../../api';
|
||||||
@@ -214,6 +215,10 @@
|
|||||||
|
|
||||||
const shouldHideSameInstance = computed(() => isSidebarGroupByInstance.value && isHideFriendsInSameInstance.value);
|
const shouldHideSameInstance = computed(() => isSidebarGroupByInstance.value && isHideFriendsInSameInstance.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param list
|
||||||
|
*/
|
||||||
function excludeSameInstance(list) {
|
function excludeSameInstance(list) {
|
||||||
if (!shouldHideSameInstance.value) {
|
if (!shouldHideSameInstance.value) {
|
||||||
return list;
|
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 virtualRows = computed(() => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
|
||||||
@@ -521,22 +491,6 @@
|
|||||||
return rows;
|
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(
|
const virtualizer = useVirtualizer(
|
||||||
computed(() => ({
|
computed(() => ({
|
||||||
count: virtualRows.value.length,
|
count: virtualRows.value.length,
|
||||||
@@ -568,6 +522,9 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function saveFriendsGroupStates() {
|
function saveFriendsGroupStates() {
|
||||||
configRepository.setBool('VRCX_isFriendsGroupMe', isFriendsGroupMe.value);
|
configRepository.setBool('VRCX_isFriendsGroupMe', isFriendsGroupMe.value);
|
||||||
configRepository.setBool('VRCX_isFriendsGroupFavorites', isVIPFriends.value);
|
configRepository.setBool('VRCX_isFriendsGroupFavorites', isVIPFriends.value);
|
||||||
@@ -576,6 +533,9 @@
|
|||||||
configRepository.setBool('VRCX_isFriendsGroupOffline', isOfflineFriends.value);
|
configRepository.setBool('VRCX_isFriendsGroupOffline', isOfflineFriends.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
async function loadFriendsGroupStates() {
|
async function loadFriendsGroupStates() {
|
||||||
isFriendsGroupMe.value = await configRepository.getBool('VRCX_isFriendsGroupMe', true);
|
isFriendsGroupMe.value = await configRepository.getBool('VRCX_isFriendsGroupMe', true);
|
||||||
isVIPFriends.value = await configRepository.getBool('VRCX_isFriendsGroupFavorites', true);
|
isVIPFriends.value = await configRepository.getBool('VRCX_isFriendsGroupFavorites', true);
|
||||||
@@ -588,31 +548,49 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function toggleSwitchGroupByInstanceCollapsed() {
|
function toggleSwitchGroupByInstanceCollapsed() {
|
||||||
isSidebarGroupByInstanceCollapsed.value = !isSidebarGroupByInstanceCollapsed.value;
|
isSidebarGroupByInstanceCollapsed.value = !isSidebarGroupByInstanceCollapsed.value;
|
||||||
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
|
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function toggleFriendsGroupMe() {
|
function toggleFriendsGroupMe() {
|
||||||
isFriendsGroupMe.value = !isFriendsGroupMe.value;
|
isFriendsGroupMe.value = !isFriendsGroupMe.value;
|
||||||
saveFriendsGroupStates();
|
saveFriendsGroupStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function toggleVIPFriends() {
|
function toggleVIPFriends() {
|
||||||
isVIPFriends.value = !isVIPFriends.value;
|
isVIPFriends.value = !isVIPFriends.value;
|
||||||
saveFriendsGroupStates();
|
saveFriendsGroupStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function toggleOnlineFriends() {
|
function toggleOnlineFriends() {
|
||||||
isOnlineFriends.value = !isOnlineFriends.value;
|
isOnlineFriends.value = !isOnlineFriends.value;
|
||||||
saveFriendsGroupStates();
|
saveFriendsGroupStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function toggleActiveFriends() {
|
function toggleActiveFriends() {
|
||||||
isActiveFriends.value = !isActiveFriends.value;
|
isActiveFriends.value = !isActiveFriends.value;
|
||||||
saveFriendsGroupStates();
|
saveFriendsGroupStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function toggleOfflineFriends() {
|
function toggleOfflineFriends() {
|
||||||
isOfflineFriends.value = !isOfflineFriends.value;
|
isOfflineFriends.value = !isOfflineFriends.value;
|
||||||
saveFriendsGroupStates();
|
saveFriendsGroupStates();
|
||||||
@@ -660,12 +638,20 @@
|
|||||||
return history.slice(0, 10);
|
return history.slice(0, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
function changeStatus(value) {
|
function changeStatus(value) {
|
||||||
userRequest.saveCurrentUser({ status: value }).then(() => {
|
userRequest.saveCurrentUser({ status: value }).then(() => {
|
||||||
toast.success('Status updated');
|
toast.success('Status updated');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param status
|
||||||
|
*/
|
||||||
function setStatusFromHistory(status) {
|
function setStatusFromHistory(status) {
|
||||||
userRequest.saveCurrentUser({ statusDescription: status }).then(() => {
|
userRequest.saveCurrentUser({ statusDescription: status }).then(() => {
|
||||||
toast.success('Status updated');
|
toast.success('Status updated');
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useVirtualizer } from '@tanstack/vue-virtual';
|
import { useVirtualizer } from '@tanstack/vue-virtual';
|
||||||
|
|
||||||
|
import { buildGroupHeaderRow, buildGroupItemRow, estimateGroupRowSize, getGroupId } from '../groupsSidebarUtils';
|
||||||
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
|
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
|
||||||
import { convertFileUrlToImageUrl } from '../../../shared/utils';
|
import { convertFileUrlToImageUrl } from '../../../shared/utils';
|
||||||
|
|
||||||
@@ -98,50 +99,27 @@
|
|||||||
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
|
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildGroupHeaderRow = (group, index) => ({
|
const buildGroupHeaderRowLocal = (group, index) => buildGroupHeaderRow(group, index, groupInstancesCfg.value);
|
||||||
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 buildGroupItemRow = (ref, index, groupId) => ({
|
const buildGroupItemRowLocal = (ref, index, groupId) =>
|
||||||
type: 'group-item',
|
buildGroupItemRow(ref, index, groupId, isAgeGatedInstancesVisible.value);
|
||||||
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 virtualRows = computed(() => {
|
const virtualRows = computed(() => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
groupedGroupInstances.value.forEach((group, index) => {
|
groupedGroupInstances.value.forEach((group, index) => {
|
||||||
if (!group?.length) return;
|
if (!group?.length) return;
|
||||||
const groupId = getGroupId(group);
|
const groupId = getGroupId(group);
|
||||||
rows.push(buildGroupHeaderRow(group, index));
|
rows.push(buildGroupHeaderRowLocal(group, index));
|
||||||
if (!groupInstancesCfg.value[groupId]?.isCollapsed) {
|
if (!groupInstancesCfg.value[groupId]?.isCollapsed) {
|
||||||
group.forEach((ref, idx) => {
|
group.forEach((ref, idx) => {
|
||||||
rows.push(buildGroupItemRow(ref, idx, groupId));
|
rows.push(buildGroupItemRowLocal(ref, idx, groupId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
|
|
||||||
const estimateRowSize = (row) => {
|
const estimateRowSize = (row) => estimateGroupRowSize(row);
|
||||||
if (!row) return 44;
|
|
||||||
if (row.type === 'group-header') {
|
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
return 52;
|
|
||||||
};
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer(
|
const virtualizer = useVirtualizer(
|
||||||
computed(() => ({
|
computed(() => ({
|
||||||
@@ -170,18 +148,22 @@
|
|||||||
transform: `translateY(${item.virtualItem.start}px)`
|
transform: `translateY(${item.virtualItem.start}px)`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param url
|
||||||
|
*/
|
||||||
function getSmallGroupIconUrl(url) {
|
function getSmallGroupIconUrl(url) {
|
||||||
return convertFileUrlToImageUrl(url);
|
return convertFileUrlToImageUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
*/
|
||||||
function toggleGroupSidebarCollapse(groupId) {
|
function toggleGroupSidebarCollapse(groupId) {
|
||||||
groupInstancesCfg.value[groupId].isCollapsed = !groupInstancesCfg.value[groupId].isCollapsed;
|
groupInstancesCfg.value[groupId].isCollapsed = !groupInstancesCfg.value[groupId].isCollapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupId(group) {
|
|
||||||
return group[0]?.group?.groupId || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
virtualizer.value?.measure?.();
|
virtualizer.value?.measure?.();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+9
-4
@@ -18,11 +18,16 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.{test,spec}.js'],
|
include: ['src/**/*.{test,spec}.js'],
|
||||||
coverage: {
|
coverage: {
|
||||||
reporter: ['text', 'text-summary'],
|
reporter: ['text', 'text-summary'],
|
||||||
include: ['src/shared/utils/**/*.js', 'src/components/**/*.vue'],
|
|
||||||
exclude: [
|
exclude: [
|
||||||
'src/shared/utils/**/*.test.js',
|
'src/public/**',
|
||||||
'src/shared/utils/**/__tests__/**',
|
'src/vr/**',
|
||||||
'src/components/**/__tests__/**'
|
'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