split some store func and add test

This commit is contained in:
pa
2026-03-04 20:21:17 +09:00
parent ea82825823
commit 1f1b996239
15 changed files with 1172 additions and 1307 deletions

View File

@@ -0,0 +1,73 @@
import {
FRIEND_TYPES,
GROUP_EXACT_TYPES,
getNotificationCategory,
getNotificationTs
} from '../notificationCategory';
describe('getNotificationCategory', () => {
test('returns "other" for falsy type', () => {
expect(getNotificationCategory('')).toBe('other');
expect(getNotificationCategory(null)).toBe('other');
expect(getNotificationCategory(undefined)).toBe('other');
});
test('returns "friend" for friend types', () => {
for (const type of FRIEND_TYPES) {
expect(getNotificationCategory(type)).toBe('friend');
}
});
test('returns "group" for group exact types', () => {
for (const type of GROUP_EXACT_TYPES) {
expect(getNotificationCategory(type)).toBe('group');
}
});
test('returns "group" for group prefix types', () => {
expect(getNotificationCategory('group.announcement')).toBe('group');
expect(getNotificationCategory('group.invite')).toBe('group');
expect(getNotificationCategory('group.transfer')).toBe('group');
expect(getNotificationCategory('moderation.warning')).toBe('group');
});
test('returns "other" for unknown types', () => {
expect(getNotificationCategory('OnPlayerJoined')).toBe('other');
expect(getNotificationCategory('GPS')).toBe('other');
expect(getNotificationCategory('VideoPlay')).toBe('other');
});
});
describe('getNotificationTs', () => {
test('returns millisecond timestamp from created_at string', () => {
const ts = getNotificationTs({
created_at: '2024-01-01T00:00:00.000Z'
});
expect(ts).toBe(new Date('2024-01-01T00:00:00.000Z').getTime());
});
test('returns millisecond timestamp from createdAt string', () => {
const ts = getNotificationTs({
createdAt: '2024-06-15T12:00:00.000Z'
});
expect(ts).toBe(new Date('2024-06-15T12:00:00.000Z').getTime());
});
test('prefers created_at over createdAt', () => {
const ts = getNotificationTs({
created_at: '2024-01-01T00:00:00.000Z',
createdAt: '2025-01-01T00:00:00.000Z'
});
expect(ts).toBe(new Date('2024-01-01T00:00:00.000Z').getTime());
});
test('handles numeric millisecond timestamp', () => {
const ms = 1700000000000;
expect(getNotificationTs({ created_at: ms })).toBe(ms);
});
test('converts seconds to milliseconds', () => {
const sec = 1700000000;
expect(getNotificationTs({ created_at: sec })).toBe(sec * 1000);
});
});

View File

