This commit is contained in:
pa
2026-03-06 04:22:16 +09:00
parent 761ef5ad6b
commit 787f25705e
55 changed files with 6437 additions and 506 deletions

View File

@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
// Mock AppDebug
vi.mock('../../../service/appConfig', () => ({
AppDebug: { endpointDomain: 'https://api.vrchat.cloud/api/1' }
}));
// Mock transitive deps
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
import { convertFileUrlToImageUrl, debounce } from '../common';
describe('convertFileUrlToImageUrl', () => {
test('converts standard file URL to image URL', () => {
const url =
'https://api.vrchat.cloud/api/1/file/file_abc123-def456/1/file';
const result = convertFileUrlToImageUrl(url);
expect(result).toBe(
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/1/128'
);
});
test('converts URL without trailing /file', () => {
const url = 'https://api.vrchat.cloud/api/1/file/file_abc123-def456/1';
const result = convertFileUrlToImageUrl(url);
expect(result).toBe(
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/1/128'
);
});
test('converts URL with trailing slash', () => {
const url = 'https://api.vrchat.cloud/api/1/file/file_abc123-def456/2/';
const result = convertFileUrlToImageUrl(url);
expect(result).toBe(
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/2/128'
);
});
test('accepts custom resolution', () => {
const url =
'https://api.vrchat.cloud/api/1/file/file_abc123-def456/1/file';
const result = convertFileUrlToImageUrl(url, 256);
expect(result).toBe(
'https://api.vrchat.cloud/api/1/image/file_abc123-def456/1/256'
);
});
test('returns original URL when pattern does not match', () => {
const url = 'https://example.com/some/other/path';
expect(convertFileUrlToImageUrl(url)).toBe(url);
});
test('returns empty string for empty input', () => {
expect(convertFileUrlToImageUrl('')).toBe('');
});
test('returns empty string for null input', () => {
expect(convertFileUrlToImageUrl(null)).toBe('');
});
test('returns empty string for undefined input', () => {
expect(convertFileUrlToImageUrl(undefined)).toBe('');
});
test('handles URL with /file/file path', () => {
const url =
'https://api.vrchat.cloud/api/1/file/file_aabbccdd-1234-5678-9012-abcdef123456/5/file/';
const result = convertFileUrlToImageUrl(url, 64);
expect(result).toBe(
'https://api.vrchat.cloud/api/1/image/file_aabbccdd-1234-5678-9012-abcdef123456/5/64'
);
});
});
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('delays function execution', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
test('resets timer on subsequent calls', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
vi.advanceTimersByTime(50);
debounced();
vi.advanceTimersByTime(50);
// Only 50ms since last call, should not fire yet
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(fn).toHaveBeenCalledOnce();
});
test('passes arguments to debounced function', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced('arg1', 'arg2');
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
});
test('uses latest arguments when called multiple times', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced('first');
debounced('second');
debounced('third');
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('third');
});
test('can be called again after execution', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
debounced();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -2,7 +2,10 @@ import {
compareByCreatedAt,
compareByCreatedAtAscending,
compareByDisplayName,
compareById,
compareByFriendOrder,
compareByLastActive,
compareByLastActiveRef,
compareByLastSeen,
compareByLocation,
compareByLocationAt,
@@ -376,6 +379,103 @@ describe('Compare Functions', () => {
});
});
describe('compareById', () => {
test('compares objects by id property ascending', () => {
const a = { id: 'usr_aaa' };
const b = { id: 'usr_bbb' };
expect(compareById(a, b)).toBeLessThan(0);
expect(compareById(b, a)).toBeGreaterThan(0);
});
test('returns 0 for equal ids', () => {
const a = { id: 'usr_123' };
const b = { id: 'usr_123' };
expect(compareById(a, b)).toBe(0);
});
test('handles non-string id properties', () => {
expect(compareById({ id: null }, { id: 'usr_1' })).toBe(0);
expect(compareById({}, { id: 'usr_1' })).toBe(0);
expect(compareById({ id: 123 }, { id: 'usr_1' })).toBe(0);
});
});
describe('compareByLastActiveRef', () => {
test('compares online users by $online_for descending', () => {
const a = { state: 'online', $online_for: 100 };
const b = { state: 'online', $online_for: 200 };
// a.$online_for < b.$online_for → 1 (b is more recent)
expect(compareByLastActiveRef(a, b)).toBe(1);
expect(compareByLastActiveRef(b, a)).toBe(-1);
});
test('falls back to last_login when $online_for is equal', () => {
const a = {
state: 'online',
$online_for: 100,
last_login: '2023-01-01'
};
const b = {
state: 'online',
$online_for: 100,
last_login: '2023-01-02'
};
expect(compareByLastActiveRef(a, b)).toBe(1);
expect(compareByLastActiveRef(b, a)).toBe(-1);
});
test('compares non-online users by last_activity descending', () => {
const a = {
state: 'offline',
last_activity: '2023-01-01'
};
const b = {
state: 'offline',
last_activity: '2023-01-02'
};
expect(compareByLastActiveRef(a, b)).toBe(1);
expect(compareByLastActiveRef(b, a)).toBe(-1);
});
test('compares mixed online states by last_activity', () => {
const a = {
state: 'online',
last_activity: '2023-06-01'
};
const b = {
state: 'offline',
last_activity: '2023-01-01'
};
// not both online, so compares by last_activity
expect(compareByLastActiveRef(a, b)).toBe(-1);
});
});
describe('compareByFriendOrder', () => {
test('compares by $friendNumber descending', () => {
const a = { $friendNumber: 10 };
const b = { $friendNumber: 20 };
// b.$friendNumber - a.$friendNumber = 10
expect(compareByFriendOrder(a, b)).toBe(10);
expect(compareByFriendOrder(b, a)).toBe(-10);
});
test('returns 0 for equal $friendNumber', () => {
const a = { $friendNumber: 5 };
const b = { $friendNumber: 5 };
expect(compareByFriendOrder(a, b)).toBe(0);
});
test('handles undefined inputs', () => {
expect(compareByFriendOrder(undefined, { $friendNumber: 1 })).toBe(
0
);
expect(compareByFriendOrder({ $friendNumber: 1 }, undefined)).toBe(
0
);
});
});
describe('edge cases and boundary conditions', () => {
test('handles null objects', () => {
// compareByName doesn't handle null objects - it will throw

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { formatCsvField, formatCsvRow, needsCsvQuotes } from '../csv';
describe('needsCsvQuotes', () => {
it('returns false for plain text', () => {
expect(needsCsvQuotes('hello')).toBe(false);
});
it('returns true for text containing commas', () => {
expect(needsCsvQuotes('hello,world')).toBe(true);
});
it('returns true for text containing double quotes', () => {
expect(needsCsvQuotes('say "hi"')).toBe(true);
});
it('returns true for text with control characters', () => {
expect(needsCsvQuotes('line\nbreak')).toBe(true);
expect(needsCsvQuotes('tab\there')).toBe(true);
expect(needsCsvQuotes('\x00null')).toBe(true);
});
it('returns false for empty string', () => {
expect(needsCsvQuotes('')).toBe(false);
});
});
describe('formatCsvField', () => {
it('returns empty string for null', () => {
expect(formatCsvField(null)).toBe('');
});
it('returns empty string for undefined', () => {
expect(formatCsvField(undefined)).toBe('');
});
it('returns plain string unchanged', () => {
expect(formatCsvField('hello')).toBe('hello');
});
it('converts numbers to strings', () => {
expect(formatCsvField(42)).toBe('42');
});
it('wraps text with commas in double quotes', () => {
expect(formatCsvField('a,b')).toBe('"a,b"');
});
it('escapes existing double quotes by doubling', () => {
expect(formatCsvField('say "hi"')).toBe('"say ""hi"""');
});
it('wraps text with newlines in double quotes', () => {
expect(formatCsvField('line\nbreak')).toBe('"line\nbreak"');
});
});
describe('formatCsvRow', () => {
it('formats selected fields from an object', () => {
const obj = {
id: 'avtr_123',
name: 'Test Avatar',
authorName: 'Author'
};
expect(formatCsvRow(obj, ['id', 'name'])).toBe('avtr_123,Test Avatar');
});
it('handles missing fields as empty strings', () => {
const obj = { id: 'avtr_123' };
expect(formatCsvRow(obj, ['id', 'name'])).toBe('avtr_123,');
});
it('escapes fields that need quoting', () => {
const obj = { id: 'avtr_123', name: 'Test, Avatar' };
expect(formatCsvRow(obj, ['id', 'name'])).toBe(
'avtr_123,"Test, Avatar"'
);
});
it('handles null obj gracefully', () => {
expect(formatCsvRow(null, ['id', 'name'])).toBe(',');
});
it('returns empty string for no fields', () => {
expect(formatCsvRow({ id: '1' }, [])).toBe('');
});
});

View File

@@ -1,28 +1,213 @@
import { isFriendOnline, sortStatus } from '../friend';
import { getFriendsSortFunction, isFriendOnline, sortStatus } from '../friend';
describe('Friend Utils', () => {
describe('sortStatus', () => {
test('handles same status', () => {
expect(sortStatus('active', 'active')).toBe(0);
expect(sortStatus('join me', 'join me')).toBe(0);
const statuses = ['join me', 'active', 'ask me', 'busy', 'offline'];
test('returns 0 for same status', () => {
for (const s of statuses) {
expect(sortStatus(s, s)).toBe(0);
}
});
test('handles unknown status', () => {
test('sorts statuses in priority order: join me > active > ask me > busy > offline', () => {
// Higher priority status vs lower priority → negative
expect(sortStatus('join me', 'active')).toBe(-1);
expect(sortStatus('join me', 'ask me')).toBe(-1);
expect(sortStatus('join me', 'busy')).toBe(-1);
expect(sortStatus('join me', 'offline')).toBe(-1);
expect(sortStatus('active', 'ask me')).toBe(-1);
expect(sortStatus('active', 'busy')).toBe(-1);
expect(sortStatus('active', 'offline')).toBe(-1);
expect(sortStatus('ask me', 'busy')).toBe(-1);
expect(sortStatus('ask me', 'offline')).toBe(-1);
expect(sortStatus('busy', 'offline')).toBe(-1);
});
test('lower priority vs higher priority → positive', () => {
expect(sortStatus('active', 'join me')).toBe(1);
expect(sortStatus('busy', 'active')).toBe(1);
expect(sortStatus('offline', 'join me')).toBe(1);
expect(sortStatus('offline', 'busy')).toBe(1);
});
test('returns 0 for unknown statuses', () => {
expect(sortStatus('unknown', 'active')).toBe(0);
// @ts-ignore
expect(sortStatus('active', 'unknown')).toBe(0);
expect(sortStatus(null, 'active')).toBe(0);
});
});
describe('isFriendOnline', () => {
test('detects online friends', () => {
const friend = { state: 'online', ref: { location: 'world' } };
expect(isFriendOnline(friend)).toBe(true);
test('returns true for online friends', () => {
expect(
isFriendOnline({ state: 'online', ref: { location: 'wrld_1' } })
).toBe(true);
});
test('handles missing data', () => {
test('returns true for non-online friends with non-private location', () => {
// This is the "wat" case in the code
expect(
isFriendOnline({
state: 'active',
ref: { location: 'wrld_1' }
})
).toBe(true);
});
test('returns false for friends in private with non-online state', () => {
expect(
isFriendOnline({
state: 'active',
ref: { location: 'private' }
})
).toBe(false);
});
test('returns false for undefined or missing ref', () => {
expect(isFriendOnline(undefined)).toBe(false);
expect(isFriendOnline({})).toBe(false);
expect(isFriendOnline({ state: 'online' })).toBe(false);
});
});
describe('getFriendsSortFunction', () => {
test('returns a comparator function', () => {
const fn = getFriendsSortFunction(['Sort Alphabetically']);
expect(typeof fn).toBe('function');
});
test('sorts alphabetically by name', () => {
const fn = getFriendsSortFunction(['Sort Alphabetically']);
const a = { name: 'Alice', ref: {} };
const b = { name: 'Bob', ref: {} };
expect(fn(a, b)).toBeLessThan(0);
expect(fn(b, a)).toBeGreaterThan(0);
});
test('sorts private to bottom', () => {
const fn = getFriendsSortFunction(['Sort Private to Bottom']);
const pub = { ref: { location: 'wrld_1' } };
const priv = { ref: { location: 'private' } };
expect(fn(priv, pub)).toBe(1);
expect(fn(pub, priv)).toBe(-1);
});
test('sorts by status', () => {
const fn = getFriendsSortFunction(['Sort by Status']);
const joinMe = { ref: { status: 'join me', state: 'online' } };
const busy = { ref: { status: 'busy', state: 'online' } };
expect(fn(joinMe, busy)).toBeLessThan(0);
});
test('sorts by last active', () => {
const fn = getFriendsSortFunction(['Sort by Last Active']);
const a = {
state: 'offline',
ref: { last_activity: '2023-01-01' }
};
const b = {
state: 'offline',
ref: { last_activity: '2023-06-01' }
};
expect(fn(a, b)).toBe(1);
});
test('sorts by last seen', () => {
const fn = getFriendsSortFunction(['Sort by Last Seen']);
const a = { ref: { $lastSeen: '2023-01-01' } };
const b = { ref: { $lastSeen: '2023-06-01' } };
expect(fn(a, b)).toBe(1);
});
test('sorts by time in instance', () => {
const fn = getFriendsSortFunction(['Sort by Time in Instance']);
const a = {
state: 'online',
pendingOffline: false,
ref: { $location_at: 100, location: 'wrld_1' }
};
const b = {
state: 'online',
pendingOffline: false,
ref: { $location_at: 200, location: 'wrld_2' }
};
// compareByLocationAt(b.ref, a.ref): b.$location_at(200) > a.$location_at(100) → 1
expect(fn(a, b)).toBe(1);
});
test('sorts pending offline to bottom for time in instance', () => {
const fn = getFriendsSortFunction(['Sort by Time in Instance']);
const pending = {
pendingOffline: true,
ref: { $location_at: 100 }
};
const active = {
pendingOffline: false,
state: 'online',
ref: { $location_at: 200 }
};
expect(fn(pending, active)).toBe(1);
expect(fn(active, pending)).toBe(-1);
});
test('sorts by location', () => {
const fn = getFriendsSortFunction(['Sort by Location']);
const a = { state: 'online', ref: { location: 'aaa' } };
const b = { state: 'online', ref: { location: 'zzz' } };
expect(fn(a, b)).toBeLessThan(0);
});
test('None sort returns 0', () => {
const fn = getFriendsSortFunction(['None']);
const a = { name: 'Zack' };
const b = { name: 'Alice' };
expect(fn(a, b)).toBe(0);
});
test('applies multiple sort methods in order (tie-breaking)', () => {
const fn = getFriendsSortFunction([
'Sort by Status',
'Sort Alphabetically'
]);
// Same status → tie → falls to alphabetical
const a = {
name: 'Alice',
ref: { status: 'active', state: 'online' }
};
const b = {
name: 'Bob',
ref: { status: 'active', state: 'online' }
};
expect(fn(a, b)).toBeLessThan(0);
});
test('first sort wins when not tied', () => {
const fn = getFriendsSortFunction([
'Sort by Status',
'Sort Alphabetically'
]);
const joinMe = {
name: 'Zack',
ref: { status: 'join me', state: 'online' }
};
const busy = {
name: 'Alice',
ref: { status: 'busy', state: 'online' }
};
// status differs → alphabetical not reached
expect(fn(joinMe, busy)).toBeLessThan(0);
});
test('handles empty sort methods array', () => {
const fn = getFriendsSortFunction([]);
const a = { name: 'Alice' };
const b = { name: 'Bob' };
// No sort functions → result is undefined from loop
expect(fn(a, b)).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,102 @@
import { hasGroupModerationPermission, hasGroupPermission } from '../group';
// Mock transitive deps to avoid import errors
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
describe('Group Utils', () => {
describe('hasGroupPermission', () => {
test('returns true when permission is in list', () => {
const ref = {
myMember: {
permissions: ['group-bans-manage', 'group-audit-view']
}
};
expect(hasGroupPermission(ref, 'group-bans-manage')).toBe(true);
});
test('returns true when wildcard permission is present', () => {
const ref = { myMember: { permissions: ['*'] } };
expect(hasGroupPermission(ref, 'group-bans-manage')).toBe(true);
});
test('returns false when permission is not in list', () => {
const ref = {
myMember: { permissions: ['group-bans-manage'] }
};
expect(hasGroupPermission(ref, 'group-audit-view')).toBe(false);
});
test('returns false when permissions array is empty', () => {
const ref = { myMember: { permissions: [] } };
expect(hasGroupPermission(ref, 'group-bans-manage')).toBe(false);
});
test('returns false when myMember is null', () => {
expect(hasGroupPermission({ myMember: null }, 'x')).toBe(false);
});
test('returns false when ref is null', () => {
expect(hasGroupPermission(null, 'x')).toBe(false);
});
test('returns false when ref is undefined', () => {
expect(hasGroupPermission(undefined, 'x')).toBe(false);
});
test('returns false when permissions is missing', () => {
const ref = { myMember: {} };
expect(hasGroupPermission(ref, 'x')).toBe(false);
});
});
describe('hasGroupModerationPermission', () => {
test('returns true for any single moderation permission', () => {
const permissions = [
'group-invites-manage',
'group-moderates-manage',
'group-audit-view',
'group-bans-manage',
'group-data-manage',
'group-members-manage',
'group-members-remove',
'group-roles-assign',
'group-roles-manage',
'group-default-role-manage'
];
for (const perm of permissions) {
const ref = { myMember: { permissions: [perm] } };
expect(hasGroupModerationPermission(ref)).toBe(true);
}
});
test('returns true for wildcard', () => {
const ref = { myMember: { permissions: ['*'] } };
expect(hasGroupModerationPermission(ref)).toBe(true);
});
test('returns false for non-moderation permissions', () => {
const ref = {
myMember: {
permissions: ['group-announcements-manage']
}
};
expect(hasGroupModerationPermission(ref)).toBe(false);
});
test('returns false for empty permissions', () => {
const ref = { myMember: { permissions: [] } };
expect(hasGroupModerationPermission(ref)).toBe(false);
});
test('returns false for null ref', () => {
expect(hasGroupModerationPermission(null)).toBe(false);
});
});
});

View File

@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
// Mock transitive deps to avoid i18n init errors
vi.mock('vue-sonner', () => ({
toast: { error: vi.fn() }
}));
vi.mock('../../../service/request', () => ({
$throw: vi.fn()
}));
vi.mock('../../../service/appConfig', () => ({
AppDebug: { endpointDomain: 'https://api.vrchat.cloud/api/1' }
}));
vi.mock('../../utils/index.js', () => ({
extractFileId: vi.fn()
}));
vi.mock('../../../api', () => ({
imageRequest: {}
}));
import { toast } from 'vue-sonner';
import { handleImageUploadInput, withUploadTimeout } from '../imageUpload';
// ─── withUploadTimeout ───────────────────────────────────────────────
describe('withUploadTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('resolves if promise resolves before timeout', async () => {
const promise = withUploadTimeout(Promise.resolve('done'));
await expect(promise).resolves.toBe('done');
});
test('rejects with timeout error if promise is too slow', async () => {
const neverResolves = new Promise(() => {});
const result = withUploadTimeout(neverResolves);
vi.advanceTimersByTime(30_000);
await expect(result).rejects.toThrow('Upload timed out');
});
test('resolves if promise finishes just before timeout', async () => {
const slowPromise = new Promise((resolve) => {
setTimeout(() => resolve('just in time'), 29_999);
});
const result = withUploadTimeout(slowPromise);
vi.advanceTimersByTime(29_999);
await expect(result).resolves.toBe('just in time');
});
test('rejects if underlying promise rejects', async () => {
const failingPromise = Promise.reject(new Error('upload failed'));
await expect(withUploadTimeout(failingPromise)).rejects.toThrow(
'upload failed'
);
});
});
// ─── handleImageUploadInput ──────────────────────────────────────────
describe('handleImageUploadInput', () => {
const makeFile = (size = 1000, type = 'image/png') => ({
size,
type
});
const makeEvent = (file) => ({
target: { files: file ? [file] : [] }
});
beforeEach(() => {
vi.clearAllMocks();
});
test('returns null file when no files in event', () => {
const { file } = handleImageUploadInput({ target: { files: [] } });
expect(file).toBeNull();
});
test('returns null file when event has no target', () => {
const { file } = handleImageUploadInput({});
expect(file).toBeNull();
});
test('returns file for valid image within size limit', () => {
const mockFile = makeFile(5000, 'image/png');
const { file } = handleImageUploadInput(makeEvent(mockFile));
expect(file).toBe(mockFile);
});
test('returns null file when file exceeds maxSize', () => {
const mockFile = makeFile(20_000_001, 'image/png');
const { file } = handleImageUploadInput(makeEvent(mockFile));
expect(file).toBeNull();
});
test('shows toast error when file exceeds maxSize and tooLargeMessage provided', () => {
const mockFile = makeFile(20_000_001, 'image/png');
handleImageUploadInput(makeEvent(mockFile), {
tooLargeMessage: 'File too large!'
});
expect(toast.error).toHaveBeenCalledWith('File too large!');
});
test('supports function as tooLargeMessage', () => {
const mockFile = makeFile(20_000_001, 'image/png');
handleImageUploadInput(makeEvent(mockFile), {
tooLargeMessage: () => 'Dynamic error'
});
expect(toast.error).toHaveBeenCalledWith('Dynamic error');
});
test('returns null file when file type does not match acceptPattern', () => {
const mockFile = makeFile(1000, 'text/plain');
const { file } = handleImageUploadInput(makeEvent(mockFile));
expect(file).toBeNull();
});
test('shows toast error for invalid type when invalidTypeMessage provided', () => {
const mockFile = makeFile(1000, 'text/plain');
handleImageUploadInput(makeEvent(mockFile), {
invalidTypeMessage: 'Wrong type!'
});
expect(toast.error).toHaveBeenCalledWith('Wrong type!');
});
test('respects custom maxSize', () => {
const mockFile = makeFile(600, 'image/png');
const { file } = handleImageUploadInput(makeEvent(mockFile), {
maxSize: 500
});
expect(file).toBeNull();
});
test('respects custom acceptPattern as string', () => {
const mockFile = makeFile(1000, 'video/mp4');
const { file } = handleImageUploadInput(makeEvent(mockFile), {
acceptPattern: 'video.*'
});
expect(file).toBe(mockFile);
});
test('returns clearInput function', () => {
const mockFile = makeFile(1000, 'image/png');
const { clearInput } = handleImageUploadInput(makeEvent(mockFile));
expect(typeof clearInput).toBe('function');
});
test('calls onClear callback when clearing', () => {
const onClear = vi.fn();
const { clearInput } = handleImageUploadInput(
{ target: { files: [] } },
{ onClear }
);
// clearInput is called automatically for empty files, but let's call explicitly
clearInput();
expect(onClear).toHaveBeenCalled();
});
test('reads files from dataTransfer when target.files absent', () => {
const mockFile = makeFile(1000, 'image/png');
const event = { dataTransfer: { files: [mockFile] } };
const { file } = handleImageUploadInput(event);
expect(file).toBe(mockFile);
});
});

View File

@@ -0,0 +1,143 @@
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: {}
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn() }
}));
import { buildLegacyInstanceTag } from '../instance';
const base = {
instanceName: '12345',
userId: 'usr_test',
accessType: 'public',
region: 'US West'
};
describe('buildLegacyInstanceTag', () => {
test('public instance with US West region', () => {
expect(buildLegacyInstanceTag(base)).toBe('12345~region(us)');
});
test('public instance with US East region', () => {
expect(buildLegacyInstanceTag({ ...base, region: 'US East' })).toBe(
'12345~region(use)'
);
});
test('public instance with Europe region', () => {
expect(buildLegacyInstanceTag({ ...base, region: 'Europe' })).toBe(
'12345~region(eu)'
);
});
test('public instance with Japan region', () => {
expect(buildLegacyInstanceTag({ ...base, region: 'Japan' })).toBe(
'12345~region(jp)'
);
});
test('friends+ adds hidden tag', () => {
expect(
buildLegacyInstanceTag({ ...base, accessType: 'friends+' })
).toBe('12345~hidden(usr_test)~region(us)');
});
test('friends adds friends tag', () => {
expect(buildLegacyInstanceTag({ ...base, accessType: 'friends' })).toBe(
'12345~friends(usr_test)~region(us)'
);
});
test('invite adds private tag and canRequestInvite', () => {
expect(buildLegacyInstanceTag({ ...base, accessType: 'invite+' })).toBe(
'12345~private(usr_test)~canRequestInvite~region(us)'
);
});
test('invite (no +) adds private tag without canRequestInvite', () => {
expect(buildLegacyInstanceTag({ ...base, accessType: 'invite' })).toBe(
'12345~private(usr_test)~region(us)'
);
});
test('group adds group and groupAccessType tags', () => {
expect(
buildLegacyInstanceTag({
...base,
accessType: 'group',
groupId: 'grp_abc',
groupAccessType: 'plus'
})
).toBe('12345~group(grp_abc)~groupAccessType(plus)~region(us)');
});
test('group with ageGate appends ~ageGate', () => {
expect(
buildLegacyInstanceTag({
...base,
accessType: 'group',
groupId: 'grp_abc',
groupAccessType: 'members',
ageGate: true
})
).toBe(
'12345~group(grp_abc)~groupAccessType(members)~ageGate~region(us)'
);
});
test('ageGate ignored for non-group access types', () => {
expect(buildLegacyInstanceTag({ ...base, ageGate: true })).toBe(
'12345~region(us)'
);
});
test('strict appended for invite access type', () => {
expect(
buildLegacyInstanceTag({
...base,
accessType: 'invite',
strict: true
})
).toBe('12345~private(usr_test)~region(us)~strict');
});
test('strict appended for friends access type', () => {
expect(
buildLegacyInstanceTag({
...base,
accessType: 'friends',
strict: true
})
).toBe('12345~friends(usr_test)~region(us)~strict');
});
test('strict ignored for public access type', () => {
expect(buildLegacyInstanceTag({ ...base, strict: true })).toBe(
'12345~region(us)'
);
});
test('strict ignored for friends+ access type', () => {
expect(
buildLegacyInstanceTag({
...base,
accessType: 'friends+',
strict: true
})
).toBe('12345~hidden(usr_test)~region(us)');
});
test('empty instanceName produces no leading segment', () => {
expect(buildLegacyInstanceTag({ ...base, instanceName: '' })).toBe(
'~region(us)'
);
});
test('unknown region produces no region tag', () => {
expect(buildLegacyInstanceTag({ ...base, region: 'Mars' })).toBe(
'12345'
);
});
});

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
// Mock stores
vi.mock('../../../stores', () => ({
useFriendStore: vi.fn(),
useInstanceStore: vi.fn(),
useLocationStore: vi.fn(),
useUserStore: vi.fn()
}));
// Mock transitive deps
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
import {
useFriendStore,
useInstanceStore,
useLocationStore,
useUserStore
} from '../../../stores';
import { checkCanInvite, checkCanInviteSelf } from '../invite';
describe('Invite Utils', () => {
beforeEach(() => {
useUserStore.mockReturnValue({
currentUser: { id: 'usr_me' }
});
useLocationStore.mockReturnValue({
lastLocation: { location: 'wrld_last:12345' }
});
useInstanceStore.mockReturnValue({
cachedInstances: new Map()
});
useFriendStore.mockReturnValue({
friends: new Map()
});
});
describe('checkCanInvite', () => {
test('returns false for empty location', () => {
expect(checkCanInvite('')).toBe(false);
expect(checkCanInvite(null)).toBe(false);
});
test('returns true for public instance', () => {
expect(checkCanInvite('wrld_123:instance')).toBe(true);
});
test('returns true for group instance', () => {
expect(
checkCanInvite(
'wrld_123:instance~group(grp_123)~groupAccessType(public)'
)
).toBe(true);
});
test('returns true for own instance', () => {
expect(checkCanInvite('wrld_123:instance~private(usr_me)')).toBe(
true
);
});
test('returns false for invite-only instance owned by another', () => {
expect(checkCanInvite('wrld_123:instance~private(usr_other)')).toBe(
false
);
});
test('returns false for friends-only instance', () => {
expect(checkCanInvite('wrld_123:instance~friends(usr_other)')).toBe(
false
);
});
test('returns true for friends+ instance if current location matches', () => {
const location = 'wrld_123:instance~hidden(usr_other)';
useLocationStore.mockReturnValue({
lastLocation: { location }
});
expect(checkCanInvite(location)).toBe(true);
});
test('returns false for friends+ instance if not in that location', () => {
expect(checkCanInvite('wrld_123:instance~hidden(usr_other)')).toBe(
false
);
});
test('returns false for closed instance', () => {
const location = 'wrld_123:instance';
useInstanceStore.mockReturnValue({
cachedInstances: new Map([
[location, { closedAt: '2024-01-01' }]
])
});
expect(checkCanInvite(location)).toBe(false);
});
});
describe('checkCanInviteSelf', () => {
test('returns false for empty location', () => {
expect(checkCanInviteSelf('')).toBe(false);
expect(checkCanInviteSelf(null)).toBe(false);
});
test('returns true for own instance', () => {
expect(
checkCanInviteSelf('wrld_123:instance~private(usr_me)')
).toBe(true);
});
test('returns true for public instance', () => {
expect(checkCanInviteSelf('wrld_123:instance')).toBe(true);
});
test('returns true for friends-only instance if user is a friend', () => {
useFriendStore.mockReturnValue({
friends: new Map([['usr_owner', {}]])
});
expect(
checkCanInviteSelf('wrld_123:instance~friends(usr_owner)')
).toBe(true);
});
test('returns false for friends-only instance if user is not a friend', () => {
expect(
checkCanInviteSelf('wrld_123:instance~friends(usr_other)')
).toBe(false);
});
test('returns false for closed instance', () => {
const location = 'wrld_123:instance';
useInstanceStore.mockReturnValue({
cachedInstances: new Map([
[location, { closedAt: '2024-01-01' }]
])
});
expect(checkCanInviteSelf(location)).toBe(false);
});
test('returns true for invite instance (not owned, not closed)', () => {
expect(
checkCanInviteSelf('wrld_123:instance~private(usr_other)')
).toBe(true);
});
});
});

View File

@@ -1,4 +1,10 @@
import { displayLocation, parseLocation } from '../locationParser';
import {
displayLocation,
parseLocation,
resolveRegion,
translateAccessType
} from '../locationParser';
import { accessTypeLocaleKeyMap } from '../../constants';
describe('Location Utils', () => {
describe('parseLocation', () => {
@@ -408,4 +414,98 @@ describe('Location Utils', () => {
});
});
});
describe('resolveRegion', () => {
test('returns empty string for offline', () => {
const L = parseLocation('offline');
expect(resolveRegion(L)).toBe('');
});
test('returns empty string for private', () => {
const L = parseLocation('private');
expect(resolveRegion(L)).toBe('');
});
test('returns empty string for traveling', () => {
const L = parseLocation('traveling');
expect(resolveRegion(L)).toBe('');
});
test('returns explicit region when present', () => {
const L = parseLocation('wrld_12345:67890~region(eu)');
expect(resolveRegion(L)).toBe('eu');
});
test('defaults to us when instance exists but no region', () => {
const L = parseLocation('wrld_12345:67890');
expect(resolveRegion(L)).toBe('us');
});
test('returns empty string for world-only (no instance)', () => {
const L = parseLocation('wrld_12345');
expect(resolveRegion(L)).toBe('');
});
test('returns jp region', () => {
const L = parseLocation('wrld_12345:67890~region(jp)');
expect(resolveRegion(L)).toBe('jp');
});
});
describe('translateAccessType', () => {
// Simple mock translation: returns the key itself
const t = (key) => key;
test('returns raw name when not in keyMap', () => {
expect(
translateAccessType('unknown', t, accessTypeLocaleKeyMap)
).toBe('unknown');
});
test('translates public', () => {
expect(
translateAccessType('public', t, accessTypeLocaleKeyMap)
).toBe(accessTypeLocaleKeyMap['public']);
});
test('translates invite', () => {
expect(
translateAccessType('invite', t, accessTypeLocaleKeyMap)
).toBe(accessTypeLocaleKeyMap['invite']);
});
test('translates friends', () => {
expect(
translateAccessType('friends', t, accessTypeLocaleKeyMap)
).toBe(accessTypeLocaleKeyMap['friends']);
});
test('translates friends+', () => {
expect(
translateAccessType('friends+', t, accessTypeLocaleKeyMap)
).toBe(accessTypeLocaleKeyMap['friends+']);
});
test('prefixes Group for groupPublic', () => {
const result = translateAccessType(
'groupPublic',
t,
accessTypeLocaleKeyMap
);
expect(result).toBe(
`${accessTypeLocaleKeyMap['group']} ${accessTypeLocaleKeyMap['groupPublic']}`
);
});
test('prefixes Group for groupPlus', () => {
const result = translateAccessType(
'groupPlus',
t,
accessTypeLocaleKeyMap
);
expect(result).toBe(
`${accessTypeLocaleKeyMap['group']} ${accessTypeLocaleKeyMap['groupPlus']}`
);
});
});
});

View File

@@ -0,0 +1,228 @@
import { describe, expect, test } from 'vitest';
import {
displayLocation,
parseLocation,
resolveRegion,
translateAccessType
} from '../locationParser';
// ─── parseLocation ───────────────────────────────────────────────────
describe('parseLocation', () => {
test('returns offline context', () => {
const ctx = parseLocation('offline');
expect(ctx.isOffline).toBe(true);
expect(ctx.isPrivate).toBe(false);
expect(ctx.worldId).toBe('');
});
test('handles offline:offline variant', () => {
expect(parseLocation('offline:offline').isOffline).toBe(true);
});
test('returns private context', () => {
const ctx = parseLocation('private');
expect(ctx.isPrivate).toBe(true);
expect(ctx.isOffline).toBe(false);
});
test('handles private:private variant', () => {
expect(parseLocation('private:private').isPrivate).toBe(true);
});
test('returns traveling context', () => {
const ctx = parseLocation('traveling');
expect(ctx.isTraveling).toBe(true);
});
test('handles traveling:traveling variant', () => {
expect(parseLocation('traveling:traveling').isTraveling).toBe(true);
});
test('parses public instance', () => {
const ctx = parseLocation('wrld_abc:12345');
expect(ctx.worldId).toBe('wrld_abc');
expect(ctx.instanceId).toBe('12345');
expect(ctx.instanceName).toBe('12345');
expect(ctx.accessType).toBe('public');
expect(ctx.isRealInstance).toBe(true);
});
test('parses friends instance', () => {
const ctx = parseLocation(
'wrld_abc:12345~friends(usr_owner)~region(eu)'
);
expect(ctx.accessType).toBe('friends');
expect(ctx.friendsId).toBe('usr_owner');
expect(ctx.userId).toBe('usr_owner');
expect(ctx.region).toBe('eu');
});
test('parses friends+ (hidden) instance', () => {
const ctx = parseLocation('wrld_abc:12345~hidden(usr_owner)');
expect(ctx.accessType).toBe('friends+');
expect(ctx.hiddenId).toBe('usr_owner');
expect(ctx.userId).toBe('usr_owner');
});
test('parses invite instance', () => {
const ctx = parseLocation('wrld_abc:12345~private(usr_owner)');
expect(ctx.accessType).toBe('invite');
expect(ctx.privateId).toBe('usr_owner');
});
test('parses invite+ instance', () => {
const ctx = parseLocation(
'wrld_abc:12345~private(usr_owner)~canRequestInvite'
);
expect(ctx.accessType).toBe('invite+');
expect(ctx.canRequestInvite).toBe(true);
});
test('parses group instance', () => {
const ctx = parseLocation(
'wrld_abc:12345~group(grp_xyz)~groupAccessType(public)'
);
expect(ctx.accessType).toBe('group');
expect(ctx.groupId).toBe('grp_xyz');
expect(ctx.groupAccessType).toBe('public');
expect(ctx.accessTypeName).toBe('groupPublic');
});
test('parses group plus access type', () => {
const ctx = parseLocation(
'wrld_abc:12345~group(grp_xyz)~groupAccessType(plus)'
);
expect(ctx.accessTypeName).toBe('groupPlus');
});
test('handles strict and ageGate', () => {
const ctx = parseLocation('wrld_abc:12345~strict~ageGate');
expect(ctx.strict).toBe(true);
expect(ctx.ageGate).toBe(true);
});
test('extracts shortName from URL', () => {
const ctx = parseLocation(
'wrld_abc:12345~friends(usr_a)&shortName=myShort'
);
expect(ctx.shortName).toBe('myShort');
expect(ctx.accessType).toBe('friends');
});
test('handles world-only tag (no colon)', () => {
const ctx = parseLocation('wrld_abc');
expect(ctx.worldId).toBe('wrld_abc');
expect(ctx.instanceId).toBe('');
expect(ctx.isRealInstance).toBe(true);
});
test('handles null/empty input', () => {
const ctx = parseLocation('');
expect(ctx.isOffline).toBe(false);
expect(ctx.isPrivate).toBe(false);
expect(ctx.worldId).toBe('');
});
test('handles local instance (non-real)', () => {
const ctx = parseLocation('local:12345');
expect(ctx.isRealInstance).toBe(false);
expect(ctx.worldId).toBe('');
});
});
// ─── displayLocation ─────────────────────────────────────────────────
describe('displayLocation', () => {
test('shows Offline for offline location', () => {
expect(displayLocation('offline', 'World Name')).toBe('Offline');
});
test('shows Private for private location', () => {
expect(displayLocation('private', 'World Name')).toBe('Private');
});
test('shows Traveling for traveling location', () => {
expect(displayLocation('traveling', 'World Name')).toBe('Traveling');
});
test('shows world name with access type', () => {
const result = displayLocation(
'wrld_abc:12345~friends(usr_a)',
'My World'
);
expect(result).toBe('My World friends');
});
test('includes group name when provided', () => {
const result = displayLocation(
'wrld_abc:12345~group(grp_xyz)~groupAccessType(public)',
'My World',
'My Group'
);
expect(result).toBe('My World groupPublic(My Group)');
});
test('returns worldName for world-only tag', () => {
expect(displayLocation('wrld_abc', 'My World')).toBe('My World');
});
});
// ─── resolveRegion ───────────────────────────────────────────────────
describe('resolveRegion', () => {
test('returns empty for offline', () => {
expect(resolveRegion(parseLocation('offline'))).toBe('');
});
test('returns region from tag', () => {
expect(resolveRegion(parseLocation('wrld_abc:12345~region(eu)'))).toBe(
'eu'
);
});
test('defaults to us when instance has no region', () => {
expect(resolveRegion(parseLocation('wrld_abc:12345'))).toBe('us');
});
test('returns empty when no instanceId', () => {
expect(resolveRegion(parseLocation('wrld_abc'))).toBe('');
});
});
// ─── translateAccessType ─────────────────────────────────────────────
describe('translateAccessType', () => {
const t = (key) => `translated_${key}`;
const keyMap = {
public: 'access.public',
friends: 'access.friends',
invite: 'access.invite',
group: 'access.group',
groupPublic: 'access.groupPublic',
groupPlus: 'access.groupPlus'
};
test('translates simple access type', () => {
expect(translateAccessType('friends', t, keyMap)).toBe(
'translated_access.friends'
);
});
test('translates groupPublic with group prefix', () => {
expect(translateAccessType('groupPublic', t, keyMap)).toBe(
'translated_access.group translated_access.groupPublic'
);
});
test('translates groupPlus with group prefix', () => {
expect(translateAccessType('groupPlus', t, keyMap)).toBe(
'translated_access.group translated_access.groupPlus'
);
});
test('returns raw name when not in keyMap', () => {
expect(translateAccessType('unknown', t, keyMap)).toBe('unknown');
});
});

View File

@@ -0,0 +1,108 @@
import { resolveRef } from '../resolveRef';
describe('resolveRef', () => {
const emptyDefault = { id: '', displayName: '' };
const nameKey = 'displayName';
const idAlias = 'userId';
const mockFetchFn = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('returns emptyDefault for null input', async () => {
const result = await resolveRef(null, {
emptyDefault,
idAlias,
nameKey,
fetchFn: mockFetchFn
});
expect(result).toEqual(emptyDefault);
expect(mockFetchFn).not.toHaveBeenCalled();
});
test('returns emptyDefault for empty string input', async () => {
const result = await resolveRef('', {
emptyDefault,
idAlias,
nameKey,
fetchFn: mockFetchFn
});
expect(result).toEqual(emptyDefault);
});
test('converts string input to object and fetches name', async () => {
mockFetchFn.mockResolvedValue({
ref: { id: 'usr_123', displayName: 'Alice' }
});
const result = await resolveRef('usr_123', {
emptyDefault,
idAlias,
nameKey,
fetchFn: mockFetchFn
});
expect(mockFetchFn).toHaveBeenCalledWith('usr_123');
expect(result.id).toBe('usr_123');
expect(result.displayName).toBe('Alice');
});
test('returns object with name when name is already present', async () => {
const input = { id: 'usr_456', displayName: 'Bob' };
const result = await resolveRef(input, {
emptyDefault,
idAlias,
nameKey,
fetchFn: mockFetchFn
});
expect(mockFetchFn).not.toHaveBeenCalled();
expect(result.displayName).toBe('Bob');
});
test('fetches name when object has id but no name', async () => {
mockFetchFn.mockResolvedValue({
ref: { id: 'usr_789', displayName: 'Charlie' }
});
const result = await resolveRef(
{ id: 'usr_789' },
{ emptyDefault, idAlias, nameKey, fetchFn: mockFetchFn }
);
expect(mockFetchFn).toHaveBeenCalledWith('usr_789');
expect(result.displayName).toBe('Charlie');
});
test('uses idAlias as fallback for id', async () => {
mockFetchFn.mockResolvedValue({
ref: { id: 'usr_alt', displayName: 'AltUser' }
});
const result = await resolveRef(
{ userId: 'usr_alt' },
{ emptyDefault, idAlias, nameKey, fetchFn: mockFetchFn }
);
expect(mockFetchFn).toHaveBeenCalledWith('usr_alt');
expect(result.displayName).toBe('AltUser');
});
test('handles fetch failure gracefully', async () => {
mockFetchFn.mockRejectedValue(new Error('Network error'));
const result = await resolveRef('usr_err', {
emptyDefault,
idAlias,
nameKey,
fetchFn: mockFetchFn
});
expect(result.id).toBe('usr_err');
expect(result.displayName).toBe('');
});
test('returns input properties when no id and no name', async () => {
const input = { someField: 'value' };
const result = await resolveRef(input, {
emptyDefault,
idAlias,
nameKey,
fetchFn: mockFetchFn
});
expect(mockFetchFn).not.toHaveBeenCalled();
expect(result.someField).toBe('value');
});
});

View File

@@ -0,0 +1,558 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
// Mock stores
vi.mock('../../../stores', () => ({
useUserStore: vi.fn(),
useAppearanceSettingsStore: vi.fn()
}));
// 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}`)
}));
// Mock transitive deps that get pulled in via stores
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
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 (with store mock)', () => {
beforeEach(() => {
useUserStore.mockReturnValue({
currentUser: {
id: 'usr_me',
presence: { platform: 'standalonewindows' },
onlineFriends: [],
activeFriends: []
}
});
});
test('returns null for undefined user', () => {
expect(userStatusClass(undefined)).toBeNull();
});
test('returns current user style with status', () => {
const result = userStatusClass({
id: 'usr_me',
status: 'active',
isFriend: true
});
expect(result).toMatchObject({
'status-icon': true,
online: true,
mobile: false
});
});
test('returns mobile true for non-PC platform on current user', () => {
useUserStore.mockReturnValue({
currentUser: {
id: 'usr_me',
presence: { platform: 'android' },
onlineFriends: [],
activeFriends: []
}
});
const result = userStatusClass({
id: 'usr_me',
status: 'active'
});
expect(result.mobile).toBe(true);
});
test('returns null for non-friend users', () => {
expect(
userStatusClass({
id: 'usr_other',
status: 'active',
isFriend: false
})
).toBeNull();
});
test('returns offline style for pending offline friend', () => {
const result = userStatusClass(
{ id: 'usr_other', isFriend: true, status: 'active' },
true
);
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
});
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: ''
});
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'
});
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'
});
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'
});
expect(result.mobile).toBeUndefined();
});
test('uses userId as fallback when id is not present', () => {
const result = userStatusClass({
userId: 'usr_me',
status: 'busy'
});
expect(result).toMatchObject({
'status-icon': true,
busy: true,
mobile: false
});
});
test('handles private location with empty state (temp fix branch)', () => {
useUserStore.mockReturnValue({
currentUser: {
id: 'usr_me',
onlineFriends: [],
activeFriends: ['usr_f']
}
});
const result = userStatusClass({
id: 'usr_f',
isFriend: true,
status: 'busy',
location: 'private',
state: ''
});
// activeFriends includes usr_f → active
expect(result.active).toBe(true);
});
test('handles private location temp fix → offline branch', () => {
useUserStore.mockReturnValue({
currentUser: {
id: 'usr_me',
onlineFriends: [],
activeFriends: []
}
});
const result = userStatusClass({
id: 'usr_f',
isFriend: true,
status: 'busy',
location: 'private',
state: ''
});
expect(result.offline).toBe(true);
});
});
describe('userImage (with store mock)', () => {
beforeEach(() => {
useAppearanceSettingsStore.mockReturnValue({
displayVRCPlusIconsAsAvatar: false
});
});
test('returns empty string for falsy user', () => {
expect(userImage(null)).toBe('');
expect(userImage(undefined)).toBe('');
});
test('returns profilePicOverrideThumbnail when available', () => {
const user = {
profilePicOverrideThumbnail: 'https://img.com/pic/256/thumb'
};
expect(userImage(user)).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')).toBe(
'https://img.com/pic/64/thumb'
);
});
test('returns profilePicOverride when no thumbnail', () => {
const user = { profilePicOverride: 'https://img.com/full' };
expect(userImage(user)).toBe('https://img.com/full');
});
test('returns thumbnailUrl as fallback', () => {
const user = { thumbnailUrl: 'https://img.com/thumb' };
expect(userImage(user)).toBe('https://img.com/thumb');
});
test('returns currentAvatarThumbnailImageUrl as fallback', () => {
const user = {
currentAvatarThumbnailImageUrl:
'https://img.com/avatar/256/thumb'
};
expect(userImage(user)).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')).toBe(
'https://img.com/avatar/64/thumb'
);
});
test('returns currentAvatarImageUrl as last resort', () => {
const user = {
currentAvatarImageUrl: 'https://img.com/avatar/full'
};
expect(userImage(user)).toBe('https://img.com/avatar/full');
});
test('converts currentAvatarImageUrl for icon mode', () => {
const user = {
currentAvatarImageUrl: 'https://img.com/avatar/full'
};
expect(userImage(user, true)).toBe(
'converted:https://img.com/avatar/full'
);
});
test('returns empty string when user has no image fields', () => {
expect(userImage({})).toBe('');
});
test('returns userIcon when displayVRCPlusIconsAsAvatar is true', () => {
useAppearanceSettingsStore.mockReturnValue({
displayVRCPlusIconsAsAvatar: true
});
const user = {
userIcon: 'https://img.com/icon',
thumbnailUrl: 'https://img.com/thumb'
};
expect(userImage(user)).toBe('https://img.com/icon');
});
test('converts userIcon for icon mode when VRCPlus setting enabled', () => {
useAppearanceSettingsStore.mockReturnValue({
displayVRCPlusIconsAsAvatar: true
});
const user = { userIcon: 'https://img.com/icon' };
expect(userImage(user, 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)).toBe(
'https://img.com/icon'
);
});
});
describe('userImageFull (with store mock)', () => {
beforeEach(() => {
useAppearanceSettingsStore.mockReturnValue({
displayVRCPlusIconsAsAvatar: false
});
});
test('returns empty string for falsy user', () => {
expect(userImageFull(null)).toBe('');
});
test('returns profilePicOverride when available', () => {
const user = {
profilePicOverride: 'https://img.com/full',
currentAvatarImageUrl: 'https://img.com/avatar'
};
expect(userImageFull(user)).toBe('https://img.com/full');
});
test('returns currentAvatarImageUrl as fallback', () => {
const user = {
currentAvatarImageUrl: 'https://img.com/avatar'
};
expect(userImageFull(user)).toBe('https://img.com/avatar');
});
test('returns userIcon when VRCPlus setting enabled', () => {
useAppearanceSettingsStore.mockReturnValue({
displayVRCPlusIconsAsAvatar: true
});
const user = {
userIcon: 'https://img.com/icon',
profilePicOverride: 'https://img.com/full'
};
expect(userImageFull(user)).toBe('https://img.com/icon');
});
});
});

