mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-11 19:03:51 +02:00
573 lines
19 KiB
JavaScript
573 lines
19 KiB
JavaScript
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
// 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}`)
|
|
}));
|
|
|
|
const storeMocks = vi.hoisted(() => ({
|
|
useUserStore: vi.fn(() => ({
|
|
currentUser: {
|
|
id: 'usr_store',
|
|
presence: { platform: 'standalonewindows' },
|
|
onlineFriends: [],
|
|
activeFriends: []
|
|
}
|
|
})),
|
|
useAppearanceSettingsStore: vi.fn(() => ({
|
|
displayVRCPlusIconsAsAvatar: false
|
|
}))
|
|
}));
|
|
|
|
vi.mock('../../../stores', () => storeMocks);
|
|
|
|
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 (explicit currentUser)', () => {
|
|
let currentUser;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
currentUser = {
|
|
id: 'usr_me',
|
|
presence: { platform: 'standalonewindows' },
|
|
onlineFriends: [],
|
|
activeFriends: []
|
|
};
|
|
});
|
|
|
|
test('does not access stores when currentUser is passed (pure path)', () => {
|
|
userStatusClass(
|
|
{ id: 'usr_me', status: 'active', isFriend: true },
|
|
false,
|
|
currentUser
|
|
);
|
|
expect(storeMocks.useUserStore).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns null for undefined user', () => {
|
|
expect(userStatusClass(undefined, false, currentUser)).toBeNull();
|
|
});
|
|
|
|
test('returns current user style with status', () => {
|
|
const result = userStatusClass(
|
|
{
|
|
id: 'usr_me',
|
|
status: 'active',
|
|
isFriend: true
|
|
},
|
|
false,
|
|
currentUser
|
|
);
|
|
expect(result).toMatchObject({
|
|
'status-icon': true,
|
|
online: true,
|
|
mobile: false
|
|
});
|
|
});
|
|
|
|
test('returns mobile true for non-PC platform on current user', () => {
|
|
currentUser.presence = { platform: 'android' };
|
|
const result = userStatusClass(
|
|
{
|
|
id: 'usr_me',
|
|
status: 'active'
|
|
},
|
|
false,
|
|
currentUser
|
|
);
|
|
expect(result.mobile).toBe(true);
|
|
});
|
|
|
|
test('returns null for non-friend users', () => {
|
|
expect(
|
|
userStatusClass(
|
|
{
|
|
id: 'usr_other',
|
|
status: 'active',
|
|
isFriend: false
|
|
},
|
|
false,
|
|
currentUser
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
test('returns offline style for pending offline friend', () => {
|
|
const result = userStatusClass(
|
|
{ id: 'usr_other', isFriend: true, status: 'active' },
|
|
true,
|
|
currentUser
|
|
);
|
|
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
|
|
}, false, currentUser);
|
|
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: ''
|
|
}, false, currentUser);
|
|
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'
|
|
}, false, currentUser);
|
|
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'
|
|
}, false, currentUser);
|
|
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'
|
|
}, false, currentUser);
|
|
expect(result.mobile).toBeUndefined();
|
|
});
|
|
|
|
test('uses userId as fallback when id is not present', () => {
|
|
const result = userStatusClass({
|
|
userId: 'usr_me',
|
|
status: 'busy'
|
|
}, false, currentUser);
|
|
expect(result).toMatchObject({
|
|
'status-icon': true,
|
|
busy: true,
|
|
mobile: false
|
|
});
|
|
});
|
|
|
|
test('handles private location with empty state (temp fix branch)', () => {
|
|
currentUser.activeFriends = ['usr_f'];
|
|
const result = userStatusClass({
|
|
id: 'usr_f',
|
|
isFriend: true,
|
|
status: 'busy',
|
|
location: 'private',
|
|
state: ''
|
|
}, false, currentUser);
|
|
// activeFriends includes usr_f → active
|
|
expect(result.active).toBe(true);
|
|
});
|
|
|
|
test('handles private location temp fix → offline branch', () => {
|
|
currentUser.activeFriends = [];
|
|
const result = userStatusClass({
|
|
id: 'usr_f',
|
|
isFriend: true,
|
|
status: 'busy',
|
|
location: 'private',
|
|
state: ''
|
|
}, false, currentUser);
|
|
expect(result.offline).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('userImage (explicit settings)', () => {
|
|
test('does not access appearance store when setting is passed (pure path)', () => {
|
|
userImage(
|
|
{ thumbnailUrl: 'https://img.com/thumb' },
|
|
false,
|
|
'128',
|
|
false,
|
|
false
|
|
);
|
|
expect(storeMocks.useAppearanceSettingsStore).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns empty string for falsy user', () => {
|
|
expect(userImage(null, false, '128', false, false)).toBe('');
|
|
expect(userImage(undefined, false, '128', false, false)).toBe('');
|
|
});
|
|
|
|
test('returns profilePicOverrideThumbnail when available', () => {
|
|
const user = {
|
|
profilePicOverrideThumbnail: 'https://img.com/pic/256/thumb'
|
|
};
|
|
expect(userImage(user, false, '128', false, false)).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', false, false)).toBe(
|
|
'https://img.com/pic/64/thumb'
|
|
);
|
|
});
|
|
|
|
test('returns profilePicOverride when no thumbnail', () => {
|
|
const user = { profilePicOverride: 'https://img.com/full' };
|
|
expect(userImage(user, false, '128', false, false)).toBe(
|
|
'https://img.com/full'
|
|
);
|
|
});
|
|
|
|
test('returns thumbnailUrl as fallback', () => {
|
|
const user = { thumbnailUrl: 'https://img.com/thumb' };
|
|
expect(userImage(user, false, '128', false, false)).toBe(
|
|
'https://img.com/thumb'
|
|
);
|
|
});
|
|
|
|
test('returns currentAvatarThumbnailImageUrl as fallback', () => {
|
|
const user = {
|
|
currentAvatarThumbnailImageUrl:
|
|
'https://img.com/avatar/256/thumb'
|
|
};
|
|
expect(userImage(user, false, '128', false, false)).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', false, false)).toBe(
|
|
'https://img.com/avatar/64/thumb'
|
|
);
|
|
});
|
|
|
|
test('returns currentAvatarImageUrl as last resort', () => {
|
|
const user = {
|
|
currentAvatarImageUrl: 'https://img.com/avatar/full'
|
|
};
|
|
expect(userImage(user, false, '128', false, false)).toBe(
|
|
'https://img.com/avatar/full'
|
|
);
|
|
});
|
|
|
|
test('converts currentAvatarImageUrl for icon mode', () => {
|
|
const user = {
|
|
currentAvatarImageUrl: 'https://img.com/avatar/full'
|
|
};
|
|
expect(userImage(user, true, '128', false, false)).toBe(
|
|
'converted:https://img.com/avatar/full'
|
|
);
|
|
});
|
|
|
|
test('returns empty string when user has no image fields', () => {
|
|
expect(userImage({}, false, '128', false, false)).toBe('');
|
|
});
|
|
|
|
test('returns userIcon when displayVRCPlusIconsAsAvatar is true', () => {
|
|
const user = {
|
|
userIcon: 'https://img.com/icon',
|
|
thumbnailUrl: 'https://img.com/thumb'
|
|
};
|
|
expect(userImage(user, false, '128', false, true)).toBe(
|
|
'https://img.com/icon'
|
|
);
|
|
});
|
|
|
|
test('converts userIcon for icon mode when VRCPlus setting enabled', () => {
|
|
const user = { userIcon: 'https://img.com/icon' };
|
|
expect(userImage(user, true, '128', false, 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, false)).toBe(
|
|
'https://img.com/icon'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('userImageFull (explicit settings)', () => {
|
|
test('does not access appearance store when setting is passed (pure path)', () => {
|
|
userImageFull(
|
|
{ currentAvatarImageUrl: 'https://img.com/avatar' },
|
|
false
|
|
);
|
|
expect(storeMocks.useAppearanceSettingsStore).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns empty string for falsy user', () => {
|
|
expect(userImageFull(null, false)).toBe('');
|
|
});
|
|
|
|
test('returns profilePicOverride when available', () => {
|
|
const user = {
|
|
profilePicOverride: 'https://img.com/full',
|
|
currentAvatarImageUrl: 'https://img.com/avatar'
|
|
};
|
|
expect(userImageFull(user, false)).toBe('https://img.com/full');
|
|
});
|
|
|
|
test('returns currentAvatarImageUrl as fallback', () => {
|
|
const user = {
|
|
currentAvatarImageUrl: 'https://img.com/avatar'
|
|
};
|
|
expect(userImageFull(user, false)).toBe('https://img.com/avatar');
|
|
});
|
|
|
|
test('returns userIcon when VRCPlus setting enabled', () => {
|
|
const user = {
|
|
userIcon: 'https://img.com/icon',
|
|
profilePicOverride: 'https://img.com/full'
|
|
};
|
|
expect(userImageFull(user, true)).toBe('https://img.com/icon');
|
|
});
|
|
});
|
|
});
|