@@ -0,0 +1,346 @@
import {
getNotificationMessage,
toNotificationText
} from '../notificationMessage';
// Mock displayLocation to return a predictable string
vi.mock('../utils', () => ({
displayLocation: (location, worldName, groupName) => {
let text = worldName || location;
if (groupName) text += ` (${groupName})`;
return text;
}
}));
describe('getNotificationMessage', () => {
test('returns null for unknown type', () => {
expect(getNotificationMessage({ type: 'unknown' }, '')).toBeNull();
});
test('OnPlayerJoined', () => {
const result = getNotificationMessage(
{ type: 'OnPlayerJoined', displayName: 'Alice' },
''
);
expect(result).toEqual({ title: 'Alice', body: 'has joined' });
});
test('OnPlayerLeft', () => {
const result = getNotificationMessage(
{ type: 'OnPlayerLeft', displayName: 'Bob' },
''
);
expect(result).toEqual({ title: 'Bob', body: 'has left' });
});
test('OnPlayerJoining', () => {
const result = getNotificationMessage(
{ type: 'OnPlayerJoining', displayName: 'Alice' },
''
);
expect(result).toEqual({ title: 'Alice', body: 'is joining' });
});
test('GPS', () => {
const result = getNotificationMessage(
{
type: 'GPS',
displayName: 'Alice',
location: 'wrld_123',
worldName: 'TestWorld',
groupName: ''
},
''
);
expect(result.title).toBe('Alice');
expect(result.body).toContain('is in');
expect(result.body).toContain('TestWorld');
});
test('Online with worldName', () => {
const result = getNotificationMessage(
{
type: 'Online',
displayName: 'Alice',
worldName: 'Lobby',
location: 'wrld_456',
groupName: ''
},
''
);
expect(result.title).toBe('Alice');
expect(result.body).toContain('has logged in');
expect(result.body).toContain('Lobby');
});
test('Online without worldName', () => {
const result = getNotificationMessage(
{ type: 'Online', displayName: 'Alice', worldName: '' },
''
);
expect(result).toEqual({
title: 'Alice',
body: 'has logged in'
});
});
test('Offline', () => {
const result = getNotificationMessage(
{ type: 'Offline', displayName: 'Alice' },
''
);
expect(result).toEqual({ title: 'Alice', body: 'has logged out' });
});
test('Status', () => {
const result = getNotificationMessage(
{
type: 'Status',
displayName: 'Alice',
status: 'busy',
statusDescription: 'working'
},
''
);
expect(result).toEqual({
title: 'Alice',
body: 'status is now busy working'
});
});
test('invite', () => {
const result = getNotificationMessage(
{
type: 'invite',
senderUsername: 'Bob',
details: { worldId: 'wrld_1', worldName: 'Hub' }
},
' (msg)'
);
expect(result.title).toBe('Bob');
expect(result.body).toContain('has invited you to');
expect(result.body).toContain('Hub');
expect(result.body).toContain('(msg)');
});
test('requestInvite', () => {
const result = getNotificationMessage(
{ type: 'requestInvite', senderUsername: 'Bob' },
' hey'
);
expect(result).toEqual({
title: 'Bob',
body: 'has requested an invite hey'
});
});
test('friendRequest', () => {
const result = getNotificationMessage(
{ type: 'friendRequest', senderUsername: 'Charlie' },
''
);
expect(result).toEqual({
title: 'Charlie',
body: 'has sent you a friend request'
});
});
test('Friend', () => {
const result = getNotificationMessage(
{ type: 'Friend', displayName: 'Dave' },
''
);
expect(result).toEqual({
title: 'Dave',
body: 'is now your friend'
});
});
test('DisplayName', () => {
const result = getNotificationMessage(
{
type: 'DisplayName',
previousDisplayName: 'OldName',
displayName: 'NewName'
},
''
);
expect(result).toEqual({
title: 'OldName',
body: 'changed their name to NewName'
});
});
test('boop', () => {
const result = getNotificationMessage(
{ type: 'boop', senderUsername: 'Eve', message: 'boop!' },
''
);
expect(result).toEqual({ title: 'Eve', body: 'boop!' });
});
test('groupChange', () => {
const result = getNotificationMessage(
{ type: 'groupChange', senderUsername: 'Mod', message: 'rank up' },
''
);
expect(result).toEqual({ title: 'Mod', body: 'rank up' });
});
test('group.announcement', () => {
const result = getNotificationMessage(
{ type: 'group.announcement', message: 'Hello all' },
''
);
expect(result).toEqual({
title: 'Group Announcement',
body: 'Hello all'
});
});
test('PortalSpawn with displayName', () => {
const result = getNotificationMessage(
{
type: 'PortalSpawn',
displayName: 'Alice',
instanceId: 'inst_1',
worldName: 'Room',
groupName: ''
},
''
);
expect(result.title).toBe('Alice');
expect(result.body).toContain('has spawned a portal to');
});
test('PortalSpawn without displayName', () => {
const result = getNotificationMessage(
{ type: 'PortalSpawn', displayName: '' },
''
);
expect(result).toEqual({
title: '',
body: 'User has spawned a portal'
});
});
test('VideoPlay', () => {
const result = getNotificationMessage(
{ type: 'VideoPlay', notyName: 'Cool Song' },
''
);
expect(result).toEqual({
title: 'Now playing',
body: 'Cool Song'
});
});
test('BlockedOnPlayerJoined', () => {
const result = getNotificationMessage(
{ type: 'BlockedOnPlayerJoined', displayName: 'Troll' },
''
);
expect(result).toEqual({
title: 'Troll',
body: 'Blocked user has joined'
});
});
test('Event', () => {
const result = getNotificationMessage(
{ type: 'Event', data: 'something happened' },
''
);
expect(result).toEqual({
title: 'Event',
body: 'something happened'
});
});
test('External', () => {
const result = getNotificationMessage(
{ type: 'External', message: 'ext msg' },
''
);
expect(result).toEqual({ title: 'External', body: 'ext msg' });
});
});
describe('toNotificationText', () => {
test('body-only types return just the body', () => {
expect(toNotificationText('Eve', 'boop!', 'boop')).toBe('boop!');
expect(
toNotificationText(
'Group Announcement',
'Hello',
'group.announcement'
)
).toBe('Hello');
expect(toNotificationText('Event', 'data', 'Event')).toBe('data');
expect(toNotificationText('External', 'msg', 'External')).toBe('msg');
expect(
toNotificationText('Instance Closed', 'closing', 'instance.closed')
).toBe('closing');
});
test('colon separator types use ": "', () => {
expect(toNotificationText('Mod', 'rank up', 'groupChange')).toBe(
'Mod: rank up'
);
expect(toNotificationText('Now playing', 'Song', 'VideoPlay')).toBe(
'Now playing: Song'
);
});
test('colon separator with empty title returns body only', () => {
expect(toNotificationText('', 'rank up', 'groupChange')).toBe(
'rank up'
);
});
test('custom format messages for blocked/muted', () => {
expect(
toNotificationText(
'Troll',
'blocked user has joined',
'BlockedOnPlayerJoined'
)
).toBe('Blocked user Troll has joined');
expect(
toNotificationText(
'Troll',
'blocked user has left',
'BlockedOnPlayerLeft'
)
).toBe('Blocked user Troll has left');
expect(
toNotificationText(
'Troll',
'muted user has joined',
'MutedOnPlayerJoined'
)
).toBe('Muted user Troll has joined');
expect(
toNotificationText(
'Troll',
'muted user has left',
'MutedOnPlayerLeft'
)
).toBe('Muted user Troll has left');
});
test('default types use space separator', () => {
expect(
toNotificationText('Alice', 'has joined', 'OnPlayerJoined')
).toBe('Alice has joined');
expect(toNotificationText('Bob', 'has logged out', 'Offline')).toBe(
'Bob has logged out'
);
});
test('default with empty title returns body only', () => {
expect(
toNotificationText('', 'User has spawned a portal', 'PortalSpawn')
).toBe('User has spawned a portal');
});
});

