feat: add jest testing for utility functions

This commit is contained in:
pa
2025-07-21 14:24:50 +09:00
committed by Natsumi
parent b9b0cebd7f
commit e2b1948159
17 changed files with 9188 additions and 25 deletions

View File

@@ -34,6 +34,7 @@ export default defineConfig([
{
files: [
'**/webpack.*.js',
'**/jest.config.js',
'src-electron/*.js',
'src/localization/*.js'
],
@@ -44,6 +45,18 @@ export default defineConfig([
}
}
},
{
files: [
'**/__tests__/**/*.{js,mjs,cjs,vue}',
'**/*.spec.{js,mjs,cjs,vue}',
'**/*.test.{js,mjs,cjs,vue}'
],
languageOptions: {
globals: {
...globals.jest
}
}
},
pluginVue.configs['flat/vue2-essential'],
{
rules: {

19
jest.config.js Normal file
View File

@@ -0,0 +1,19 @@
module.exports = {
testEnvironment: 'node',
moduleFileExtensions: ['js', 'vue'],
transform: {
'^.+\\.js$': 'esbuild-jest'
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
testMatch: ['<rootDir>/src/**/*.{test,spec}.js'],
testPathIgnorePatterns: [],
watchPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/build/'],
coverageReporters: ['text', 'text-summary'],
collectCoverageFrom: [
'src/shared/utils/**/*.js',
'!src/shared/utils/**/*.test.js',
'!src/shared/utils/**/__tests__/**'
]
};

7817
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,12 @@
"main": "src-electron/main.js",
"scripts": {
"dev": "cross-env PLATFORM=windows webpack serve --config webpack.config.js --mode development",
"dev:test": "concurrently \"npm run dev\" \"jest --watchAll\"",
"watch": "cross-env PLATFORM=windows webpack --config webpack.config.js --mode development --watch",
"watch-linux": "cross-env PLATFORM=linux webpack --config webpack.config.js --mode development --watch",
"localization": "node ./src/localization/localizationHelperCLI.js",
"test": "jest",
"test:coverage": "jest --coverage",
"prod": "cross-env PLATFORM=windows webpack --config webpack.config.js --mode production",
"prod-linux": "cross-env PLATFORM=linux webpack --config webpack.config.js --mode production",
"build-electron": "node ./src-electron/download-dotnet-runtime.js && node ./src-electron/patch-package-version.js && electron-builder --publish never",
@@ -36,8 +39,10 @@
"@fontsource/noto-sans-tc": "^5.2.6",
"@infolektuell/noto-color-emoji": "^0.2.0",
"@prettier/plugin-pug": "^3.4.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.13",
"animate.css": "^4.1.1",
"concurrently": "^9.2.0",
"copy-webpack-plugin": "^13.0.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
@@ -46,12 +51,14 @@
"electron": "^37.2.1",
"electron-builder": "^26.0.12",
"element-ui": "^2.15.14",
"esbuild-jest": "^0.5.0",
"esbuild-loader": "^4.3.0",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.3.0",
"html-webpack-plugin": "^5.6.3",
"jest": "^30.0.4",
"mini-css-extract-plugin": "^2.9.2",
"noty": "^3.2.0-beta-deprecated",
"pinia": "^2.3.1",

View File

@@ -0,0 +1,404 @@
import {
compareByName,
compareByCreatedAt,
compareByCreatedAtAscending,
compareByUpdatedAt,
compareByDisplayName,
compareByMemberCount,
compareByPrivate,
compareByStatus,
compareByLastActive,
compareByLastSeen,
compareByLocationAt,
compareByLocation
} from '../compare';
describe('Compare Functions', () => {
describe('compareByName', () => {
test('compares objects by name property', () => {
const a = { name: 'Alice' };
const b = { name: 'Bob' };
expect(compareByName(a, b)).toBeLessThan(0);
expect(compareByName(b, a)).toBeGreaterThan(0);
});
test('returns 0 for equal names', () => {
const a = { name: 'Alice' };
const b = { name: 'Alice' };
expect(compareByName(a, b)).toBe(0);
});
test('handles non-string name properties', () => {
const a = { name: null };
const b = { name: 'Bob' };
expect(compareByName(a, b)).toBe(0);
const c = { name: 123 };
const d = { name: 'Alice' };
expect(compareByName(c, d)).toBe(0);
});
test('handles missing name properties', () => {
const a = {};
const b = { name: 'Bob' };
expect(compareByName(a, b)).toBe(0);
});
test('uses locale-aware comparison', () => {
const a = { name: 'ä' };
const b = { name: 'z' };
expect(typeof compareByName(a, b)).toBe('number');
});
});
describe('compareByCreatedAt', () => {
test('compares objects by created_at in descending order', () => {
const a = { created_at: '2023-01-01' };
const b = { created_at: '2023-01-02' };
expect(compareByCreatedAt(a, b)).toBeGreaterThan(0);
expect(compareByCreatedAt(b, a)).toBeLessThan(0);
});
test('returns 0 for equal created_at', () => {
const a = { created_at: '2023-01-01' };
const b = { created_at: '2023-01-01' };
expect(compareByCreatedAt(a, b)).toBe(0);
});
test('handles non-string created_at properties', () => {
const a = { created_at: null };
const b = { created_at: '2023-01-01' };
expect(compareByCreatedAt(a, b)).toBe(0);
const c = { created_at: 123 };
const d = { created_at: '2023-01-01' };
expect(compareByCreatedAt(c, d)).toBe(0);
});
test('handles case-insensitive comparison', () => {
const a = { created_at: '2023-01-01t12:00:00z' };
const b = { created_at: '2023-01-01T12:00:00Z' };
expect(compareByCreatedAt(a, b)).toBe(0);
});
});
describe('compareByCreatedAtAscending', () => {
test('compares objects by created_at in ascending order', () => {
const a = { created_at: '2023-01-01' };
const b = { created_at: '2023-01-02' };
expect(compareByCreatedAtAscending(a, b)).toBeLessThan(0);
expect(compareByCreatedAtAscending(b, a)).toBeGreaterThan(0);
});
test('returns 0 for equal created_at', () => {
const a = { created_at: '2023-01-01' };
const b = { created_at: '2023-01-01' };
expect(compareByCreatedAtAscending(a, b)).toBe(0);
});
test('handles undefined created_at', () => {
const a = { created_at: undefined };
const b = { created_at: '2023-01-01' };
// undefined comparison with string: both undefined < string and undefined > string are false
// So it falls through to return 0
expect(compareByCreatedAtAscending(a, b)).toBe(0);
});
});
describe('compareByUpdatedAt', () => {
test('compares objects by updated_at in descending order', () => {
const a = { updated_at: '2023-01-01' };
const b = { updated_at: '2023-01-02' };
expect(compareByUpdatedAt(a, b)).toBeGreaterThan(0);
expect(compareByUpdatedAt(b, a)).toBeLessThan(0);
});
test('returns 0 for equal updated_at', () => {
const a = { updated_at: '2023-01-01' };
const b = { updated_at: '2023-01-01' };
expect(compareByUpdatedAt(a, b)).toBe(0);
});
test('handles non-string updated_at properties', () => {
const a = { updated_at: null };
const b = { updated_at: '2023-01-01' };
expect(compareByUpdatedAt(a, b)).toBe(0);
});
test('handles case-insensitive comparison', () => {
const a = { updated_at: '2023-01-01t12:00:00z' };
const b = { updated_at: '2023-01-01T12:00:00Z' };
expect(compareByUpdatedAt(a, b)).toBe(0);
});
});
describe('compareByDisplayName', () => {
test('compares objects by displayName property', () => {
const a = { displayName: 'Alice Display' };
const b = { displayName: 'Bob Display' };
expect(compareByDisplayName(a, b)).toBeLessThan(0);
expect(compareByDisplayName(b, a)).toBeGreaterThan(0);
});
test('returns 0 for equal displayNames', () => {
const a = { displayName: 'Alice Display' };
const b = { displayName: 'Alice Display' };
expect(compareByDisplayName(a, b)).toBe(0);
});
test('handles non-string displayName properties', () => {
const a = { displayName: null };
const b = { displayName: 'Bob Display' };
expect(compareByDisplayName(a, b)).toBe(0);
const c = { displayName: 123 };
const d = { displayName: 'Alice Display' };
expect(compareByDisplayName(c, d)).toBe(0);
});
});
describe('compareByMemberCount', () => {
test('compares objects by memberCount property', () => {
const a = { memberCount: 5 };
const b = { memberCount: 10 };
expect(compareByMemberCount(a, b)).toBe(-5);
expect(compareByMemberCount(b, a)).toBe(5);
});
test('returns 0 for equal memberCounts', () => {
const a = { memberCount: 5 };
const b = { memberCount: 5 };
expect(compareByMemberCount(a, b)).toBe(0);
});
test('handles non-number memberCount properties', () => {
const a = { memberCount: 'invalid' };
const b = { memberCount: 10 };
expect(compareByMemberCount(a, b)).toBe(0);
const c = { memberCount: null };
const d = { memberCount: 5 };
expect(compareByMemberCount(c, d)).toBe(0);
});
test('handles negative member counts', () => {
const a = { memberCount: -5 };
const b = { memberCount: 10 };
expect(compareByMemberCount(a, b)).toBe(-15);
});
});
describe('compareByPrivate', () => {
test('prioritizes non-private locations', () => {
const a = { ref: { location: 'public' } };
const b = { ref: { location: 'private' } };
expect(compareByPrivate(a, b)).toBe(-1);
expect(compareByPrivate(b, a)).toBe(1);
});
test('returns 0 when both are private', () => {
const a = { ref: { location: 'private' } };
const b = { ref: { location: 'private' } };
expect(compareByPrivate(a, b)).toBe(0);
});
test('returns 0 when both are non-private', () => {
const a = { ref: { location: 'public' } };
const b = { ref: { location: 'friends' } };
expect(compareByPrivate(a, b)).toBe(0);
});
test('handles undefined ref properties', () => {
const a = { ref: undefined };
const b = { ref: { location: 'private' } };
expect(compareByPrivate(a, b)).toBe(0);
const c = {};
const d = { ref: { location: 'public' } };
expect(compareByPrivate(c, d)).toBe(0);
});
});
describe('compareByStatus', () => {
test('handles offline users', () => {
const a = { ref: { state: 'offline', status: 'active' } };
const b = { ref: { state: 'online', status: 'busy' } };
expect(compareByStatus(a, b)).toBe(1);
});
test('returns 0 for same status', () => {
const a = { ref: { status: 'active' } };
const b = { ref: { status: 'active' } };
expect(compareByStatus(a, b)).toBe(0);
});
test('handles undefined ref properties', () => {
const a = { ref: undefined };
const b = { ref: { status: 'active' } };
expect(compareByStatus(a, b)).toBe(0);
});
});
describe('compareByLastActive', () => {
test('compares online users by $online_for', () => {
const a = {
state: 'online',
ref: { $online_for: 100 }
};
const b = {
state: 'online',
ref: { $online_for: 200 }
};
expect(compareByLastActive(a, b)).toBe(1);
});
test('falls back to last_activity for non-online users', () => {
const a = {
state: 'offline',
ref: { last_activity: '2023-01-01' }
};
const b = {
state: 'offline',
ref: { last_activity: '2023-01-02' }
};
expect(compareByLastActive(a, b)).toBe(1);
});
test('handles undefined ref properties', () => {
const a = { state: 'online', ref: undefined };
const b = { state: 'online', ref: { $online_for: 100 } };
expect(compareByLastActive(a, b)).toBe(0);
});
});
describe('compareByLastSeen', () => {
test('compares by $lastSeen field', () => {
const a = { ref: { $lastSeen: '2023-01-01' } };
const b = { ref: { $lastSeen: '2023-01-02' } };
expect(compareByLastSeen(a, b)).toBe(1);
});
test('handles empty string as longest active', () => {
const a = { ref: { $lastSeen: '' } };
const b = { ref: { $lastSeen: '2023-01-01' } };
// '' < '2023-01-01' is true, so first condition matches and returns 1
expect(compareByLastSeen(a, b)).toBe(1);
});
test('handles undefined ref properties', () => {
const a = { ref: undefined };
const b = { ref: { $lastSeen: '2023-01-01' } };
expect(compareByLastSeen(a, b)).toBe(0);
});
});
describe('compareByLocationAt', () => {
test('handles traveling status', () => {
const a = { location: 'traveling', $location_at: 100 };
const b = { location: 'public', $location_at: 200 };
expect(compareByLocationAt(a, b)).toBe(1);
expect(compareByLocationAt(b, a)).toBe(-1);
});
test('returns 0 when both are traveling', () => {
const a = { location: 'traveling', $location_at: 100 };
const b = { location: 'traveling', $location_at: 200 };
expect(compareByLocationAt(a, b)).toBe(0);
});
test('compares by $location_at for non-traveling locations', () => {
const a = { location: 'public', $location_at: 100 };
const b = { location: 'friends', $location_at: 200 };
expect(compareByLocationAt(a, b)).toBe(-1);
expect(compareByLocationAt(b, a)).toBe(1);
});
test('returns 0 for equal $location_at', () => {
const a = { location: 'public', $location_at: 100 };
const b = { location: 'friends', $location_at: 100 };
expect(compareByLocationAt(a, b)).toBe(0);
});
});
describe('compareByLocation', () => {
test('compares online users by location', () => {
const a = {
state: 'online',
ref: { location: 'public' }
};
const b = {
state: 'online',
ref: { location: 'friends' }
};
expect(compareByLocation(a, b)).toBeGreaterThan(0);
});
test('returns 0 for non-online users', () => {
const a = {
state: 'offline',
ref: { location: 'public' }
};
const b = {
state: 'offline',
ref: { location: 'friends' }
};
expect(compareByLocation(a, b)).toBe(0);
});
test('returns 0 when one user is not online', () => {
const a = {
state: 'online',
ref: { location: 'public' }
};
const b = {
state: 'offline',
ref: { location: 'friends' }
};
expect(compareByLocation(a, b)).toBe(0);
});
test('handles undefined ref properties', () => {
const a = { state: 'online', ref: undefined };
const b = { state: 'online', ref: { location: 'public' } };
expect(compareByLocation(a, b)).toBe(0);
});
test('uses locale-aware comparison', () => {
const a = {
state: 'online',
ref: { location: 'ä' }
};
const b = {
state: 'online',
ref: { location: 'z' }
};
expect(typeof compareByLocation(a, b)).toBe('number');
});
});
describe('edge cases and boundary conditions', () => {
test('handles null objects', () => {
// compareByName doesn't handle null objects - it will throw
expect(() => compareByName(null, { name: 'test' })).toThrow();
expect(() => compareByName({ name: 'test' }, null)).toThrow();
});
test('handles empty objects', () => {
expect(compareByName({}, {})).toBe(0);
expect(compareByDisplayName({}, {})).toBe(0);
expect(compareByMemberCount({}, {})).toBe(0);
});
test('handles mixed valid and invalid data', () => {
const valid = { memberCount: 5 };
const invalid = { memberCount: 'not a number' };
expect(compareByMemberCount(valid, invalid)).toBe(0);
});
test('handles zero values', () => {
const a = { memberCount: 0 };
const b = { memberCount: 5 };
expect(compareByMemberCount(a, b)).toBe(-5);
});
});
});

View File

@@ -0,0 +1,28 @@
import { sortStatus, isFriendOnline } 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);
});
test('handles unknown status', () => {
expect(sortStatus('unknown', 'active')).toBe(0);
// @ts-ignore
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('handles missing data', () => {
expect(isFriendOnline({})).toBe(false);
expect(isFriendOnline({ state: 'online' })).toBe(false);
});
});
});

View File

@@ -0,0 +1,314 @@
import {
getPrintFileName,
getPrintLocalDate,
getEmojiFileName
} from '../gallery';
describe('Gallery Utils', () => {
describe('getPrintFileName', () => {
test('generates filename with createdAt', () => {
const print = {
authorName: 'TestUser',
createdAt: '2023-01-15T10:30:45.123Z',
id: 'print_12345'
};
const result = getPrintFileName(print);
expect(result).toContain('TestUser');
expect(result).toContain('print_12345');
expect(result).toContain('.png');
// Check date formatting - should replace : with - and T with _
expect(result).toMatch(
/\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d{3}/
);
});
test('generates filename with timestamp fallback', () => {
const print = {
authorName: 'TestUser2',
timestamp: 1673776245123, // 2023-01-15T10:30:45.123Z
id: 'print_67890'
};
const result = getPrintFileName(print);
expect(result).toContain('TestUser2');
expect(result).toContain('print_67890');
expect(result).toContain('.png');
});
test('generates filename with current date fallback', () => {
const print = {
authorName: 'TestUser3',
id: 'print_fallback'
};
const result = getPrintFileName(print);
expect(result).toContain('TestUser3');
expect(result).toContain('print_fallback');
expect(result).toContain('.png');
// Should still have valid date format
expect(result).toMatch(
/\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d{3}/
);
});
test('handles missing authorName', () => {
const print = {
createdAt: '2023-01-15T10:30:45.123Z',
id: 'print_no_author'
};
const result = getPrintFileName(print);
expect(result).toContain('undefined_'); // authorName will be undefined
expect(result).toContain('print_no_author');
});
test('handles special characters in authorName', () => {
const print = {
authorName: 'Test User / With : Chars',
createdAt: '2023-01-15T10:30:45.123Z',
id: 'print_special'
};
const result = getPrintFileName(print);
expect(result).toContain('Test User / With : Chars'); // Should preserve original chars
expect(result).toContain('print_special');
});
test('handles missing data gracefully', () => {
const print = { id: 'test' };
const result = getPrintFileName(print);
expect(typeof result).toBe('string');
expect(result).toContain('test');
expect(result).toContain('.png');
});
});
describe('getPrintLocalDate', () => {
test('converts createdAt to local date', () => {
const print = { createdAt: '2023-01-15T10:30:45.123Z' };
const result = getPrintLocalDate(print);
expect(result).toBeInstanceOf(Date);
// Should have timezone offset applied
const originalDate = new Date('2023-01-15T10:30:45.123Z');
const expectedLocalTime =
originalDate.getTime() -
originalDate.getTimezoneOffset() * 60000;
expect(result.getTime()).toBe(expectedLocalTime);
});
test('uses timestamp without timezone conversion', () => {
const timestamp = 1673776245123; // 2023-01-15T10:30:45.123Z
const print = { timestamp };
const result = getPrintLocalDate(print);
expect(result).toBeInstanceOf(Date);
expect(result.getTime()).toBe(timestamp);
});
test('prefers createdAt over timestamp', () => {
const print = {
createdAt: '2023-01-15T10:30:45.123Z',
timestamp: 1673000000000 // Different timestamp
};
const result = getPrintLocalDate(print);
// Should use createdAt, not timestamp
const originalDate = new Date('2023-01-15T10:30:45.123Z');
const expectedLocalTime =
originalDate.getTime() -
originalDate.getTimezoneOffset() * 60000;
expect(result.getTime()).toBe(expectedLocalTime);
});
test('defaults to current date with timezone adjustment', () => {
const print = {};
const result = getPrintLocalDate(print);
expect(result).toBeInstanceOf(Date);
// Should be approximately current time with timezone offset
const currentDate = new Date();
const expectedLocalTime =
currentDate.getTime() - currentDate.getTimezoneOffset() * 60000;
// Allow some tolerance for execution time
expect(Math.abs(result.getTime() - expectedLocalTime)).toBeLessThan(
1000
);
});
test('handles invalid createdAt gracefully', () => {
const print = { createdAt: 'invalid-date' };
const result = getPrintLocalDate(print);
expect(result).toBeInstanceOf(Date);
// Should fall back to current date when createdAt is invalid
});
test('handles null/undefined inputs', () => {
expect(getPrintLocalDate({})).toBeInstanceOf(Date);
expect(getPrintLocalDate({ createdAt: null })).toBeInstanceOf(Date);
expect(getPrintLocalDate({ timestamp: null })).toBeInstanceOf(Date);
});
});
describe('getEmojiFileName', () => {
test('creates animated filename with all properties', () => {
const emoji = {
name: 'happy',
animationStyle: 'bounce',
frames: 10,
framesOverTime: 30,
loopStyle: 'loop'
};
const result = getEmojiFileName(emoji);
expect(result).toBe(
'happy_bounceanimationStyle_10frames_30fps_looploopStyle.png'
);
});
test('creates animated filename with default loopStyle', () => {
const emoji = {
name: 'wave',
animationStyle: 'wiggle',
frames: 5,
framesOverTime: 15
};
const result = getEmojiFileName(emoji);
expect(result).toBe(
'wave_wiggleanimationStyle_5frames_15fps_linearloopStyle.png'
);
});
test('creates static filename without frames', () => {
const emoji = {
name: 'smile',
animationStyle: 'static'
};
const result = getEmojiFileName(emoji);
expect(result).toBe('smile_staticanimationStyle.png');
});
test('treats zero frames as static', () => {
const emoji = {
name: 'neutral',
animationStyle: 'none',
frames: 0,
framesOverTime: 0
};
const result = getEmojiFileName(emoji);
expect(result).toBe('neutral_noneanimationStyle.png');
});
test('handles missing name gracefully', () => {
const emoji = {
animationStyle: 'bounce',
frames: 5,
framesOverTime: 10
};
const result = getEmojiFileName(emoji);
expect(result).toContain('undefined_bounceanimationStyle');
});
test('handles missing animationStyle', () => {
const emoji = {
name: 'test',
frames: 3,
framesOverTime: 20
};
const result = getEmojiFileName(emoji);
expect(result).toContain('test_undefinedanimationStyle');
});
test('handles special characters in name', () => {
const emoji = {
name: 'emoji-with_special.chars',
animationStyle: 'bounce',
frames: 8,
framesOverTime: 24,
loopStyle: 'pingpong'
};
const result = getEmojiFileName(emoji);
expect(result).toBe(
'emoji-with_special.chars_bounceanimationStyle_8frames_24fps_pingpongloopStyle.png'
);
});
test('handles edge case values', () => {
const emoji = {
name: '',
animationStyle: '',
frames: -1,
framesOverTime: 0,
loopStyle: ''
};
const result = getEmojiFileName(emoji);
// Should still generate a valid filename structure
expect(result).toContain('animationStyle');
expect(result).toContain('.png');
});
});
describe('integration and edge cases', () => {
test('getPrintFileName produces valid file names', () => {
const testCases = [
{
print: {
authorName: 'ValidUser',
createdAt: '2023-12-31T23:59:59.999Z',
id: 'print_test123'
},
expectValid: true
},
{
print: {
authorName: '',
createdAt: '2023-01-01T00:00:00.000Z',
id: ''
},
expectValid: true
},
{
print: {},
expectValid: true
}
];
testCases.forEach(({ print, expectValid }) => {
const result = getPrintFileName(print);
if (expectValid) {
expect(result).toMatch(/.*\.png$/);
expect(result.length).toBeGreaterThan(0);
}
});
});
test('date functions handle timezone consistency', () => {
const testDate = '2023-06-15T12:00:00.000Z';
const print = { createdAt: testDate };
const localDate = getPrintLocalDate(print);
const fileName = getPrintFileName({
...print,
authorName: 'test',
id: 'test'
});
// Both functions should produce consistent results
expect(localDate).toBeInstanceOf(Date);
expect(fileName).toMatch(
/test_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d{3}_test\.png/
);
});
test('emoji filename handles boundary conditions', () => {
const extremeCases = [
{ name: 'a'.repeat(100), animationStyle: 'test' },
{ name: 'テスト', animationStyle: 'unicode' },
{
name: 'test',
animationStyle: 'test',
frames: 999,
framesOverTime: 999
}
];
extremeCases.forEach((emoji) => {
const result = getEmojiFileName(emoji);
expect(typeof result).toBe('string');
expect(result.endsWith('.png')).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,411 @@
import { parseLocation, displayLocation } from '../location';
describe('Location Utils', () => {
describe('parseLocation', () => {
test('parses simple world ID', () => {
const worldId = 'wrld_12345678-1234-1234-1234-123456789012';
const result = parseLocation(worldId);
expect(result.worldId).toBe(worldId);
expect(result.instanceId).toBe('');
expect(result.isOffline).toBe(false);
expect(result.isPrivate).toBe(false);
expect(result.isTraveling).toBe(false);
expect(result.isRealInstance).toBe(true);
expect(result.accessType).toBe(''); // No instance means no access type
});
test('parses world with basic instance', () => {
const location = 'wrld_12345:67890';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.instanceId).toBe('67890');
expect(result.instanceName).toBe('67890');
expect(result.accessType).toBe('public');
expect(result.isRealInstance).toBe(true);
});
test('parses instance with region', () => {
const location = 'wrld_12345:67890~region(us)';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.instanceId).toBe('67890~region(us)');
expect(result.instanceName).toBe('67890');
expect(result.region).toBe('us');
expect(result.accessType).toBe('public');
});
test('parses private instance', () => {
const location = 'wrld_12345:instance~private(usr_12345)';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.accessType).toBe('invite');
expect(result.privateId).toBe('usr_12345');
expect(result.userId).toBe('usr_12345');
expect(result.canRequestInvite).toBe(false);
});
test('parses invite+ instance', () => {
const location =
'wrld_12345:instance~private(usr_12345)~canRequestInvite';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.accessType).toBe('invite+');
expect(result.privateId).toBe('usr_12345');
expect(result.userId).toBe('usr_12345');
expect(result.canRequestInvite).toBe(true);
});
test('parses friends only instance', () => {
const location = 'wrld_12345:instance~friends(usr_67890)';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.accessType).toBe('friends');
expect(result.friendsId).toBe('usr_67890');
expect(result.userId).toBe('usr_67890');
});
test('parses friends+ instance', () => {
const location = 'wrld_12345:instance~hidden(usr_99999)';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.accessType).toBe('friends+');
expect(result.hiddenId).toBe('usr_99999');
expect(result.userId).toBe('usr_99999');
});
test('parses group instance', () => {
const location = 'wrld_12345:instance~group(grp_12345)';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.accessType).toBe('group');
expect(result.groupId).toBe('grp_12345');
expect(result.accessTypeName).toBe('group');
});
test('parses group public instance', () => {
const location =
'wrld_12345:instance~group(grp_12345)~groupAccessType(public)';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.accessType).toBe('group');
expect(result.groupId).toBe('grp_12345');
expect(result.groupAccessType).toBe('public');
expect(result.accessTypeName).toBe('groupPublic');
});
test('parses instance with strict flag', () => {
const location = 'wrld_12345:instance~strict';
const result = parseLocation(location);
expect(result.strict).toBe(true);
});
test('parses instance with ageGate flag', () => {
const location = 'wrld_12345:instance~ageGate';
const result = parseLocation(location);
expect(result.ageGate).toBe(true);
});
test('parses complex instance with multiple parameters', () => {
const location =
'wrld_12345:67890~region(eu)~private(usr_abc)~canRequestInvite~strict~ageGate';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.instanceName).toBe('67890');
expect(result.region).toBe('eu');
expect(result.accessType).toBe('invite+');
expect(result.privateId).toBe('usr_abc');
expect(result.canRequestInvite).toBe(true);
expect(result.strict).toBe(true);
expect(result.ageGate).toBe(true);
});
test('parses instance with shortName in URL', () => {
const location = 'wrld_12345:67890&shortName=TestInstance';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.instanceId).toBe('67890');
expect(result.shortName).toBe('TestInstance');
});
test('detects special states with variations', () => {
expect(parseLocation('offline').isOffline).toBe(true);
expect(parseLocation('offline:offline').isOffline).toBe(true);
expect(parseLocation('private').isPrivate).toBe(true);
expect(parseLocation('private:private').isPrivate).toBe(true);
expect(parseLocation('traveling').isTraveling).toBe(true);
expect(parseLocation('traveling:traveling').isTraveling).toBe(true);
});
test('handles empty and null inputs', () => {
expect(parseLocation(null).tag).toBe('');
expect(parseLocation(undefined).tag).toBe('');
expect(parseLocation('').tag).toBe('');
[null, undefined, ''].forEach((input) => {
const result = parseLocation(input);
expect(result.isOffline).toBe(false);
expect(result.isPrivate).toBe(false);
expect(result.isTraveling).toBe(false);
expect(result.isRealInstance).toBe(false);
});
});
test('handles malformed instance parameters', () => {
const location =
'wrld_12345:instance~private()~region~invalid(test';
const result = parseLocation(location);
expect(result.worldId).toBe('wrld_12345');
expect(result.privateId).toBe(''); // Empty parentheses
expect(result.region).toBe(''); // No parentheses
});
test('preserves original tag', () => {
const originalTag = 'wrld_12345:instance~region(us)';
const result = parseLocation(originalTag);
expect(result.tag).toBe(originalTag);
});
});
describe('displayLocation', () => {
test('shows world name for simple world ID', () => {
const result = displayLocation('wrld_12345', 'Test World');
expect(result).toBe('Test World');
});
test('handles offline state', () => {
expect(displayLocation('offline', 'Some World')).toBe('Offline');
expect(displayLocation('offline:offline', 'Some World')).toBe(
'Offline'
);
});
test('handles private state', () => {
expect(displayLocation('private', 'Some World')).toBe('Private');
expect(displayLocation('private:private', 'Some World')).toBe(
'Private'
);
});
test('handles traveling state', () => {
expect(displayLocation('traveling', 'Some World')).toBe(
'Traveling'
);
expect(displayLocation('traveling:traveling', 'Some World')).toBe(
'Traveling'
);
});
test('shows world with access type for instance', () => {
const result = displayLocation(
'wrld_12345:instance~private(usr_123)',
'Test World'
);
expect(result).toBe('Test World invite');
});
test('includes group name when provided', () => {
const result = displayLocation(
'wrld_12345:instance~group(grp_123)',
'Test World',
'My Group'
);
expect(result).toBe('Test World group(My Group)');
});
test('shows different access types correctly', () => {
const worldName = 'Test World';
expect(displayLocation('wrld_12345:instance', worldName)).toBe(
'Test World public'
);
expect(
displayLocation(
'wrld_12345:instance~private(usr_123)',
worldName
)
).toBe('Test World invite');
expect(
displayLocation(
'wrld_12345:instance~private(usr_123)~canRequestInvite',
worldName
)
).toBe('Test World invite+');
expect(
displayLocation(
'wrld_12345:instance~friends(usr_123)',
worldName
)
).toBe('Test World friends');
expect(
displayLocation(
'wrld_12345:instance~hidden(usr_123)',
worldName
)
).toBe('Test World friends+');
expect(
displayLocation('wrld_12345:instance~group(grp_123)', worldName)
).toBe('Test World group');
});
test('shows group access types correctly', () => {
const worldName = 'Test World';
const groupName = 'Test Group';
expect(
displayLocation(
'wrld_12345:instance~group(grp_123)~groupAccessType(public)',
worldName,
groupName
)
).toBe('Test World groupPublic(Test Group)');
expect(
displayLocation(
'wrld_12345:instance~group(grp_123)~groupAccessType(plus)',
worldName,
groupName
)
).toBe('Test World groupPlus(Test Group)');
});
test('prioritizes group name over access type when both available', () => {
const result = displayLocation(
'wrld_12345:instance~private(usr_123)',
'Test World',
'Override Group'
);
expect(result).toBe('Test World invite(Override Group)');
});
test('handles empty or missing world name', () => {
expect(displayLocation('wrld_12345:instance', '')).toBe(' public');
expect(displayLocation('wrld_12345:instance', undefined)).toBe(
'undefined public'
);
});
test('handles empty or missing group name', () => {
const result = displayLocation(
'wrld_12345:instance~group(grp_123)',
'Test World',
''
);
expect(result).toBe('Test World group');
});
test('handles local instances', () => {
const result = displayLocation('local:12345', 'Local World');
expect(result).toBe('Local World');
});
test('handles malformed location strings', () => {
expect(displayLocation('invalid-location', 'Test World')).toBe(
'Test World'
);
expect(displayLocation('', 'Test World')).toBe('Test World');
expect(displayLocation(null, 'Test World')).toBe('Test World');
});
});
describe('integration and edge cases', () => {
test('parseLocation handles all known access types', () => {
const testCases = [
{ location: 'wrld_test:public', expectedAccessType: 'public' },
{
location: 'wrld_test:private~private(usr_123)',
expectedAccessType: 'invite'
},
{
location:
'wrld_test:private~private(usr_123)~canRequestInvite',
expectedAccessType: 'invite+'
},
{
location: 'wrld_test:friends~friends(usr_123)',
expectedAccessType: 'friends'
},
{
location: 'wrld_test:hidden~hidden(usr_123)',
expectedAccessType: 'friends+'
},
{
location: 'wrld_test:group~group(grp_123)',
expectedAccessType: 'group'
}
];
testCases.forEach(({ location, expectedAccessType }) => {
const result = parseLocation(location);
expect(result.accessType).toBe(expectedAccessType);
});
});
test('displayLocation and parseLocation work together consistently', () => {
const testCases = [
{ location: 'offline', worldName: 'Test', expected: 'Offline' },
{ location: 'private', worldName: 'Test', expected: 'Private' },
{
location: 'traveling',
worldName: 'Test',
expected: 'Traveling'
},
{
location: 'wrld_12345',
worldName: 'Test World',
expected: 'Test World'
},
{
location: 'wrld_12345:instance',
worldName: 'Test World',
expected: 'Test World public'
}
];
testCases.forEach(({ location, worldName, expected }) => {
const result = displayLocation(location, worldName);
expect(result).toBe(expected);
});
});
test('parseLocation maintains consistency across parameter order', () => {
// Different parameter orders should produce same result
const location1 =
'wrld_12345:instance~region(us)~private(usr_123)~strict';
const location2 =
'wrld_12345:instance~strict~private(usr_123)~region(us)';
const result1 = parseLocation(location1);
const result2 = parseLocation(location2);
expect(result1.region).toBe(result2.region);
expect(result1.privateId).toBe(result2.privateId);
expect(result1.strict).toBe(result2.strict);
expect(result1.accessType).toBe(result2.accessType);
});
test('parseLocation handles extremely long world IDs', () => {
const longWorldId = 'wrld_' + 'a'.repeat(100);
const result = parseLocation(longWorldId);
expect(result.worldId).toBe(longWorldId);
expect(result.isRealInstance).toBe(true);
});
test('error recovery with corrupted location strings', () => {
const corruptedCases = [
'wrld_12345:',
'wrld_12345:~',
'wrld_12345:~~~',
'wrld_12345:instance~(',
'wrld_12345:instance~)(',
'wrld_12345:instance~region((nested))'
];
corruptedCases.forEach((location) => {
expect(() => {
const result = parseLocation(location);
expect(typeof result).toBe('object');
expect(result.worldId).toBeDefined();
}).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,36 @@
import { removeFromArray } from '../array';
describe('Array Utils', () => {
describe('removeFromArray', () => {
test('removes items', () => {
const arr = [1, 2, 3];
expect(removeFromArray(arr, 2)).toBe(true);
expect(arr).toEqual([1, 3]);
});
test('removes objects', () => {
const obj = { id: 1 };
const arr = [obj, { id: 2 }];
expect(removeFromArray(arr, obj)).toBe(true);
expect(arr).toHaveLength(1);
});
test('handles missing items', () => {
const arr = [1, 2, 3];
expect(removeFromArray(arr, 4)).toBe(false);
});
test('removes first occurrence only', () => {
const arr = [1, 2, 2, 3];
removeFromArray(arr, 2);
expect(arr).toEqual([1, 2, 3]);
});
test('handles null items', () => {
const arr = [1, null, 2];
// @ts-ignore
expect(removeFromArray(arr, null)).toBe(true);
expect(arr).toEqual([1, 2]);
});
});
});

View File

@@ -0,0 +1,27 @@
import { timeToText } from '../format';
describe('Format Utils', () => {
describe('timeToText', () => {
test('formats basic durations', () => {
expect(timeToText(1000)).toBe('1s');
expect(timeToText(60000)).toBe('1m');
expect(timeToText(3600000)).toBe('1h');
});
test('formats with seconds flag', () => {
expect(timeToText(60000, true)).toBe('1m 0s');
expect(timeToText(90000, true)).toBe('1m 30s');
});
test('handles zero and negative', () => {
expect(timeToText(0)).toBe('0s');
expect(timeToText(-1000)).toBe('1s');
});
test('handles complex time', () => {
const result = timeToText(90061000);
expect(result).toContain('1d');
expect(result).toContain('1h');
});
});
});

View File

@@ -0,0 +1,102 @@
import {
escapeTag,
escapeTagRecursive,
textToHex,
commaNumber,
localeIncludes,
changeLogRemoveLinks
} from '../string';
describe('String Utils', () => {
describe('escapeTag', () => {
test('escapes HTML characters', () => {
expect(escapeTag('<script>')).toBe('&#60;script&#62;');
expect(escapeTag('Hello & goodbye')).toBe('Hello &#38; goodbye');
expect(escapeTag('"test"')).toBe('&#34;test&#34;');
});
test('handles different types', () => {
expect(escapeTag('')).toBe('');
// @ts-ignore
expect(escapeTag(null)).toBe('null');
// @ts-ignore
expect(escapeTag(123)).toBe('123');
});
});
describe('escapeTagRecursive', () => {
test('escapes nested objects', () => {
const input = {
name: '<script>alert("xss")</script>',
data: { value: 'Hello & world' }
};
const result = escapeTagRecursive(input);
expect(result.name).toBe(
'&#60;script&#62;alert(&#34;xss&#34;)&#60;/script&#62;'
);
expect(result.data.value).toBe('Hello &#38; world');
});
test('handles arrays', () => {
const input = ['<script>', 'normal text'];
const result = escapeTagRecursive(input);
expect(result[0]).toBe('&#60;script&#62;');
expect(result[1]).toBe('normal text');
});
});
describe('textToHex', () => {
test('converts basic text', () => {
expect(textToHex('ABC')).toBe('41 42 43');
expect(textToHex('Hello')).toBe('48 65 6c 6c 6f');
});
test('handles special cases', () => {
expect(textToHex('')).toBe('');
// @ts-ignore
expect(textToHex(123)).toBe('31 32 33');
});
});
describe('commaNumber', () => {
test('formats numbers', () => {
expect(commaNumber(1000)).toBe('1,000');
expect(commaNumber(1234567)).toBe('1,234,567');
});
test('handles edge cases', () => {
expect(commaNumber(0)).toBe('0');
// @ts-ignore
expect(commaNumber(null)).toBe('0');
// @ts-ignore
expect(commaNumber('abc')).toBe('0');
});
});
describe('localeIncludes', () => {
const comparer = {
compare: (a, b) => a.toLowerCase().localeCompare(b.toLowerCase())
};
test('finds substrings', () => {
expect(localeIncludes('Hello World', 'hello', comparer)).toBe(true);
expect(localeIncludes('Hello World', 'xyz', comparer)).toBe(false);
});
test('handles empty search', () => {
expect(localeIncludes('Hello', '', comparer)).toBe(true);
});
});
describe('changeLogRemoveLinks', () => {
test('removes markdown links', () => {
expect(
changeLogRemoveLinks('Hello [world](http://example.com)')
).toBe('Hello ');
});
test('preserves image links', () => {
expect(changeLogRemoveLinks('![image](url)')).toBe('![image](url)');
});
});
});

View File

@@ -47,7 +47,11 @@ function commaNumber(num) {
if (!num) {
return '0';
}
const s = String(Number(num));
const numValue = Number(num);
if (isNaN(numValue)) {
return '0';
}
const s = String(numValue);
return s.replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
}

View File

@@ -1,4 +1,4 @@
import groupRequest from '../../api/group';
import { groupRequest } from '../../api';
import { parseLocation } from './location';
/**

View File

@@ -2,10 +2,10 @@
*
* @param {string} location
* @param {string} worldName
* @param {string} groupName
* @param {string?} groupName
* @returns {string}
*/
function displayLocation(location, worldName, groupName) {
function displayLocation(location, worldName, groupName = '') {
let text = worldName;
const L = parseLocation(location);
if (L.isOffline) {

View File

@@ -1,4 +1,3 @@
import { storeToRefs } from 'pinia';
import { database } from '../../service/database.js';
import { useFriendStore } from '../../stores';
@@ -44,7 +43,6 @@ async function getUserMemo(userId) {
*/
async function saveUserMemo(id, memo) {
const friendStore = useFriendStore();
const { friends } = storeToRefs(friendStore);
if (memo) {
await database.setUserMemo({
userId: id,
@@ -54,7 +52,7 @@ async function saveUserMemo(id, memo) {
} else {
await database.deleteUserMemo(id);
}
const ref = friends.value.get(id);
const ref = friendStore.friends.get(id);
if (ref) {
ref.memo = String(memo || '');
if (memo) {
@@ -71,10 +69,9 @@ async function saveUserMemo(id, memo) {
*/
async function getAllUserMemos() {
const friendStore = useFriendStore();
const { friends } = storeToRefs(friendStore);
const memos = await database.getAllUserMemos();
memos.forEach((memo) => {
const ref = friends.value.get(memo.userId);
const ref = friendStore.friends.get(memo.userId);
if (typeof ref !== 'undefined') {
ref.memo = memo.memo;
ref.$nickName = '';

View File

@@ -1,4 +1,3 @@
import { storeToRefs } from 'pinia';
import { useAppearanceSettingsStore, useUserStore } from '../../stores';
import { languageMappings } from '../constants';
import { timeToText } from './base/format';
@@ -182,15 +181,13 @@ function userImage(
isUserDialogIcon = false
) {
const appAppearanceSettingsStore = useAppearanceSettingsStore();
const { displayVRCPlusIconsAsAvatar } = storeToRefs(
appAppearanceSettingsStore
);
if (!user) {
return '';
}
if (
(isUserDialogIcon && user.userIcon) ||
(displayVRCPlusIconsAsAvatar.value && user.userIcon)
(appAppearanceSettingsStore.displayVRCPlusIconsAsAvatar &&
user.userIcon)
) {
if (isIcon) {
return convertFileUrlToImageUrl(user.userIcon);
@@ -238,10 +235,10 @@ function userImage(
*/
function userImageFull(user) {
const appAppearanceSettingsStore = useAppearanceSettingsStore();
const { displayVRCPlusIconsAsAvatar } = storeToRefs(
appAppearanceSettingsStore
);
if (displayVRCPlusIconsAsAvatar.value && user.userIcon) {
if (
appAppearanceSettingsStore.displayVRCPlusIconsAsAvatar &&
user.userIcon
) {
return user.userIcon;
}
if (user.profilePicOverride) {

View File

@@ -1,4 +1,5 @@
/// <reference types="node" />
/// <reference types="jest" />
declare global {
const WINDOWS: boolean;