improve search tab

This commit is contained in:
pa
2026-03-12 20:55:43 +09:00
parent 08e160ff69
commit 76ff4844db
14 changed files with 1240 additions and 593 deletions

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
avatarRemoteDatabase: require('vue').ref(true),
searchText: require('vue').ref(''),
lookupAvatars: vi.fn()
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('../../../../stores', () => ({
useAdvancedSettingsStore: () => ({
avatarRemoteDatabase: mocks.avatarRemoteDatabase
}),
useSearchStore: () => ({
searchText: mocks.searchText
})
}));
vi.mock('../../../../coordinators/avatarCoordinator', () => ({
lookupAvatars: (...args) => mocks.lookupAvatars(...args)
}));
import { useSearchAvatar } from '../useSearchAvatar';
describe('useSearchAvatar', () => {
beforeEach(() => {
mocks.avatarRemoteDatabase.value = true;
mocks.searchText.value = '';
mocks.lookupAvatars.mockReset();
});
it('queries remote avatars and builds first page', async () => {
mocks.searchText.value = 'alice';
mocks.lookupAvatars.mockResolvedValue([
{ id: 'avtr_1', name: 'A' },
{ id: 'avtr_1', name: 'A-dup' },
{ id: 'avtr_2', name: 'B' }
]);
const api = useSearchAvatar();
await api.searchAvatar();
expect(mocks.lookupAvatars).toHaveBeenCalledWith('search', 'alice');
expect(api.searchAvatarResults.value.map((x) => x.id)).toEqual(['avtr_1', 'avtr_2']);
expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(['avtr_1', 'avtr_2']);
expect(api.searchAvatarPageNum.value).toBe(0);
});
it('skips remote query when text is too short', async () => {
mocks.searchText.value = 'ab';
const api = useSearchAvatar();
await api.searchAvatar();
expect(mocks.lookupAvatars).not.toHaveBeenCalled();
expect(api.searchAvatarResults.value).toEqual([]);
});
it('paginates results by 10 items', () => {
const api = useSearchAvatar();
api.searchAvatarResults.value = Array.from({ length: 25 }, (_, i) => ({ id: `avtr_${i}` }));
api.searchAvatarPage.value = api.searchAvatarResults.value.slice(0, 10);
api.moreSearchAvatar(1);
expect(api.searchAvatarPageNum.value).toBe(1);
expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(
Array.from({ length: 10 }, (_, i) => `avtr_${i + 10}`)
);
api.moreSearchAvatar(-1);
expect(api.searchAvatarPageNum.value).toBe(0);
expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(
Array.from({ length: 10 }, (_, i) => `avtr_${i}`)
);
});
});

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
searchText: require('vue').ref(''),
replaceBioSymbols: vi.fn((text) => text),
groupSearch: vi.fn()
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('../../../../stores', () => ({
useSearchStore: () => ({
searchText: mocks.searchText
})
}));
vi.mock('../../../../shared/utils', () => ({
replaceBioSymbols: (...args) => mocks.replaceBioSymbols(...args)
}));
vi.mock('../../../../api', () => ({
groupRequest: {
groupSearch: (...args) => mocks.groupSearch(...args)
}
}));
import { useSearchGroup } from '../useSearchGroup';
describe('useSearchGroup', () => {
beforeEach(() => {
mocks.searchText.value = '';
mocks.replaceBioSymbols.mockReset();
mocks.replaceBioSymbols.mockImplementation((text) => text);
mocks.groupSearch.mockReset();
});
it('starts group search with normalized query', async () => {
mocks.searchText.value = 'group+name';
mocks.replaceBioSymbols.mockReturnValue('group name');
mocks.groupSearch.mockResolvedValue({ json: [{ id: 'grp_1' }, { id: 'grp_1' }, { id: 'grp_2' }] });
const api = useSearchGroup();
await api.searchGroup();
expect(mocks.replaceBioSymbols).toHaveBeenCalledWith('group+name');
expect(mocks.groupSearch).toHaveBeenCalledWith({
n: 10,
offset: 0,
query: 'group name'
});
expect(api.searchGroupResults.value.map((x) => x.id)).toEqual(['grp_1', 'grp_2']);
});
it('moves backward paging offset without going below zero', async () => {
mocks.groupSearch.mockResolvedValue({ json: [] });
const api = useSearchGroup();
api.searchGroupParams.value = { n: 10, offset: 5, query: 'abc' };
await api.moreSearchGroup(-1);
expect(mocks.groupSearch).toHaveBeenCalledWith({ n: 10, offset: 0, query: 'abc' });
});
});

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
searchText: require('vue').ref(''),
moreSearchUser: vi.fn(() => Promise.resolve())
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('../../../../stores', () => ({
useSearchStore: () => ({
searchText: mocks.searchText,
moreSearchUser: (...args) => mocks.moreSearchUser(...args)
})
}));
import { useSearchUser } from '../useSearchUser';
describe('useSearchUser', () => {
beforeEach(() => {
mocks.searchText.value = '';
mocks.moreSearchUser.mockReset();
mocks.moreSearchUser.mockResolvedValue(undefined);
});
it('builds search params and requests first page', async () => {
mocks.searchText.value = 'Alice';
const api = useSearchUser();
api.searchUserByBio.value = true;
api.searchUserSortByLastLoggedIn.value = true;
await api.searchUser();
expect(mocks.moreSearchUser).toHaveBeenCalledWith(null, {
n: 10,
offset: 0,
search: 'Alice',
customFields: 'bio',
sort: 'last_login'
});
expect(api.isSearchUserLoading.value).toBe(false);
});
it('passes page direction into handleMoreSearchUser', async () => {
const api = useSearchUser();
api.searchUserParams.value = { n: 10, offset: 10, search: 'Alice', customFields: 'displayName', sort: 'relevance' };
await api.handleMoreSearchUser(-1);
expect(mocks.moreSearchUser).toHaveBeenCalledWith(-1, api.searchUserParams.value);
});
});