View File

@@ -0,0 +1,86 @@
import { resolveRef } from '../resolveRef';
describe('resolveRef', () => {
const emptyDefault = { id: '', name: '' };
const opts = {
emptyDefault,
idAlias: 'worldId',
nameKey: 'name',
fetchFn: vi.fn()
};
beforeEach(() => {
opts.fetchFn.mockReset();
});
test('returns emptyDefault for null input', async () => {
expect(await resolveRef(null, opts)).toEqual(emptyDefault);
});
test('returns emptyDefault for undefined input', async () => {
expect(await resolveRef(undefined, opts)).toEqual(emptyDefault);
});
test('normalises string input to object', async () => {
const result = await resolveRef('wrld_123', {
...opts,
fetchFn: vi.fn().mockResolvedValue({
ref: { name: 'MyWorld', extra: true }
})
});
expect(result.id).toBe('wrld_123');
expect(result.name).toBe('MyWorld');
});
test('uses idAlias when id is missing', async () => {
const result = await resolveRef(
{ worldId: 'wrld_456', name: 'World' },
opts
);
expect(result.id).toBe('wrld_456');
expect(result.name).toBe('World');
expect(opts.fetchFn).not.toHaveBeenCalled();
});
test('fetches when name is empty', async () => {
opts.fetchFn.mockResolvedValue({
ref: { name: 'Fetched', data: 42 }
});
const result = await resolveRef({ id: 'wrld_789', name: '' }, opts);
expect(opts.fetchFn).toHaveBeenCalledWith('wrld_789');
expect(result.id).toBe('wrld_789');
expect(result.name).toBe('Fetched');
expect(result.data).toBe(42);
});
test('returns input with id when fetch fails', async () => {
opts.fetchFn.mockRejectedValue(new Error('network'));
const result = await resolveRef({ id: 'wrld_111', name: '' }, opts);
expect(result.id).toBe('wrld_111');
expect(result.name).toBe('');
});
test('does not fetch when name is already present', async () => {
const result = await resolveRef(
{ id: 'wrld_222', name: 'Known' },
opts
);
expect(opts.fetchFn).not.toHaveBeenCalled();
expect(result.name).toBe('Known');
});
test('works with user-style config (displayName)', async () => {
const userOpts = {
emptyDefault: { id: '', displayName: '' },
idAlias: 'userId',
nameKey: 'displayName',
fetchFn: vi.fn().mockResolvedValue({
ref: { displayName: 'Alice', status: 'online' }
})
};
const result = await resolveRef('usr_1', userOpts);
expect(result.id).toBe('usr_1');
expect(result.displayName).toBe('Alice');
expect(result.status).toBe('online');
});
});