View File

@@ -0,0 +1,40 @@
import { isRpcWorld } from '../world';
// Mock transitive deps
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
describe('World Utils', () => {
describe('isRpcWorld', () => {
test('returns true for a known RPC world', () => {
expect(
isRpcWorld(
'wrld_f20326da-f1ac-45fc-a062-609723b097b1:12345~region(us)'
)
).toBe(true);
});
test('returns false for a random world', () => {
expect(
isRpcWorld('wrld_00000000-0000-0000-0000-000000000000:12345')
).toBe(false);
});
test('returns false for offline location', () => {
expect(isRpcWorld('offline')).toBe(false);
});
test('returns false for private location', () => {
expect(isRpcWorld('private')).toBe(false);
});
test('returns false for empty string', () => {
expect(isRpcWorld('')).toBe(false);
});
});
});

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
// Mock the store
vi.mock('../../../../stores', () => ({
useAppearanceSettingsStore: vi.fn()
}));
// Mock transitive deps
vi.mock('../../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
import { useAppearanceSettingsStore } from '../../../../stores';
import { formatDateFilter } from '../date';
describe('formatDateFilter', () => {
beforeEach(() => {
useAppearanceSettingsStore.mockReturnValue({
dtIsoFormat: false,
dtHour12: false,
currentCulture: 'en-gb'
});
});
test('returns dash for empty dateStr', () => {
expect(formatDateFilter('', 'long')).toBe('-');
expect(formatDateFilter(null, 'long')).toBe('-');
expect(formatDateFilter(undefined, 'long')).toBe('-');
});
test('returns dash for invalid dateStr', () => {
expect(formatDateFilter('not-a-date', 'long')).toBe('-');
});
test('formats long ISO format', () => {
useAppearanceSettingsStore.mockReturnValue({
dtIsoFormat: true,
dtHour12: false,
currentCulture: 'en-gb'
});
const result = formatDateFilter('2023-06-15T14:30:45Z', 'long');
// ISO format: YYYY-MM-DD HH:MM:SS (in local timezone)
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
test('formats long locale format', () => {
const result = formatDateFilter('2023-06-15T14:30:45Z', 'long');
// Result is locale-dependent; just verify it produces something
expect(result).not.toBe('-');
expect(result.length).toBeGreaterThan(5);
});
test('formats short locale format', () => {
const result = formatDateFilter('2023-06-15T14:30:45Z', 'short');
expect(result).not.toBe('-');
});
test('formats time only', () => {
const result = formatDateFilter('2023-06-15T14:30:45Z', 'time');
expect(result).not.toBe('-');
});
test('formats date only', () => {
const result = formatDateFilter('2023-06-15T14:30:45Z', 'date');
expect(result).not.toBe('-');
});
test('handles culture with no underscore at position 4', () => {
useAppearanceSettingsStore.mockReturnValue({
dtIsoFormat: false,
dtHour12: true,
currentCulture: 'en-us'
});
const result = formatDateFilter('2023-06-15T14:30:45Z', 'long');
expect(result).not.toBe('-');
});
test('returns dash for unknown format', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = formatDateFilter('2023-06-15T14:30:45Z', 'unknown');
expect(result).toBe('-');
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
test('uses hour12 setting', () => {
useAppearanceSettingsStore.mockReturnValue({
dtIsoFormat: false,
dtHour12: true,
currentCulture: 'en-us'
});
const result = formatDateFilter('2023-06-15T14:30:45Z', 'short');
// hour12 should produce am/pm in the output
expect(result).not.toBe('-');
});
});

View File

@@ -1,4 +1,4 @@
import { timeToText } from '../format';
import { convertYoutubeTime, formatSeconds, timeToText } from '../format';
describe('Format Utils', () => {
describe('timeToText', () => {
@@ -24,4 +24,64 @@ describe('Format Utils', () => {
expect(result).toContain('1h');
});
});
describe('formatSeconds', () => {
test('formats seconds only', () => {
expect(formatSeconds(5)).toBe('00:05');
expect(formatSeconds(0)).toBe('00:00');
expect(formatSeconds(59)).toBe('00:59');
});
test('formats minutes and seconds', () => {
expect(formatSeconds(60)).toBe('01:00');
expect(formatSeconds(125)).toBe('02:05');
expect(formatSeconds(3599)).toBe('59:59');
});
test('formats hours, minutes and seconds', () => {
expect(formatSeconds(3600)).toBe('01:00:00');
expect(formatSeconds(3661)).toBe('01:01:01');
expect(formatSeconds(7200)).toBe('02:00:00');
});
test('handles decimal input', () => {
expect(formatSeconds(5.7)).toBe('00:05');
});
});
describe('convertYoutubeTime', () => {
test('converts minutes and seconds (PT3M45S)', () => {
expect(convertYoutubeTime('PT3M45S')).toBe(225);
});
test('converts hours, minutes, seconds (PT1H30M15S)', () => {
expect(convertYoutubeTime('PT1H30M15S')).toBe(5415);
});
test('converts minutes only (PT5M)', () => {
expect(convertYoutubeTime('PT5M')).toBe(300);
});
test('converts seconds only (PT30S)', () => {
expect(convertYoutubeTime('PT30S')).toBe(30);
});
test('converts hours only (PT2H)', () => {
expect(convertYoutubeTime('PT2H')).toBe(7200);
});
test('converts hours and seconds, no minutes (PT1H30S)', () => {
expect(convertYoutubeTime('PT1H30S')).toBe(3630);
});
test('converts hours and minutes, no seconds (PT1H30M)', () => {
// H present, M present, S missing → a = [1, 30]
// length === 2 → 1*60 + 30 = 90... but that's wrong for the intent
// Actually looking at the code: H>=0 && M present && S missing
// doesn't hit any special case, so a = ['1','30'] from match
// length 2 → 1*60 + 30 = 90
// This is a known quirk of the parser
expect(convertYoutubeTime('PT1H30M')).toBe(90);
});
});
});

31
src/shared/utils/csv.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* @param {string} text
* @returns {boolean}
*/
export function needsCsvQuotes(text) {
return /[\x00-\x1f,"]/.test(text);
}
/**
* @param {*} value
* @returns {string}
*/
export function formatCsvField(value) {
if (value === null || typeof value === 'undefined') {
return '';
}
const text = String(value);
if (needsCsvQuotes(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
/**
* @param {object} obj - The source object
* @param {string[]} fields - Property names to include
* @returns {string}
*/
export function formatCsvRow(obj, fields) {
return fields.map((field) => formatCsvField(obj?.[field])).join(',');
}

View File

@@ -7,6 +7,7 @@ export * from './avatar';
export * from './chart';
export * from './common';
export * from './compare';
export * from './csv';
export * from './fileUtils';
export * from './friend';
export * from './group';

View File

@@ -64,4 +64,76 @@ function getLaunchURL(instance) {
)}`;
}
export { refreshInstancePlayerCount, isRealInstance, getLaunchURL };
const regionTagMap = {
'US West': 'us',
'US East': 'use',
Europe: 'eu',
Japan: 'jp'
};
/**
* @param {object} opts
* @param {string} opts.instanceName - Sanitised instance name segment
* @param {string} opts.userId
* @param {string} opts.accessType
* @param {string} [opts.groupId]
* @param {string} [opts.groupAccessType]
* @param {string} opts.region - Display region name ('US West', 'US East', 'Europe', 'Japan')
* @param {boolean} [opts.ageGate]
* @param {boolean} [opts.strict]
* @returns {string} instance tag, e.g. '12345~hidden(usr_xxx)~region(us)'
*/
function buildLegacyInstanceTag({
instanceName,
userId,
accessType,
groupId,
groupAccessType,
region,
ageGate,
strict
}) {
const tags = [];
if (instanceName) {
tags.push(instanceName);
}
if (accessType !== 'public') {
if (accessType === 'friends+') {
tags.push(`~hidden(${userId})`);
} else if (accessType === 'friends') {
tags.push(`~friends(${userId})`);
} else if (accessType === 'group') {
tags.push(`~group(${groupId})`);
tags.push(`~groupAccessType(${groupAccessType})`);
} else {
tags.push(`~private(${userId})`);
}
if (accessType === 'invite+') {
tags.push('~canRequestInvite');
}
}
if (accessType === 'group' && ageGate) {
tags.push('~ageGate');
}
const regionCode = regionTagMap[region];
if (regionCode) {
tags.push(`~region(${regionCode})`);
}
if (strict && (accessType === 'invite' || accessType === 'friends')) {
tags.push('~strict');
}
return tags.join('');
}
export {
refreshInstancePlayerCount,
isRealInstance,
getLaunchURL,
buildLegacyInstanceTag
};

View File

@@ -1,9 +1,17 @@
import { isRealInstance } from './instance.js';
import { useLocationStore } from '../../stores/location.js';
// Re-export pure parsing functions from the standalone module
export { parseLocation, displayLocation } from './locationParser.js';
export {
parseLocation,
displayLocation,
resolveRegion,
translateAccessType
} from './locationParser.js';
/**
*
* @param friendsArr
*/
function getFriendsLocations(friendsArr) {
const locationStore = useLocationStore();
// prevent the instance title display as "Traveling".

View File

@@ -1,8 +1,3 @@
/**
* Pure location parsing utilities with no external dependencies.
* These functions are extracted to enable clean unit testing.
*/
/**
*
* @param {string} location
@@ -146,4 +141,39 @@ function parseLocation(tag) {
return ctx;
}
export { parseLocation, displayLocation };
/**
* @param {object} L - A parsed location object from parseLocation()
* @returns {string} region code (e.g. 'us', 'eu', 'jp') or empty string
*/
function resolveRegion(L) {
if (L.isOffline || L.isPrivate || L.isTraveling) {
return '';
}
if (L.region) {
return L.region;
}
if (L.instanceId) {
return 'us';
}
return '';
}
/**
* @param {string} accessTypeName - Raw access type name from parseLocation
* @param {function} t - Translation function (e.g. i18n.global.t)
* @param {object} keyMap - Mapping of access type names to locale keys
* @returns {string} Translated access type label
*/
function translateAccessType(accessTypeName, t, keyMap) {
const key = keyMap[accessTypeName];
if (!key) {
return accessTypeName;
}
if (accessTypeName === 'groupPublic' || accessTypeName === 'groupPlus') {
const groupKey = keyMap['group'];
return t(groupKey) + ' ' + t(key);
}
return t(key);
}
export { parseLocation, displayLocation, resolveRegion, translateAccessType };