View File

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
searchText: require('vue').ref(''),
cachedConfig: require('vue').ref({ dynamicWorldRows: [] }),
cachedWorlds: new Map(),
replaceBioSymbols: vi.fn((text) => text),
getWorlds: vi.fn()
}));
vi.mock('pinia', () => ({
storeToRefs: (store) => store
}));
vi.mock('../../../../stores', () => ({
useSearchStore: () => ({
searchText: mocks.searchText
}),
useAuthStore: () => ({
cachedConfig: mocks.cachedConfig
}),
useWorldStore: () => ({
cachedWorlds: mocks.cachedWorlds
})
}));
vi.mock('../../../../shared/utils', () => ({
replaceBioSymbols: (...args) => mocks.replaceBioSymbols(...args)
}));
vi.mock('../../../../api', () => ({
worldRequest: {
getWorlds: (...args) => mocks.getWorlds(...args)
}
}));
import { useSearchWorld } from '../useSearchWorld';
describe('useSearchWorld', () => {
beforeEach(() => {
mocks.searchText.value = '';
mocks.cachedConfig.value = { dynamicWorldRows: [] };
mocks.cachedWorlds.clear();
mocks.replaceBioSymbols.mockReset();
mocks.replaceBioSymbols.mockImplementation((text) => text);
mocks.getWorlds.mockReset();
});
it('creates relevance params and appends system_approved tag', async () => {
mocks.searchText.value = 'home world';
mocks.replaceBioSymbols.mockReturnValue('home world');
mocks.cachedWorlds.set('wrld_1', { id: 'wrld_1', name: 'World One' });
mocks.getWorlds.mockResolvedValue({ json: [{ id: 'wrld_1' }, { id: 'wrld_missing' }] });
const api = useSearchWorld();
api.searchWorld({});
await Promise.resolve();
await Promise.resolve();
expect(mocks.getWorlds).toHaveBeenCalledWith(
{
n: 10,
offset: 0,
sort: 'relevance',
search: 'home world',
order: 'descending',
tag: 'system_approved'
},
''
);
expect(api.searchWorldParams.value.search).toBe('home world');
});
it('selects category row and uses row sort settings', async () => {
mocks.cachedConfig.value = {
dynamicWorldRows: [{ index: 2, sortHeading: 'featured', sortOrder: 'ascending', tag: 'party' }]
};
mocks.getWorlds.mockResolvedValue({ json: [] });
const api = useSearchWorld();
api.handleSearchWorldCategorySelect(2);
await Promise.resolve();
await Promise.resolve();
expect(api.searchWorldCategoryIndex.value).toBe(2);
expect(mocks.getWorlds).toHaveBeenCalledWith(
{
n: 10,
offset: 0,
sort: 'order',
featured: 'true',
order: 'ascending',
tag: 'party,system_approved'
},
''
);
});
});