View File

@@ -0,0 +1,49 @@
import dayjs from 'dayjs';
const FRIEND_TYPES = new Set([
'friendRequest',
'ignoredFriendRequest',
'invite',
'requestInvite',
'inviteResponse',
'requestInviteResponse',
'boop'
]);
const GROUP_TYPES_PREFIX = ['group.', 'moderation.'];
const GROUP_EXACT_TYPES = new Set(['groupChange', 'event.announcement']);
/**
* Determine the category of a notification type.
* @param {string} type
* @returns {'friend'|'group'|'other'}
*/
function getNotificationCategory(type) {
if (!type) return 'other';
if (FRIEND_TYPES.has(type)) return 'friend';
if (
GROUP_EXACT_TYPES.has(type) ||
GROUP_TYPES_PREFIX.some((p) => type.startsWith(p))
)
return 'group';
return 'other';
}
/**
* Extract a millisecond timestamp from a notification object.
* @param {object} n - A notification with created_at or createdAt field
* @returns {number}
*/
function getNotificationTs(n) {
const raw = n.created_at ?? n.createdAt;
if (typeof raw === 'number') return raw > 1e12 ? raw : raw * 1000;
const ts = dayjs(raw).valueOf();
return Number.isFinite(ts) ? ts : 0;
}
export {
FRIEND_TYPES,
GROUP_TYPES_PREFIX,
GROUP_EXACT_TYPES,
getNotificationCategory,
getNotificationTs
};

View File

