mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-23 16:53:50 +02:00
add test
This commit is contained in:
151
src/shared/utils/__tests__/common.test.js
Normal file
151
src/shared/utils/__tests__/common.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
88
src/shared/utils/__tests__/csv.test.js
Normal file
88
src/shared/utils/__tests__/csv.test.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
102
src/shared/utils/__tests__/group.test.js
Normal file
102
src/shared/utils/__tests__/group.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
src/shared/utils/__tests__/imageUpload.test.js
Normal file
177
src/shared/utils/__tests__/imageUpload.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
143
src/shared/utils/__tests__/instance.test.js
Normal file
143
src/shared/utils/__tests__/instance.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
152
src/shared/utils/__tests__/invite.test.js
Normal file
152
src/shared/utils/__tests__/invite.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
228
src/shared/utils/__tests__/locationParser.test.js
Normal file
228
src/shared/utils/__tests__/locationParser.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
108
src/shared/utils/__tests__/resolveRef.test.js
Normal file
108
src/shared/utils/__tests__/resolveRef.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
558
src/shared/utils/__tests__/user.test.js
Normal file
558
src/shared/utils/__tests__/user.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/shared/utils/__tests__/world.test.js
Normal file
40
src/shared/utils/__tests__/world.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
src/shared/utils/base/__tests__/date.test.js
Normal file
100
src/shared/utils/base/__tests__/date.test.js
Normal 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('-');
|
||||
});
|
||||
});
|
||||
@@ -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
31
src/shared/utils/csv.js
Normal 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(',');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user