View File

@@ -1,29 +1,17 @@
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import {
compareByCreatedAt,
compareByName,
compareByUpdatedAt
} from '../../../shared/utils';
import {
useAdvancedSettingsStore,
useAvatarStore,
useSearchStore
} from '../../../stores';
import { useAdvancedSettingsStore, useSearchStore } from '../../../stores';
import { lookupAvatars } from '../../../coordinators/avatarCoordinator';
/**
* Avatar search composable for Search view.
* Manages avatar search state, local/remote filtering, sorting, and pagination.
* Searches remote avatar databases only (local avatar browsing is handled by My Avatars page).
*/
export function useSearchAvatar() {
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
const { lookupAvatars, cachedAvatars } = useAvatarStore();
const { searchText } = storeToRefs(useSearchStore());
const searchAvatarFilter = ref('');
const searchAvatarSort = ref('');
const searchAvatarFilterRemote = ref('');
const searchAvatarPageNum = ref(0);
const searchAvatarResults = ref([]);
const searchAvatarPage = ref([]);
@@ -33,111 +21,26 @@ export function useSearchAvatar() {
*
*/
async function searchAvatar() {
let ref;
isSearchAvatarLoading.value = true;
if (!searchAvatarFilter.value) {
searchAvatarFilter.value = 'all';
}
if (!searchAvatarSort.value) {
searchAvatarSort.value = 'name';
}
if (!searchAvatarFilterRemote.value) {
searchAvatarFilterRemote.value = 'all';
}
if (searchAvatarFilterRemote.value !== 'local') {
searchAvatarSort.value = 'name';
}
const avatars = new Map();
const query = searchText.value;
const queryUpper = query.toUpperCase();
if (!query) {
for (ref of cachedAvatars.values()) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
break;
case 'public':
if (ref.releaseStatus === 'public') {
avatars.set(ref.id, ref);
}
break;
case 'private':
if (ref.releaseStatus === 'private') {
avatars.set(ref.id, ref);
}
break;
}
if (query && query.length >= 3 && avatarRemoteDatabase.value) {
const data = await lookupAvatars('search', query);
if (data && typeof data === 'object') {
data.forEach((avatar) => {
avatars.set(avatar.id, avatar);
});
}
isSearchAvatarLoading.value = false;
} else {
if (
searchAvatarFilterRemote.value === 'all' ||
searchAvatarFilterRemote.value === 'local'
) {
for (ref of cachedAvatars.values()) {
let match = ref.name.toUpperCase().includes(queryUpper);
if (!match && ref.description) {
match = ref.description
.toUpperCase()
.includes(queryUpper);
}
if (!match && ref.authorName) {
match = ref.authorName
.toUpperCase()
.includes(queryUpper);
}
if (match) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
break;
case 'public':
if (ref.releaseStatus === 'public') {
avatars.set(ref.id, ref);
}
break;
case 'private':
if (ref.releaseStatus === 'private') {
avatars.set(ref.id, ref);
}
break;
}
}
}
}
if (
(searchAvatarFilterRemote.value === 'all' ||
searchAvatarFilterRemote.value === 'remote') &&
avatarRemoteDatabase.value &&
query.length >= 3
) {
const data = await lookupAvatars('search', query);
if (data && typeof data === 'object') {
data.forEach((avatar) => {
avatars.set(avatar.id, avatar);
});
}
}
isSearchAvatarLoading.value = false;
}
isSearchAvatarLoading.value = false;
const avatarsArray = Array.from(avatars.values());
if (searchAvatarFilterRemote.value === 'local') {
switch (searchAvatarSort.value) {
case 'updated':
avatarsArray.sort(compareByUpdatedAt);
break;
case 'created':
avatarsArray.sort(compareByCreatedAt);
break;
case 'name':
avatarsArray.sort(compareByName);
break;
}
}
searchAvatarPageNum.value = 0;
searchAvatarResults.value = avatarsArray;
searchAvatarPage.value = avatarsArray.slice(0, 10);
}
/**
*
* @param n
@@ -158,33 +61,6 @@ export function useSearchAvatar() {
);
}
/**
*
* @param value
*/
function handleSearchAvatarFilterChange(value) {
searchAvatarFilter.value = value;
searchAvatar();
}
/**
*
* @param value
*/
function handleSearchAvatarFilterRemoteChange(value) {
searchAvatarFilterRemote.value = value;
searchAvatar();
}
/**
*
* @param value
*/
function handleSearchAvatarSortChange(value) {
searchAvatarSort.value = value;
searchAvatar();
}
/**
*
*/
@@ -195,18 +71,12 @@ export function useSearchAvatar() {
}
return {
searchAvatarFilter,
searchAvatarSort,
searchAvatarFilterRemote,
searchAvatarPageNum,
searchAvatarResults,
searchAvatarPage,
isSearchAvatarLoading,
searchAvatar,
moreSearchAvatar,
handleSearchAvatarFilterChange,
handleSearchAvatarFilterRemoteChange,
handleSearchAvatarSortChange,
clearAvatarSearch
};
}

