feat: add quick search

This commit is contained in:
pa
2026-03-05 22:20:07 +09:00
parent b570de6d4a
commit fb6358b3be
13 changed files with 1411 additions and 106 deletions

View File

@@ -0,0 +1,352 @@
import {
isPrefixMatch,
matchName,
searchAvatars,
searchFavoriteAvatars,
searchFavoriteWorlds,
searchFriends,
searchGroups,
searchWorlds
} from '../globalSearchUtils';
const comparer = new Intl.Collator(undefined, {
usage: 'search',
sensitivity: 'base'
});
// ── matchName ──────────────────────────────────────────────
describe('matchName', () => {
test('matches substring', () => {
expect(matchName('HelloWorld', 'llo', comparer)).toBe(true);
});
test('case-insensitive', () => {
expect(matchName('Alice', 'alice', comparer)).toBe(true);
});
test('strips whitespace from query', () => {
expect(matchName('Alice', 'al ice', comparer)).toBe(true);
});
test('returns false for empty inputs', () => {
expect(matchName('', 'query', comparer)).toBe(false);
expect(matchName('name', '', comparer)).toBe(false);
expect(matchName(null, 'query', comparer)).toBe(false);
});
test('returns false for whitespace-only query', () => {
expect(matchName('Alice', ' ', comparer)).toBe(false);
});
test('no match', () => {
expect(matchName('Alice', 'bob', comparer)).toBe(false);
});
});
// ── isPrefixMatch ──────────────────────────────────────────
describe('isPrefixMatch', () => {
test('detects prefix', () => {
expect(isPrefixMatch('Alice', 'ali', comparer)).toBe(true);
});
test('rejects non-prefix substring', () => {
expect(isPrefixMatch('Alice', 'ice', comparer)).toBe(false);
});
test('returns false for empty inputs', () => {
expect(isPrefixMatch('', 'a', comparer)).toBe(false);
expect(isPrefixMatch('Alice', '', comparer)).toBe(false);
});
});
// ── searchFriends ──────────────────────────────────────────
describe('searchFriends', () => {
/**
*
* @param id
* @param name
* @param memo
* @param note
*/
function makeFriend(id, name, memo = '', note = '') {
return [
id,
{
id,
name,
memo,
ref: {
currentAvatarThumbnailImageUrl: `img_${id}`,
note,
$userColour: '#fff'
}
}
];
}
const friends = new Map([
makeFriend('u1', 'Alice'),
makeFriend('u2', 'Bob', '同事', ''),
makeFriend('u3', 'Charlie', '', 'roommate'),
makeFriend('u4', 'Dave')
]);
test('matches by name', () => {
const results = searchFriends('alice', friends, comparer);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('u1');
expect(results[0].matchedField).toBe('name');
});
test('matches by memo', () => {
const results = searchFriends('同事', friends, comparer);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('u2');
expect(results[0].matchedField).toBe('memo');
expect(results[0].memo).toBe('同事');
});
test('matches by note', () => {
const results = searchFriends('roommate', friends, comparer);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('u3');
expect(results[0].matchedField).toBe('note');
expect(results[0].note).toBe('roommate');
});
test('returns empty for short / empty query', () => {
expect(searchFriends('', friends, comparer)).toEqual([]);
expect(searchFriends(null, friends, comparer)).toEqual([]);
});
test('respects limit', () => {
const many = new Map(
Array.from({ length: 20 }, (_, i) =>
makeFriend(`u${i}`, `Test${i}`)
)
);
expect(searchFriends('Test', many, comparer, 5)).toHaveLength(5);
});
test('prefix matches sort first', () => {
const f = new Map([
makeFriend('u1', 'XAliceX'),
makeFriend('u2', 'Alice')
]);
const results = searchFriends('Alice', f, comparer);
expect(results[0].id).toBe('u2'); // prefix match first
});
test('skips entries without ref', () => {
const broken = new Map([['u1', { id: 'u1', name: 'Test' }]]);
expect(searchFriends('Test', broken, comparer)).toEqual([]);
});
});
// ── searchAvatars ──────────────────────────────────────────
describe('searchAvatars', () => {
const avatarMap = new Map([
[
'a1',
{
id: 'a1',
name: 'Cool Avatar',
authorId: 'me',
thumbnailImageUrl: 'img1'
}
],
[
'a2',
{
id: 'a2',
name: 'Nice Avatar',
authorId: 'other',
thumbnailImageUrl: 'img2'
}
],
[
'a3',
{
id: 'a3',
name: 'Cool Suit',
authorId: 'me',
thumbnailImageUrl: 'img3'
}
]
]);
test('finds matching avatars', () => {
const results = searchAvatars('Cool', avatarMap, comparer);
expect(results).toHaveLength(2);
});
test('filters by authorId', () => {
const results = searchAvatars('Avatar', avatarMap, comparer, 'me');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('a1');
});
test('returns all when authorId is null', () => {
const results = searchAvatars('Avatar', avatarMap, comparer, null);
expect(results).toHaveLength(2);
});
test('returns empty for null map', () => {
expect(searchAvatars('test', null, comparer)).toEqual([]);
});
});
// ── searchWorlds ───────────────────────────────────────────
describe('searchWorlds', () => {
const worldMap = new Map([
[
'w1',
{
id: 'w1',
name: 'Fun World',
authorId: 'me',
thumbnailImageUrl: 'img1'
}
],
[
'w2',
{
id: 'w2',
name: 'Fun Park',
authorId: 'other',
thumbnailImageUrl: 'img2'
}
]
]);
test('finds matching worlds', () => {
const results = searchWorlds('Fun', worldMap, comparer);
expect(results).toHaveLength(2);
});
test('filters by ownerId (authorId)', () => {
const results = searchWorlds('Fun', worldMap, comparer, 'me');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('w1');
});
});
// ── searchGroups ───────────────────────────────────────────
describe('searchGroups', () => {
const groupMap = new Map([
[
'g1',
{
id: 'g1',
name: 'My Group',
ownerId: 'me',
iconUrl: 'icon1'
}
],
[
'g2',
{
id: 'g2',
name: 'Other Group',
ownerId: 'other',
iconUrl: 'icon2'
}
],
[
'g3',
{
id: 'g3',
name: 'Another My Group',
ownerId: 'me',
iconUrl: 'icon3'
}
]
]);
test('finds all matching groups', () => {
const results = searchGroups('Group', groupMap, comparer);
expect(results).toHaveLength(3);
});
test('filters by ownerId', () => {
const results = searchGroups('Group', groupMap, comparer, 'me');
expect(results).toHaveLength(2);
expect(results.every((r) => r.id !== 'g2')).toBe(true);
});
test('returns all when ownerId is null', () => {
const results = searchGroups('Group', groupMap, comparer, null);
expect(results).toHaveLength(3);
});
});
// ── searchFavoriteAvatars ──────────────────────────────────
describe('searchFavoriteAvatars', () => {
const favorites = [
{
name: 'Fav Avatar',
ref: { id: 'fa1', name: 'Fav Avatar', thumbnailImageUrl: 'img1' }
},
{
name: 'Cool Fav',
ref: { id: 'fa2', name: 'Cool Fav', thumbnailImageUrl: 'img2' }
},
{ name: 'Broken', ref: null }
];
test('finds matching favorite avatars', () => {
const results = searchFavoriteAvatars('Fav', favorites, comparer);
expect(results).toHaveLength(2);
expect(results.map((r) => r.id)).toContain('fa1');
});
test('skips entries with null ref', () => {
const results = searchFavoriteAvatars('Broken', favorites, comparer);
expect(results).toHaveLength(0);
});
test('returns empty for null input', () => {
expect(searchFavoriteAvatars('test', null, comparer)).toEqual([]);
});
});
// ── searchFavoriteWorlds ───────────────────────────────────
describe('searchFavoriteWorlds', () => {
const favorites = [
{
name: 'Fav World',
ref: {
id: 'fw1',
name: 'Fav World',
thumbnailImageUrl: 'img1'
}
},
{
name: 'Cool Place',
ref: {
id: 'fw2',
name: 'Cool Place',
thumbnailImageUrl: 'img2'
}
}
];
test('finds matching favorite worlds', () => {
const results = searchFavoriteWorlds('Cool', favorites, comparer);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('fw2');
expect(results[0].type).toBe('world');
});
test('returns empty for empty query', () => {
expect(searchFavoriteWorlds('', favorites, comparer)).toEqual([]);
});
});

