mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 14:53:50 +02:00
imrove friend list search responsiveness and stats refresh logic
This commit is contained in:
@@ -473,6 +473,9 @@ const gameLog = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getAllUserStats(userIds, displayNames) {
|
async getAllUserStats(userIds, displayNames) {
|
||||||
|
if (!userIds.length && !displayNames.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
var data = [];
|
var data = [];
|
||||||
// this makes me most sad
|
// this makes me most sad
|
||||||
var userIdsString = '';
|
var userIdsString = '';
|
||||||
@@ -485,6 +488,13 @@ const gameLog = {
|
|||||||
displayNamesString += `'${displayName.replaceAll("'", "''")}', `;
|
displayNamesString += `'${displayName.replaceAll("'", "''")}', `;
|
||||||
}
|
}
|
||||||
displayNamesString = displayNamesString.slice(0, -2);
|
displayNamesString = displayNamesString.slice(0, -2);
|
||||||
|
var whereClauses = [];
|
||||||
|
if (userIdsString) {
|
||||||
|
whereClauses.push(`g.user_id IN (${userIdsString})`);
|
||||||
|
}
|
||||||
|
if (displayNamesString) {
|
||||||
|
whereClauses.push(`g.display_name IN (${displayNamesString})`);
|
||||||
|
}
|
||||||
|
|
||||||
await sqliteService.execute(
|
await sqliteService.execute(
|
||||||
(dbRow) => {
|
(dbRow) => {
|
||||||
@@ -507,8 +517,7 @@ const gameLog = {
|
|||||||
FROM
|
FROM
|
||||||
gamelog_join_leave g
|
gamelog_join_leave g
|
||||||
WHERE
|
WHERE
|
||||||
g.user_id IN (${userIdsString})
|
${whereClauses.join('\n OR ')}
|
||||||
OR g.display_name IN (${displayNamesString})
|
|
||||||
GROUP BY
|
GROUP BY
|
||||||
g.user_id,
|
g.user_id,
|
||||||
g.display_name
|
g.display_name
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
const sortedFriends = shallowRef([]);
|
const sortedFriends = shallowRef([]);
|
||||||
let sortedFriendsBatchDepth = 0;
|
let sortedFriendsBatchDepth = 0;
|
||||||
let pendingSortedFriendsRebuild = false;
|
let pendingSortedFriendsRebuild = false;
|
||||||
|
let allUserStatsRequestId = 0;
|
||||||
|
let allUserMutualCountRequestId = 0;
|
||||||
|
|
||||||
const derivedDebugCounters = reactive({
|
const derivedDebugCounters = reactive({
|
||||||
allFavoriteFriendIds: 0,
|
allFavoriteFriendIds: 0,
|
||||||
@@ -820,8 +822,16 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
displayNames.push(ctx.ref.displayName);
|
displayNames.push(ctx.ref.displayName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!userIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++allUserStatsRequestId;
|
||||||
|
|
||||||
const data = await database.getAllUserStats(userIds, displayNames);
|
const data = await database.getAllUserStats(userIds, displayNames);
|
||||||
|
if (requestId !== allUserStatsRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dataByDisplayName = new Map();
|
const dataByDisplayName = new Map();
|
||||||
const friendsByDisplayName = new Map();
|
const friendsByDisplayName = new Map();
|
||||||
@@ -885,7 +895,14 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
async function getAllUserMutualCount() {
|
async function getAllUserMutualCount() {
|
||||||
|
if (!friends.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = ++allUserMutualCountRequestId;
|
||||||
const mutualCountMap = await database.getMutualCountForAllUsers();
|
const mutualCountMap = await database.getMutualCountForAllUsers();
|
||||||
|
if (requestId !== allUserMutualCountRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
runInSortedFriendsBatch(() => {
|
runInSortedFriendsBatch(() => {
|
||||||
for (const [userId, mutualCount] of mutualCountMap.entries()) {
|
for (const [userId, mutualCount] of mutualCountMap.entries()) {
|
||||||
const ref = friends.get(userId);
|
const ref = friends.get(userId);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
:placeholder="t('view.friend_list.search_placeholder')"
|
:placeholder="t('view.friend_list.search_placeholder')"
|
||||||
clearable
|
clearable
|
||||||
class="w-[250px]"
|
class="w-[250px]"
|
||||||
|
@input="scheduleFriendsListSearchChange"
|
||||||
@change="friendsListSearchChange" />
|
@change="friendsListSearchChange" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { InputGroupField } from '@/components/ui/input-group';
|
import { InputGroupField } from '@/components/ui/input-group';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@@ -185,6 +186,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const friendsListRef = ref(null);
|
const friendsListRef = ref(null);
|
||||||
|
const friendSearchCache = new Map();
|
||||||
|
const FRIEND_LIST_SEARCH_DEBOUNCE_MS = 150;
|
||||||
|
const FRIEND_STATS_REFRESH_INTERVAL_MS = 30000;
|
||||||
|
let friendsListSearchTimer = 0;
|
||||||
|
let friendStatsRefreshInFlight = null;
|
||||||
|
let lastFriendStatsRefreshAt = 0;
|
||||||
|
let lastFriendStatsRefreshKey = '';
|
||||||
|
|
||||||
const friendsListColumns = computed(() =>
|
const friendsListColumns = computed(() =>
|
||||||
createColumns({
|
createColumns({
|
||||||
@@ -257,7 +265,7 @@
|
|||||||
() => route.path,
|
() => route.path,
|
||||||
() => {
|
() => {
|
||||||
refreshFriendStats();
|
refreshFriendStats();
|
||||||
nextTick(() => friendsListSearchChange());
|
nextTick(() => applyFriendsListSearchChange());
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -265,23 +273,116 @@
|
|||||||
watch(
|
watch(
|
||||||
() => friends.value.size,
|
() => friends.value.size,
|
||||||
() => {
|
() => {
|
||||||
refreshFriendStats();
|
friendSearchCache.clear();
|
||||||
friendsListSearchChange();
|
refreshFriendStats({ force: true });
|
||||||
|
applyFriendsListSearchChange();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function refreshFriendStats() {
|
onBeforeUnmount(() => {
|
||||||
getAllUserStats();
|
if (friendsListSearchTimer) {
|
||||||
getAllUserMutualCount();
|
clearTimeout(friendsListSearchTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getFriendStatsRefreshKey() {
|
||||||
|
return Array.from(friends.value.keys()).sort().join('\u0000');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshFriendStats({ force = false } = {}) {
|
||||||
|
const friendStatsRefreshKey = getFriendStatsRefreshKey();
|
||||||
|
if (!friendStatsRefreshKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const isStillFresh =
|
||||||
|
friendStatsRefreshKey === lastFriendStatsRefreshKey &&
|
||||||
|
now - lastFriendStatsRefreshAt < FRIEND_STATS_REFRESH_INTERVAL_MS;
|
||||||
|
if (!force && (friendStatsRefreshInFlight || isStillFresh)) {
|
||||||
|
return friendStatsRefreshInFlight;
|
||||||
|
}
|
||||||
|
friendStatsRefreshInFlight = Promise.allSettled([
|
||||||
|
getAllUserStats(),
|
||||||
|
getAllUserMutualCount()
|
||||||
|
]).then((results) => {
|
||||||
|
if (results.every((result) => result.status === 'fulfilled')) {
|
||||||
|
lastFriendStatsRefreshAt = Date.now();
|
||||||
|
lastFriendStatsRefreshKey = friendStatsRefreshKey;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}).finally(() => {
|
||||||
|
friendStatsRefreshInFlight = null;
|
||||||
|
});
|
||||||
|
return friendStatsRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function scheduleFriendsListSearchChange() {
|
||||||
|
if (friendsListSearchTimer) {
|
||||||
|
clearTimeout(friendsListSearchTimer);
|
||||||
|
}
|
||||||
|
friendsListSearchTimer = setTimeout(() => {
|
||||||
|
friendsListSearchTimer = 0;
|
||||||
|
applyFriendsListSearchChange();
|
||||||
|
}, FRIEND_LIST_SEARCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function friendsListSearchChange() {
|
function friendsListSearchChange() {
|
||||||
|
if (friendsListSearchTimer) {
|
||||||
|
clearTimeout(friendsListSearchTimer);
|
||||||
|
friendsListSearchTimer = 0;
|
||||||
|
}
|
||||||
|
applyFriendsListSearchChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} ctx
|
||||||
|
* @returns {object | null}
|
||||||
|
*/
|
||||||
|
function getFriendSearchEntry(ctx) {
|
||||||
|
if (!ctx?.ref?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const signature = [
|
||||||
|
ctx.memo ?? '',
|
||||||
|
ctx.ref.displayName ?? '',
|
||||||
|
ctx.ref.note ?? '',
|
||||||
|
ctx.ref.bio ?? '',
|
||||||
|
ctx.ref.statusDescription ?? '',
|
||||||
|
ctx.ref.$trustLevel ?? ''
|
||||||
|
].join('\u0000');
|
||||||
|
const cached = friendSearchCache.get(ctx.id);
|
||||||
|
if (cached?.signature === signature) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
signature,
|
||||||
|
bio: ctx.ref.bio ?? '',
|
||||||
|
displayName: ctx.ref.displayName ?? '',
|
||||||
|
memo: ctx.memo ?? '',
|
||||||
|
normalizedDisplayName: removeConfusables(ctx.ref.displayName ?? ''),
|
||||||
|
note: ctx.ref.note ?? '',
|
||||||
|
rank: String(ctx.ref.$trustLevel ?? '').toUpperCase(),
|
||||||
|
status: ctx.ref.statusDescription ?? ''
|
||||||
|
};
|
||||||
|
friendSearchCache.set(ctx.id, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function applyFriendsListSearchChange() {
|
||||||
friendsListLoading.value = true;
|
friendsListLoading.value = true;
|
||||||
let query = '';
|
let query = '';
|
||||||
let cleanedQuery = '';
|
let cleanedQuery = '';
|
||||||
|
let upperQuery = '';
|
||||||
friendsListDisplayData.value = [];
|
friendsListDisplayData.value = [];
|
||||||
let filters = friendsListSearchFilters.value.length
|
let filters = friendsListSearchFilters.value.length
|
||||||
? [...friendsListSearchFilters.value]
|
? [...friendsListSearchFilters.value]
|
||||||
@@ -290,31 +391,34 @@
|
|||||||
if (friendsListSearch.value) {
|
if (friendsListSearch.value) {
|
||||||
query = friendsListSearch.value;
|
query = friendsListSearch.value;
|
||||||
cleanedQuery = removeWhitespace(query);
|
cleanedQuery = removeWhitespace(query);
|
||||||
|
upperQuery = query.toUpperCase();
|
||||||
}
|
}
|
||||||
for (const ctx of friends.value.values()) {
|
for (const ctx of friends.value.values()) {
|
||||||
if (!ctx.ref) continue;
|
if (!ctx.ref) continue;
|
||||||
if (friendsListSearchFilterVIP.value && !allFavoriteFriendIds.value.has(ctx.id)) continue;
|
if (friendsListSearchFilterVIP.value && !allFavoriteFriendIds.value.has(ctx.id)) continue;
|
||||||
if (query) {
|
if (query) {
|
||||||
let match = false;
|
let match = false;
|
||||||
if (!match && filters.includes('Display Name') && ctx.ref.displayName) {
|
const searchEntry = getFriendSearchEntry(ctx);
|
||||||
|
if (!searchEntry) continue;
|
||||||
|
if (!match && filters.includes('Display Name') && searchEntry.displayName) {
|
||||||
match =
|
match =
|
||||||
localeIncludes(ctx.ref.displayName, cleanedQuery, stringComparer.value) ||
|
localeIncludes(searchEntry.displayName, cleanedQuery, stringComparer.value) ||
|
||||||
localeIncludes(removeConfusables(ctx.ref.displayName), cleanedQuery, stringComparer.value);
|
localeIncludes(searchEntry.normalizedDisplayName, cleanedQuery, stringComparer.value);
|
||||||
}
|
}
|
||||||
if (!match && filters.includes('Memo') && ctx.memo) {
|
if (!match && filters.includes('Memo') && searchEntry.memo) {
|
||||||
match = localeIncludes(ctx.memo, query, stringComparer.value);
|
match = localeIncludes(searchEntry.memo, query, stringComparer.value);
|
||||||
}
|
}
|
||||||
if (!match && filters.includes('Note') && ctx.ref.note) {
|
if (!match && filters.includes('Note') && searchEntry.note) {
|
||||||
match = localeIncludes(ctx.ref.note, query, stringComparer.value);
|
match = localeIncludes(searchEntry.note, query, stringComparer.value);
|
||||||
}
|
}
|
||||||
if (!match && filters.includes('Bio') && ctx.ref.bio) {
|
if (!match && filters.includes('Bio') && searchEntry.bio) {
|
||||||
match = localeIncludes(ctx.ref.bio, query, stringComparer.value);
|
match = localeIncludes(searchEntry.bio, query, stringComparer.value);
|
||||||
}
|
}
|
||||||
if (!match && filters.includes('Status') && ctx.ref.statusDescription) {
|
if (!match && filters.includes('Status') && searchEntry.status) {
|
||||||
match = localeIncludes(ctx.ref.statusDescription, query, stringComparer.value);
|
match = localeIncludes(searchEntry.status, query, stringComparer.value);
|
||||||
}
|
}
|
||||||
if (!match && filters.includes('Rank')) {
|
if (!match && filters.includes('Rank')) {
|
||||||
match = String(ctx.ref.$trustLevel).toUpperCase().includes(query.toUpperCase());
|
match = searchEntry.rank.includes(upperQuery);
|
||||||
}
|
}
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
@@ -121,6 +121,7 @@ vi.mock('../../../services/confusables', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../shared/utils', () => ({
|
vi.mock('../../../shared/utils', () => ({
|
||||||
|
debounce: (fn) => fn,
|
||||||
localeIncludes: (source, query) =>
|
localeIncludes: (source, query) =>
|
||||||
String(source ?? '')
|
String(source ?? '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -174,9 +175,9 @@ vi.mock('@/components/ui/button', () => ({
|
|||||||
vi.mock('@/components/ui/input-group', () => ({
|
vi.mock('@/components/ui/input-group', () => ({
|
||||||
InputGroupField: {
|
InputGroupField: {
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
emits: ['update:modelValue', 'change'],
|
emits: ['update:modelValue', 'input', 'change'],
|
||||||
template:
|
template:
|
||||||
'<input data-testid="friend-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @change="$emit(\'change\')" />'
|
'<input data-testid="friend-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value); $emit(\'input\', $event.target.value)" @change="$emit(\'change\')" />'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -274,6 +275,7 @@ async function flushAsync() {
|
|||||||
|
|
||||||
describe('FriendList.vue', () => {
|
describe('FriendList.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
mocks.route.path = '/friend-list';
|
mocks.route.path = '/friend-list';
|
||||||
mocks.friends.value = new Map();
|
mocks.friends.value = new Map();
|
||||||
mocks.allFavoriteFriendIds.value = new Set();
|
mocks.allFavoriteFriendIds.value = new Set();
|
||||||
@@ -296,6 +298,11 @@ describe('FriendList.vue', () => {
|
|||||||
mocks.toggleBulkColumnVisibility.mockReset();
|
mocks.toggleBulkColumnVisibility.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
test('filters friend list by search text and VIP toggle', async () => {
|
test('filters friend list by search text and VIP toggle', async () => {
|
||||||
mocks.friends.value = new Map([
|
mocks.friends.value = new Map([
|
||||||
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })],
|
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })],
|
||||||
@@ -320,6 +327,77 @@ describe('FriendList.vue', () => {
|
|||||||
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(1);
|
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('debounces search input and applies immediately on change', async () => {
|
||||||
|
mocks.friends.value = new Map([
|
||||||
|
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })],
|
||||||
|
['usr_2', makeFriendCtx({ id: 'usr_2', displayName: 'Bob' })]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wrapper = mount(FriendList);
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
const searchInput = wrapper.get('[data-testid="friend-search"]');
|
||||||
|
searchInput.element.value = 'bob';
|
||||||
|
await searchInput.trigger('input');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.vm.friendsListDisplayData.map((item) => item.id)
|
||||||
|
).toEqual(['usr_1', 'usr_2']);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.vm.friendsListDisplayData.map((item) => item.id)
|
||||||
|
).toEqual(['usr_2']);
|
||||||
|
|
||||||
|
mocks.friendsListSearch.value = 'alice';
|
||||||
|
await searchInput.trigger('change');
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.vm.friendsListDisplayData.map((item) => item.id)
|
||||||
|
).toEqual(['usr_1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshFriendStats retries immediately after a failed stats request', async () => {
|
||||||
|
mocks.friends.value = new Map([
|
||||||
|
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })]
|
||||||
|
]);
|
||||||
|
mocks.getAllUserStats.mockRejectedValueOnce(new Error('stats failed'));
|
||||||
|
|
||||||
|
const wrapper = mount(FriendList);
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await wrapper.vm.refreshFriendStats();
|
||||||
|
|
||||||
|
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshFriendStats refreshes again when friend roster changes with same size', async () => {
|
||||||
|
mocks.friends.value = new Map([
|
||||||
|
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wrapper = mount(FriendList);
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mocks.friends.value = new Map([
|
||||||
|
['usr_2', makeFriendCtx({ id: 'usr_2', displayName: 'Bob' })]
|
||||||
|
]);
|
||||||
|
|
||||||
|
await wrapper.vm.refreshFriendStats();
|
||||||
|
|
||||||
|
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens charts tab from toolbar button', async () => {
|
test('opens charts tab from toolbar button', async () => {
|
||||||
const wrapper = mount(FriendList);
|
const wrapper = mount(FriendList);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user