View File

@@ -0,0 +1,75 @@
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useSearchStore } from '../../../stores';
import { replaceBioSymbols } from '../../../shared/utils';
import { groupRequest } from '../../../api';
/**
* Group search composable for Search view.
* Manages group search state and pagination.
*/
export function useSearchGroup() {
const { searchText } = storeToRefs(useSearchStore());
const searchGroupParams = ref({});
const searchGroupResults = ref([]);
const isSearchGroupLoading = ref(false);
/**
*
*/
async function searchGroup() {
searchGroupParams.value = {
n: 10,
offset: 0,
query: replaceBioSymbols(searchText.value)
};
await moreSearchGroup();
}
/**
*
* @param go
*/
async function moreSearchGroup(go) {
const params = searchGroupParams.value;
if (go) {
params.offset += params.n * go;
if (params.offset < 0) {
params.offset = 0;
}
}
isSearchGroupLoading.value = true;
await groupRequest
.groupSearch(params)
.finally(() => {
isSearchGroupLoading.value = false;
})
.then((args) => {
const map = new Map();
for (const json of args.json) {
map.set(json.id, json);
}
searchGroupResults.value = Array.from(map.values());
return args;
});
}
/**
*
*/
function clearGroupSearch() {
searchGroupParams.value = {};
searchGroupResults.value = [];
}
return {
searchGroupParams,
searchGroupResults,
isSearchGroupLoading,
searchGroup,
moreSearchGroup,
clearGroupSearch
};
}

View File

@@ -0,0 +1,61 @@
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useSearchStore } from '../../../stores';
/**
* User search composable for Search view.
* Manages user search state, filters, and pagination.
*/
export function useSearchUser() {
const { searchText } = storeToRefs(useSearchStore());
const { moreSearchUser } = useSearchStore();
const searchUserParams = ref({});
const searchUserByBio = ref(false);
const searchUserSortByLastLoggedIn = ref(false);
const isSearchUserLoading = ref(false);
/**
*
*/
async function searchUser() {
searchUserParams.value = {
n: 10,
offset: 0,
search: searchText.value,
customFields: searchUserByBio.value ? 'bio' : 'displayName',
sort: searchUserSortByLastLoggedIn.value
? 'last_login'
: 'relevance'
};
await handleMoreSearchUser();
}
/**
*
* @param go
*/
async function handleMoreSearchUser(go = null) {
isSearchUserLoading.value = true;
await moreSearchUser(go, searchUserParams.value);
isSearchUserLoading.value = false;
}
/**
*
*/
function clearUserSearch() {
searchUserParams.value = {};
}
return {
searchUserParams,
searchUserByBio,
searchUserSortByLastLoggedIn,
isSearchUserLoading,
searchUser,
handleMoreSearchUser,
clearUserSearch
};
}