@@ -0,0 +1,228 @@
import { displayLocation } from './utils';
/**
* Extracts the notification title and body from a notification object.
* This is the single source of truth for notification message content,
* used by desktop toast, XS overlay, and OVRT overlay.
*
* @param {object} noty - The notification object
* @param {string} message - Pre-built invite/request message string
* @returns {{ title: string, body: string } | null}
*/
export function getNotificationMessage(noty, message) {
switch (noty.type) {
case 'OnPlayerJoined':
return { title: noty.displayName, body: 'has joined' };
case 'OnPlayerLeft':
return { title: noty.displayName, body: 'has left' };
case 'OnPlayerJoining':
return { title: noty.displayName, body: 'is joining' };
case 'GPS':
return {
title: noty.displayName,
body: `is in ${displayLocation(
noty.location,
noty.worldName,
noty.groupName
)}`
};
case 'Online': {
let locationName = '';
if (noty.worldName) {
locationName = ` to ${displayLocation(
noty.location,
noty.worldName,
noty.groupName
)}`;
}
return {
title: noty.displayName,
body: `has logged in${locationName}`
};
}
case 'Offline':
return { title: noty.displayName, body: 'has logged out' };
case 'Status':
return {
title: noty.displayName,
body: `status is now ${noty.status} ${noty.statusDescription}`
};
case 'invite':
return {
title: noty.senderUsername,
body: `has invited you to ${displayLocation(
noty.details.worldId,
noty.details.worldName
)}${message}`
};
case 'requestInvite':
return {
title: noty.senderUsername,
body: `has requested an invite${message}`
};
case 'inviteResponse':
return {
title: noty.senderUsername,
body: `has responded to your invite${message}`
};
case 'requestInviteResponse':
return {
title: noty.senderUsername,
body: `has responded to your invite request${message}`
};
case 'friendRequest':
return {
title: noty.senderUsername,
body: 'has sent you a friend request'
};
case 'Friend':
return { title: noty.displayName, body: 'is now your friend' };
case 'Unfriend':
return {
title: noty.displayName,
body: 'is no longer your friend'
};
case 'TrustLevel':
return {
title: noty.displayName,
body: `trust level is now ${noty.trustLevel}`
};
case 'DisplayName':
return {
title: noty.previousDisplayName,
body: `changed their name to ${noty.displayName}`
};
case 'boop':
return { title: noty.senderUsername, body: noty.message };
case 'groupChange':
return { title: noty.senderUsername, body: noty.message };
case 'group.announcement':
return { title: 'Group Announcement', body: noty.message };
case 'group.informative':
return { title: 'Group Informative', body: noty.message };
case 'group.invite':
return { title: 'Group Invite', body: noty.message };
case 'group.joinRequest':
return { title: 'Group Join Request', body: noty.message };
case 'group.transfer':
return { title: 'Group Transfer Request', body: noty.message };
case 'group.queueReady':
return { title: 'Instance Queue Ready', body: noty.message };
case 'instance.closed':
return { title: 'Instance Closed', body: noty.message };
case 'PortalSpawn':
if (noty.displayName) {
return {
title: noty.displayName,
body: `has spawned a portal to ${displayLocation(
noty.instanceId,
noty.worldName,
noty.groupName
)}`
};
}
return { title: '', body: 'User has spawned a portal' };
case 'AvatarChange':
return {
title: noty.displayName,
body: `changed into avatar ${noty.name}`
};
case 'ChatBoxMessage':
return {
title: noty.displayName,
body: `said ${noty.text}`
};
case 'Event':
return { title: 'Event', body: noty.data };
case 'External':
return { title: 'External', body: noty.message };
case 'VideoPlay':
return { title: 'Now playing', body: noty.notyName };
case 'BlockedOnPlayerJoined':
return {
title: noty.displayName,
body: 'Blocked user has joined'
};
case 'BlockedOnPlayerLeft':
return {
title: noty.displayName,
body: 'Blocked user has left'
};
case 'MutedOnPlayerJoined':
return {
title: noty.displayName,
body: 'Muted user has joined'
};
case 'MutedOnPlayerLeft':
return {
title: noty.displayName,
body: 'Muted user has left'
};
case 'Blocked':
return { title: noty.displayName, body: 'has blocked you' };
case 'Unblocked':
return { title: noty.displayName, body: 'has unblocked you' };
case 'Muted':
return { title: noty.displayName, body: 'has muted you' };
case 'Unmuted':
return { title: noty.displayName, body: 'has unmuted you' };
default:
return null;
}
}
/**
* Types where the full message is just the body (no title prefix).
* Used by XS/OVRT notifications.
*/
const BODY_ONLY_TYPES = new Set([
'boop',
'group.announcement',
'group.informative',
'group.invite',
'group.joinRequest',
'group.transfer',
'group.queueReady',
'instance.closed',
'Event',
'External'
]);
/**
* Types where title and body are joined with ": " instead of " ".
*/
const COLON_SEPARATOR_TYPES = new Set(['groupChange', 'VideoPlay']);
/**
* Types where the full message has a custom word order
* different from "{title} {body}".
*/
const CUSTOM_FORMAT_MESSAGES = {
BlockedOnPlayerJoined: (title) => `Blocked user ${title} has joined`,
BlockedOnPlayerLeft: (title) => `Blocked user ${title} has left`,
MutedOnPlayerJoined: (title) => `Muted user ${title} has joined`,
MutedOnPlayerLeft: (title) => `Muted user ${title} has left`
};
/**
* Combines title and body into a single notification text string.
* Handles per-type formatting differences for XS/OVRT overlays.
*
* @param {string} title
* @param {string} body
* @param {string} type - The notification type
* @returns {string}
*/
export function toNotificationText(title, body, type) {
if (BODY_ONLY_TYPES.has(type)) {
return body;
}
if (COLON_SEPARATOR_TYPES.has(type)) {
return title ? `${title}: ${body}` : body;
}
const customFmt = CUSTOM_FORMAT_MESSAGES[type];
if (customFmt) {
return customFmt(title);
}
return title ? `${title} ${body}` : body;
}

34
src/shared/resolveRef.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* Generic resolver for user/world/group references.
* Normalises the input, optionally fetches the display name if missing.
*
* @param {string|object|null|undefined} input
* @param {object} opts
* @param {object} opts.emptyDefault - value to return when input is falsy
* @param {string} opts.idAlias - alternative id key on input (e.g. 'userId')
* @param {string} opts.nameKey - name property key (e.g. 'displayName' or 'name')
* @param {(id: string) => Promise<{ref: object}>} opts.fetchFn - fetch function
* @returns {Promise<object>}
*/
async function resolveRef(input, { emptyDefault, idAlias, nameKey, fetchFn }) {
if (!input) {
return emptyDefault;
}
if (typeof input === 'string') {
input = { id: input, [nameKey]: '' };
}
const id = input.id || input[idAlias] || '';
let name = input[nameKey] || '';
if (id && !name) {
try {
const args = await fetchFn(id);
name = args?.ref?.[nameKey] || name;
return { ...args.ref, id, [nameKey]: name };
} catch {
return { ...input, id, [nameKey]: name };
}
}
return { ...input, id, [nameKey]: name };
}
export { resolveRef };