View File

@@ -0,0 +1,336 @@
import { localeIncludes } from './base/string';
import removeConfusables, { removeWhitespace } from '../../service/confusables';
/**
* Tests whether a name matches a query using locale-aware comparison.
* Handles confusable-character normalization and whitespace stripping.
* @param {string} name - The display name to test
* @param {string} query - The raw user query (may contain whitespace)
* @param {Intl.Collator} comparer - Locale collator for comparison
* @returns {boolean}
*/
export function matchName(name, query, comparer) {
if (!name || !query) {
return false;
}
const cleanQuery = removeWhitespace(query);
if (!cleanQuery) {
return false;
}
const cleanName = removeConfusables(name);
if (localeIncludes(cleanName, cleanQuery, comparer)) {
return true;
}
// Also check raw name for users searching with special characters
return localeIncludes(name, cleanQuery, comparer);
}
/**
* Check whether a query starts the name (for prioritizing prefix matches).
* @param {string} name
* @param {string} query
* @param {Intl.Collator} comparer
* @returns {boolean}
*/
export function isPrefixMatch(name, query, comparer) {
if (!name || !query) {
return false;
}
const cleanQuery = removeWhitespace(query);
if (!cleanQuery) {
return false;
}
return (
comparer.compare(name.substring(0, cleanQuery.length), cleanQuery) === 0
);
}
/**
* Search friends from the friends Map.
* @param {string} query
* @param {Map} friends - friendStore.friends Map
* @param {Intl.Collator} comparer
* @param {number} [limit]
* @returns {Array<{id: string, name: string, type: string, imageUrl: string, ref: object}>}
*/
export function searchFriends(query, friends, comparer, limit = 10) {
if (!query || !friends) {
return [];
}
const results = [];
for (const ctx of friends.values()) {
if (typeof ctx.ref === 'undefined') {
continue;
}
let match = matchName(ctx.name, query, comparer);
let matchedField = match ? 'name' : null;
// Include memo and note matching for friends (with raw query for spaces)
if (!match && ctx.memo) {
match = localeIncludes(ctx.memo, query, comparer);
if (match) matchedField = 'memo';
}
if (!match && ctx.ref.note) {
match = localeIncludes(ctx.ref.note, query, comparer);
if (match) matchedField = 'note';
}
if (match) {
results.push({
id: ctx.id,
name: ctx.name,
type: 'friend',
imageUrl: ctx.ref.currentAvatarThumbnailImageUrl,
memo: ctx.memo || '',
note: ctx.ref.note || '',
matchedField,
ref: ctx.ref
});
}
}
// Sort: prefix matches first, then alphabetically
results.sort((a, b) => {
const aPrefix = isPrefixMatch(a.name, query, comparer);
const bPrefix = isPrefixMatch(b.name, query, comparer);
if (aPrefix && !bPrefix) return -1;
if (bPrefix && !aPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) {
results.length = limit;
}
return results;
}
/**
* Search avatars from a Map (cachedAvatars or favorite avatars).
* @param {string} query
* @param {Map} avatarMap
* @param {Intl.Collator} comparer
* @param {string|null} [authorId] - If provided, only match avatars by this author
* @param {number} [limit]
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
*/
export function searchAvatars(
query,
avatarMap,
comparer,
authorId = null,
limit = 10
) {
if (!query || !avatarMap) {
return [];
}
const results = [];
for (const ref of avatarMap.values()) {
if (!ref || !ref.name) {
continue;
}
if (authorId && ref.authorId !== authorId) {
continue;
}
if (matchName(ref.name, query, comparer)) {
results.push({
id: ref.id,
name: ref.name,
type: 'avatar',
imageUrl: ref.thumbnailImageUrl || ref.imageUrl
});
}
}
results.sort((a, b) => {
const aPrefix = isPrefixMatch(a.name, query, comparer);
const bPrefix = isPrefixMatch(b.name, query, comparer);
if (aPrefix && !bPrefix) return -1;
if (bPrefix && !aPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) {
results.length = limit;
}
return results;
}
/**
* Search worlds from a Map (cachedWorlds or favorite worlds).
* @param {string} query
* @param {Map} worldMap
* @param {Intl.Collator} comparer
* @param {string|null} [ownerId] - If provided, only match worlds owned by this user
* @param {number} [limit]
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
*/
export function searchWorlds(
query,
worldMap,
comparer,
ownerId = null,
limit = 10
) {
if (!query || !worldMap) {
return [];
}
const results = [];
for (const ref of worldMap.values()) {
if (!ref || !ref.name) {
continue;
}
if (ownerId && ref.authorId !== ownerId) {
continue;
}
if (matchName(ref.name, query, comparer)) {
results.push({
id: ref.id,
name: ref.name,
type: 'world',
imageUrl: ref.thumbnailImageUrl || ref.imageUrl
});
}
}
results.sort((a, b) => {
const aPrefix = isPrefixMatch(a.name, query, comparer);
const bPrefix = isPrefixMatch(b.name, query, comparer);
if (aPrefix && !bPrefix) return -1;
if (bPrefix && !aPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) {
results.length = limit;
}
return results;
}
/**
* Search groups from a Map (currentUserGroups).
* @param {string} query
* @param {Map} groupMap
* @param {Intl.Collator} comparer
* @param {string|null} [ownerId] - If provided, only match groups owned by this user
* @param {number} [limit]
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
*/
export function searchGroups(
query,
groupMap,
comparer,
ownerId = null,
limit = 10
) {
if (!query || !groupMap) {
return [];
}
const results = [];
for (const ref of groupMap.values()) {
if (!ref || !ref.name) {
continue;
}
if (ownerId && ref.ownerId !== ownerId) {
continue;
}
if (matchName(ref.name, query, comparer)) {
results.push({
id: ref.id,
name: ref.name,
type: 'group',
imageUrl: ref.iconUrl || ref.bannerUrl
});
}
}
results.sort((a, b) => {
const aPrefix = isPrefixMatch(a.name, query, comparer);
const bPrefix = isPrefixMatch(b.name, query, comparer);
if (aPrefix && !bPrefix) return -1;
if (bPrefix && !aPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) {
results.length = limit;
}
return results;
}
/**
* Search favorite avatars from the favoriteStore array.
* @param {string} query
* @param {Array} favoriteAvatars - favoriteStore.favoriteAvatars array of { name, ref }
* @param {Intl.Collator} comparer
* @param {number} [limit]
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
*/
export function searchFavoriteAvatars(
query,
favoriteAvatars,
comparer,
limit = 10
) {
if (!query || !favoriteAvatars) {
return [];
}
const results = [];
for (const ctx of favoriteAvatars) {
if (!ctx?.ref?.name) {
continue;
}
if (matchName(ctx.ref.name, query, comparer)) {
results.push({
id: ctx.ref.id,
name: ctx.ref.name,
type: 'avatar',
imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl
});
}
}
results.sort((a, b) => {
const aPrefix = isPrefixMatch(a.name, query, comparer);
const bPrefix = isPrefixMatch(b.name, query, comparer);
if (aPrefix && !bPrefix) return -1;
if (bPrefix && !aPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) {
results.length = limit;
}
return results;
}
/**
* Search favorite worlds from the favoriteStore array.
* @param {string} query
* @param {Array} favoriteWorlds - favoriteStore.favoriteWorlds array of { name, ref }
* @param {Intl.Collator} comparer
* @param {number} [limit]
* @returns {Array<{id: string, name: string, type: string, imageUrl: string}>}
*/
export function searchFavoriteWorlds(
query,
favoriteWorlds,
comparer,
limit = 10
) {
if (!query || !favoriteWorlds) {
return [];
}
const results = [];
for (const ctx of favoriteWorlds) {
if (!ctx?.ref?.name) {
continue;
}
if (matchName(ctx.ref.name, query, comparer)) {
results.push({
id: ctx.ref.id,
name: ctx.ref.name,
type: 'world',
imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl
});
}
}
results.sort((a, b) => {
const aPrefix = isPrefixMatch(a.name, query, comparer);
const bPrefix = isPrefixMatch(b.name, query, comparer);
if (aPrefix && !bPrefix) return -1;
if (bPrefix && !aPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) {
results.length = limit;
}
return results;
}