View File

@@ -0,0 +1,98 @@
import { gameLogSearchFilter } from '../gameLog';
describe('gameLogSearchFilter', () => {
test('returns true for empty search query', () => {
expect(gameLogSearchFilter({}, '')).toBe(true);
expect(gameLogSearchFilter({}, ' ')).toBe(true);
});
test('matches Location by worldName', () => {
const row = { type: 'Location', worldName: 'Test World' };
expect(gameLogSearchFilter(row, 'test')).toBe(true);
expect(gameLogSearchFilter(row, 'WORLD')).toBe(true);
expect(gameLogSearchFilter(row, 'nope')).toBe(false);
});
test('matches OnPlayerJoined by displayName', () => {
const row = { type: 'OnPlayerJoined', displayName: 'Alice' };
expect(gameLogSearchFilter(row, 'alice')).toBe(true);
expect(gameLogSearchFilter(row, 'bob')).toBe(false);
});
test('matches OnPlayerLeft by displayName', () => {
const row = { type: 'OnPlayerLeft', displayName: 'Bob' };
expect(gameLogSearchFilter(row, 'bob')).toBe(true);
expect(gameLogSearchFilter(row, 'alice')).toBe(false);
});
test('matches PortalSpawn by displayName or worldName', () => {
const row = {
type: 'PortalSpawn',
displayName: 'Alice',
worldName: 'Portal Room'
};
expect(gameLogSearchFilter(row, 'alice')).toBe(true);
expect(gameLogSearchFilter(row, 'portal')).toBe(true);
expect(gameLogSearchFilter(row, 'bob')).toBe(false);
});
test('matches Event by data', () => {
const row = { type: 'Event', data: 'something happened' };
expect(gameLogSearchFilter(row, 'something')).toBe(true);
expect(gameLogSearchFilter(row, 'nothing')).toBe(false);
});
test('matches External by message or displayName', () => {
const row = {
type: 'External',
message: 'hello world',
displayName: 'Plugin'
};
expect(gameLogSearchFilter(row, 'hello')).toBe(true);
expect(gameLogSearchFilter(row, 'plugin')).toBe(true);
expect(gameLogSearchFilter(row, 'foo')).toBe(false);
});
test('matches VideoPlay by displayName, videoName, or videoUrl', () => {
const row = {
type: 'VideoPlay',
displayName: 'Alice',
videoName: 'Cool Song',
videoUrl: 'https://example.com/video'
};
expect(gameLogSearchFilter(row, 'alice')).toBe(true);
expect(gameLogSearchFilter(row, 'cool')).toBe(true);
expect(gameLogSearchFilter(row, 'example.com')).toBe(true);
expect(gameLogSearchFilter(row, 'nope')).toBe(false);
});
test('matches StringLoad/ImageLoad by resourceUrl', () => {
const rowStr = {
type: 'StringLoad',
resourceUrl: 'https://cdn.example.com/res'
};
expect(gameLogSearchFilter(rowStr, 'cdn')).toBe(true);
expect(gameLogSearchFilter(rowStr, 'nope')).toBe(false);
const rowImg = {
type: 'ImageLoad',
resourceUrl: 'https://cdn.example.com/img'
};
expect(gameLogSearchFilter(rowImg, 'img')).toBe(true);
});
test('matches location prefix wrld_ or grp_ against row.location', () => {
const row = {
type: 'Location',
location: 'wrld_123456~hidden',
worldName: 'Test'
};
expect(gameLogSearchFilter(row, 'wrld_123456')).toBe(true);
expect(gameLogSearchFilter(row, 'wrld_999')).toBe(false);
});
test('returns true for unknown type', () => {
const row = { type: 'SomeNewType' };
expect(gameLogSearchFilter(row, 'anything')).toBe(true);
});
});

View File

@@ -0,0 +1,59 @@
import { findUserByDisplayName } from '../user';
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { name: 'Feed' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
describe('findUserByDisplayName', () => {
function createCachedUsers(entries) {
const map = new Map();
for (const entry of entries) {
map.set(entry.id, entry);
}
return map;
}
test('returns the user matching displayName', () => {
const users = createCachedUsers([
{ id: 'usr_1', displayName: 'Alice' },
{ id: 'usr_2', displayName: 'Bob' },
{ id: 'usr_3', displayName: 'Charlie' }
]);
const result = findUserByDisplayName(users, 'Bob');
expect(result).toEqual({ id: 'usr_2', displayName: 'Bob' });
});
test('returns undefined when no match found', () => {
const users = createCachedUsers([
{ id: 'usr_1', displayName: 'Alice' }
]);
expect(findUserByDisplayName(users, 'Unknown')).toBeUndefined();
});
test('returns undefined for empty map', () => {
const users = new Map();
expect(findUserByDisplayName(users, 'Alice')).toBeUndefined();
});
test('returns first match when duplicates exist', () => {
const users = createCachedUsers([
{ id: 'usr_1', displayName: 'Alice' },
{ id: 'usr_2', displayName: 'Alice' }
]);
// Map preserves insertion order, first match wins
const result = findUserByDisplayName(users, 'Alice');
expect(result.id).toBe('usr_1');
});
test('match is exact (case-sensitive)', () => {
const users = createCachedUsers([
{ id: 'usr_1', displayName: 'Alice' }
]);
expect(findUserByDisplayName(users, 'alice')).toBeUndefined();
expect(findUserByDisplayName(users, 'ALICE')).toBeUndefined();
});
});

View File

@@ -0,0 +1,76 @@
/**
* Filter a game log row by search query.
* @param {object} row
* @param {string} searchQuery
* @returns {boolean}
*/
function gameLogSearchFilter(row, searchQuery) {
const value = searchQuery.trim().toUpperCase();
if (!value) {
return true;
}
if (
(value.startsWith('WRLD_') || value.startsWith('GRP_')) &&
String(row.location).toUpperCase().includes(value)
) {
return true;
}
switch (row.type) {
case 'Location':
if (String(row.worldName).toUpperCase().includes(value)) {
return true;
}
return false;
case 'OnPlayerJoined':
if (String(row.displayName).toUpperCase().includes(value)) {
return true;
}
return false;
case 'OnPlayerLeft':
if (String(row.displayName).toUpperCase().includes(value)) {
return true;
}
return false;
case 'PortalSpawn':
if (String(row.displayName).toUpperCase().includes(value)) {
return true;
}
if (String(row.worldName).toUpperCase().includes(value)) {
return true;
}
return false;
case 'Event':
if (String(row.data).toUpperCase().includes(value)) {
return true;
}
return false;
case 'External':
if (String(row.message).toUpperCase().includes(value)) {
return true;
}
if (String(row.displayName).toUpperCase().includes(value)) {
return true;
}
return false;
case 'VideoPlay':
if (String(row.displayName).toUpperCase().includes(value)) {
return true;
}
if (String(row.videoName).toUpperCase().includes(value)) {
return true;
}
if (String(row.videoUrl).toUpperCase().includes(value)) {
return true;
}
return false;
case 'StringLoad':
case 'ImageLoad':
if (String(row.resourceUrl).toUpperCase().includes(value)) {
return true;
}
return false;
}
return true;
}
export { gameLogSearchFilter };

View File

@@ -22,3 +22,4 @@ export * from './world';
export * from './memos';
export * from './throttle';
export * from './retry';
export * from './gameLog';

View File

@@ -291,6 +291,21 @@ function userOnlineFor(ref) {
return '-';
}
/**
* Find a user object from cachedUsers by displayName.
* @param {Map} cachedUsers
* @param {string} displayName
* @returns {object|undefined}
*/
function findUserByDisplayName(cachedUsers, displayName) {
for (const ref of cachedUsers.values()) {
if (ref.displayName === displayName) {
return ref;
}
}
return undefined;
}
export {
userOnlineForTimestamp,
languageClass,
@@ -301,5 +316,6 @@ export {
userImage,
userImageFull,
parseUserUrl,
userOnlineFor
userOnlineFor,
findUserByDisplayName
};