mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-30 12:13:48 +02:00
move coordinators folder
This commit is contained in:
139
src/coordinators/__tests__/authAutoLoginCoordinator.test.js
Normal file
139
src/coordinators/__tests__/authAutoLoginCoordinator.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createAuthAutoLoginCoordinator } from '../authAutoLoginCoordinator';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<void>} Promise flush helper.
|
||||
*/
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} overrides Dependency overrides for a specific test case.
|
||||
* @returns {object} Mock dependencies for auth auto-login coordinator.
|
||||
*/
|
||||
function makeDeps(overrides = {}) {
|
||||
let attemptingAutoLogin = false;
|
||||
|
||||
const deps = {
|
||||
getIsAttemptingAutoLogin: vi.fn(() => attemptingAutoLogin),
|
||||
setAttemptingAutoLogin: vi.fn((value) => {
|
||||
attemptingAutoLogin = value;
|
||||
}),
|
||||
getLastUserLoggedIn: vi.fn(() => 'usr_1'),
|
||||
getSavedCredentials: vi.fn().mockResolvedValue({
|
||||
user: { id: 'usr_1' },
|
||||
loginParams: {}
|
||||
}),
|
||||
isPrimaryPasswordEnabled: vi.fn(() => false),
|
||||
handleLogoutEvent: vi.fn().mockResolvedValue(undefined),
|
||||
autoLoginAttempts: new Set(),
|
||||
relogin: vi.fn().mockResolvedValue(undefined),
|
||||
notifyAutoLoginSuccess: vi.fn(),
|
||||
notifyAutoLoginFailed: vi.fn(),
|
||||
notifyOffline: vi.fn(),
|
||||
flashWindow: vi.fn(),
|
||||
isOnline: vi.fn(() => true),
|
||||
now: vi.fn(() => 1000)
|
||||
};
|
||||
|
||||
return {
|
||||
...deps,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('createAuthAutoLoginCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
test('returns early when auto-login is already in progress', async () => {
|
||||
const deps = makeDeps({
|
||||
getIsAttemptingAutoLogin: vi.fn(() => true)
|
||||
});
|
||||
const coordinator = createAuthAutoLoginCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleAutoLoginFlow();
|
||||
|
||||
expect(deps.setAttemptingAutoLogin).not.toHaveBeenCalled();
|
||||
expect(deps.getSavedCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('stops flow when no saved credentials are found', async () => {
|
||||
const deps = makeDeps({
|
||||
getSavedCredentials: vi.fn().mockResolvedValue(null)
|
||||
});
|
||||
const coordinator = createAuthAutoLoginCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleAutoLoginFlow();
|
||||
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(1, true);
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(2, false);
|
||||
expect(deps.relogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logs out when primary password is enabled', async () => {
|
||||
const deps = makeDeps({
|
||||
isPrimaryPasswordEnabled: vi.fn(() => true)
|
||||
});
|
||||
const coordinator = createAuthAutoLoginCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleAutoLoginFlow();
|
||||
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(1, true);
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenNthCalledWith(2, false);
|
||||
expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1);
|
||||
expect(deps.relogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logs out and flashes window when attempts exceed limit', async () => {
|
||||
const deps = makeDeps({
|
||||
autoLoginAttempts: new Set([100, 200, 300]),
|
||||
now: vi.fn(() => 500)
|
||||
});
|
||||
const coordinator = createAuthAutoLoginCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleAutoLoginFlow();
|
||||
|
||||
expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1);
|
||||
expect(deps.flashWindow).toHaveBeenCalledTimes(1);
|
||||
expect(deps.relogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runs relogin and success notify on successful auto-login', async () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createAuthAutoLoginCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleAutoLoginFlow();
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.autoLoginAttempts.has(1000)).toBe(true);
|
||||
expect(deps.relogin).toHaveBeenCalledTimes(1);
|
||||
expect(deps.notifyAutoLoginSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(deps.notifyAutoLoginFailed).not.toHaveBeenCalled();
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
test('runs failure and offline notifications when relogin fails while offline', async () => {
|
||||
const deps = makeDeps({
|
||||
relogin: vi.fn().mockRejectedValue(new Error('failed')),
|
||||
isOnline: vi.fn(() => false)
|
||||
});
|
||||
const coordinator = createAuthAutoLoginCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleAutoLoginFlow();
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.notifyAutoLoginSuccess).not.toHaveBeenCalled();
|
||||
expect(deps.notifyAutoLoginFailed).toHaveBeenCalledTimes(1);
|
||||
expect(deps.notifyOffline).toHaveBeenCalledTimes(1);
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
96
src/coordinators/__tests__/authCoordinator.test.js
Normal file
96
src/coordinators/__tests__/authCoordinator.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createAuthCoordinator } from '../authCoordinator';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {object} Mock dependencies for auth coordinator.
|
||||
*/
|
||||
function makeDeps() {
|
||||
return {
|
||||
userStore: {
|
||||
currentUser: { id: 'usr_1' },
|
||||
setUserDialogVisible: vi.fn(),
|
||||
applyCurrentUser: vi.fn()
|
||||
},
|
||||
notificationStore: {
|
||||
setNotificationInitStatus: vi.fn()
|
||||
},
|
||||
updateLoopStore: {
|
||||
setNextCurrentUserRefresh: vi.fn()
|
||||
},
|
||||
initWebsocket: vi.fn(),
|
||||
updateStoredUser: vi.fn().mockResolvedValue(undefined),
|
||||
webApiService: {
|
||||
clearCookies: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
loginForm: {
|
||||
value: {
|
||||
lastUserLoggedIn: 'usr_1'
|
||||
}
|
||||
},
|
||||
configRepository: {
|
||||
remove: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
setAttemptingAutoLogin: vi.fn(),
|
||||
autoLoginAttempts: {
|
||||
clear: vi.fn()
|
||||
},
|
||||
closeWebSocket: vi.fn(),
|
||||
queryClient: {
|
||||
clear: vi.fn()
|
||||
},
|
||||
watchState: {
|
||||
isLoggedIn: true,
|
||||
isFriendsLoaded: true,
|
||||
isFavoritesLoaded: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('createAuthCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('runLogoutFlow applies all logout side effects', async () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createAuthCoordinator(deps);
|
||||
|
||||
await coordinator.runLogoutFlow();
|
||||
|
||||
expect(deps.userStore.setUserDialogVisible).toHaveBeenCalledWith(false);
|
||||
expect(deps.watchState.isLoggedIn).toBe(false);
|
||||
expect(deps.watchState.isFriendsLoaded).toBe(false);
|
||||
expect(deps.watchState.isFavoritesLoaded).toBe(false);
|
||||
expect(
|
||||
deps.notificationStore.setNotificationInitStatus
|
||||
).toHaveBeenCalledWith(false);
|
||||
expect(deps.updateStoredUser).toHaveBeenCalledWith(
|
||||
deps.userStore.currentUser
|
||||
);
|
||||
expect(deps.webApiService.clearCookies).toHaveBeenCalledTimes(1);
|
||||
expect(deps.loginForm.value.lastUserLoggedIn).toBe('');
|
||||
expect(deps.configRepository.remove).toHaveBeenCalledWith(
|
||||
'lastUserLoggedIn'
|
||||
);
|
||||
expect(deps.setAttemptingAutoLogin).toHaveBeenCalledWith(false);
|
||||
expect(deps.autoLoginAttempts.clear).toHaveBeenCalledTimes(1);
|
||||
expect(deps.closeWebSocket).toHaveBeenCalledTimes(1);
|
||||
expect(deps.queryClient.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('runLoginSuccessFlow applies login success side effects', () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createAuthCoordinator(deps);
|
||||
const json = { id: 'usr_2' };
|
||||
|
||||
coordinator.runLoginSuccessFlow(json);
|
||||
|
||||
expect(
|
||||
deps.updateLoopStore.setNextCurrentUserRefresh
|
||||
).toHaveBeenCalledWith(420);
|
||||
expect(deps.userStore.applyCurrentUser).toHaveBeenCalledWith(json);
|
||||
expect(deps.initWebsocket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
159
src/coordinators/__tests__/friendPresenceCoordinator.test.js
Normal file
159
src/coordinators/__tests__/friendPresenceCoordinator.test.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createFriendPresenceCoordinator } from '../friendPresenceCoordinator';
|
||||
|
||||
/**
|
||||
* @returns {object} Mock dependencies and mutable state for friend presence tests.
|
||||
*/
|
||||
function makeDeps() {
|
||||
const friends = new Map();
|
||||
const cachedUsers = new Map();
|
||||
const pendingOfflineMap = new Map();
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
state: 'online',
|
||||
displayName: 'User 1',
|
||||
location: 'wrld_1:1',
|
||||
$location_at: 100,
|
||||
$lastFetch: 0,
|
||||
$online_for: 1,
|
||||
$offline_for: '',
|
||||
$active_for: ''
|
||||
};
|
||||
const ctx = {
|
||||
id: 'usr_1',
|
||||
state: 'online',
|
||||
ref,
|
||||
name: 'User 1',
|
||||
isVIP: false,
|
||||
pendingOffline: false
|
||||
};
|
||||
friends.set('usr_1', ctx);
|
||||
cachedUsers.set('usr_1', ref);
|
||||
|
||||
return {
|
||||
deps: {
|
||||
friends,
|
||||
localFavoriteFriends: new Set(),
|
||||
pendingOfflineMap,
|
||||
pendingOfflineDelay: 100,
|
||||
watchState: { isFriendsLoaded: true },
|
||||
appDebug: { debugFriendState: false },
|
||||
getCachedUsers: vi.fn(() => cachedUsers),
|
||||
isRealInstance: vi.fn(() => false),
|
||||
requestUser: vi.fn(),
|
||||
getWorldName: vi.fn().mockResolvedValue('World 1'),
|
||||
getGroupName: vi.fn().mockResolvedValue('Group 1'),
|
||||
feedStore: {
|
||||
addFeed: vi.fn()
|
||||
},
|
||||
database: {
|
||||
addOnlineOfflineToDatabase: vi.fn()
|
||||
},
|
||||
updateOnlineFriendCounter: vi.fn(),
|
||||
now: vi.fn(() => 1000),
|
||||
nowIso: vi.fn(() => '2025-01-01T00:00:00.000Z')
|
||||
},
|
||||
ctx,
|
||||
ref,
|
||||
pendingOfflineMap
|
||||
};
|
||||
}
|
||||
|
||||
describe('createFriendPresenceCoordinator', () => {
|
||||
test('queues pending offline transition when friend moves from online to offline', async () => {
|
||||
const { deps, ctx, pendingOfflineMap } = makeDeps();
|
||||
const coordinator = createFriendPresenceCoordinator(deps);
|
||||
|
||||
await coordinator.runUpdateFriendFlow('usr_1', 'offline');
|
||||
|
||||
expect(ctx.pendingOffline).toBe(true);
|
||||
expect(pendingOfflineMap.has('usr_1')).toBe(true);
|
||||
expect(deps.updateOnlineFriendCounter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('processes pending offline queue and applies delayed transition', async () => {
|
||||
const { deps, ctx, pendingOfflineMap, ref } = makeDeps();
|
||||
pendingOfflineMap.set('usr_1', {
|
||||
startTime: 0,
|
||||
newState: 'offline',
|
||||
previousLocation: 'wrld_1:1',
|
||||
previousLocationAt: 500
|
||||
});
|
||||
const coordinator = createFriendPresenceCoordinator(deps);
|
||||
|
||||
await coordinator.runPendingOfflineTickFlow();
|
||||
|
||||
expect(ctx.state).toBe('offline');
|
||||
expect(ctx.pendingOffline).toBe(false);
|
||||
expect(pendingOfflineMap.has('usr_1')).toBe(false);
|
||||
expect(ref.$offline_for).toBe(1000);
|
||||
expect(deps.feedStore.addFeed).toHaveBeenCalledTimes(1);
|
||||
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'Offline',
|
||||
userId: 'usr_1',
|
||||
location: 'wrld_1:1',
|
||||
worldName: 'World 1',
|
||||
groupName: 'Group 1',
|
||||
time: 500
|
||||
})
|
||||
);
|
||||
expect(deps.database.addOnlineOfflineToDatabase).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(deps.updateOnlineFriendCounter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('cancels pending offline transition when online state arrives again', async () => {
|
||||
const { deps, ctx, pendingOfflineMap } = makeDeps();
|
||||
pendingOfflineMap.set('usr_1', {
|
||||
startTime: 900,
|
||||
newState: 'offline',
|
||||
previousLocation: 'wrld_1:1',
|
||||
previousLocationAt: 800
|
||||
});
|
||||
const coordinator = createFriendPresenceCoordinator(deps);
|
||||
|
||||
await coordinator.runUpdateFriendFlow('usr_1', 'online');
|
||||
|
||||
expect(ctx.pendingOffline).toBe(false);
|
||||
expect(pendingOfflineMap.has('usr_1')).toBe(false);
|
||||
});
|
||||
|
||||
test('applies offline to online transition contract immediately', async () => {
|
||||
const { deps, ctx, ref } = makeDeps();
|
||||
ctx.state = 'offline';
|
||||
const coordinator = createFriendPresenceCoordinator(deps);
|
||||
|
||||
await coordinator.runUpdateFriendFlow('usr_1', 'online');
|
||||
|
||||
expect(ctx.state).toBe('online');
|
||||
expect(ref.$online_for).toBe(1000);
|
||||
expect(ref.$offline_for).toBe('');
|
||||
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'Online',
|
||||
userId: 'usr_1',
|
||||
location: 'wrld_1:1',
|
||||
worldName: 'World 1',
|
||||
groupName: 'Group 1'
|
||||
})
|
||||
);
|
||||
expect(deps.database.addOnlineOfflineToDatabase).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(deps.updateOnlineFriendCounter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('returns safely when friend context does not exist', async () => {
|
||||
const { deps } = makeDeps();
|
||||
deps.friends.clear();
|
||||
const coordinator = createFriendPresenceCoordinator(deps);
|
||||
|
||||
await coordinator.runUpdateFriendFlow('usr_404', 'online');
|
||||
|
||||
expect(deps.feedStore.addFeed).not.toHaveBeenCalled();
|
||||
expect(deps.updateOnlineFriendCounter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
158
src/coordinators/__tests__/friendRelationshipCoordinator.test.js
Normal file
158
src/coordinators/__tests__/friendRelationshipCoordinator.test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createFriendRelationshipCoordinator } from '../friendRelationshipCoordinator';
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>} Promise flush helper.
|
||||
*/
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} Mock dependencies for friend relationship tests.
|
||||
*/
|
||||
function makeDeps() {
|
||||
return {
|
||||
friendLog: new Map(),
|
||||
friendLogTable: {
|
||||
value: {
|
||||
data: []
|
||||
}
|
||||
},
|
||||
getCurrentUserId: vi.fn(() => 'usr_me'),
|
||||
requestFriendStatus: vi.fn().mockResolvedValue({
|
||||
params: {
|
||||
currentUserId: 'usr_me'
|
||||
},
|
||||
json: {
|
||||
isFriend: false
|
||||
}
|
||||
}),
|
||||
handleFriendStatus: vi.fn(),
|
||||
addFriendship: vi.fn(),
|
||||
deleteFriend: vi.fn(),
|
||||
database: {
|
||||
addFriendLogHistory: vi.fn(),
|
||||
deleteFriendLogCurrent: vi.fn()
|
||||
},
|
||||
notificationStore: {
|
||||
queueFriendLogNoty: vi.fn()
|
||||
},
|
||||
sharedFeedStore: {
|
||||
addEntry: vi.fn()
|
||||
},
|
||||
favoriteStore: {
|
||||
handleFavoriteDelete: vi.fn()
|
||||
},
|
||||
uiStore: {
|
||||
notifyMenu: vi.fn()
|
||||
},
|
||||
shouldNotifyUnfriend: vi.fn(() => true),
|
||||
nowIso: vi.fn(() => '2026-03-08T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
|
||||
describe('createFriendRelationshipCoordinator', () => {
|
||||
test('runDeleteFriendshipFlow applies unfriend side effects after status check', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.friendLog.set('usr_1', {
|
||||
displayName: 'User 1'
|
||||
});
|
||||
const coordinator = createFriendRelationshipCoordinator(deps);
|
||||
|
||||
coordinator.runDeleteFriendshipFlow('usr_1');
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.requestFriendStatus).toHaveBeenCalledWith({
|
||||
userId: 'usr_1',
|
||||
currentUserId: 'usr_me'
|
||||
});
|
||||
expect(deps.handleFriendStatus).toHaveBeenCalledTimes(1);
|
||||
expect(deps.friendLog.has('usr_1')).toBe(false);
|
||||
expect(deps.database.addFriendLogHistory).toHaveBeenCalledTimes(1);
|
||||
expect(deps.database.deleteFriendLogCurrent).toHaveBeenCalledWith(
|
||||
'usr_1'
|
||||
);
|
||||
expect(deps.notificationStore.queueFriendLogNoty).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(deps.sharedFeedStore.addEntry).toHaveBeenCalledTimes(1);
|
||||
expect(deps.favoriteStore.handleFavoriteDelete).toHaveBeenCalledWith(
|
||||
'usr_1'
|
||||
);
|
||||
expect(deps.uiStore.notifyMenu).toHaveBeenCalledWith('friend-log');
|
||||
expect(deps.deleteFriend).toHaveBeenCalledWith('usr_1');
|
||||
});
|
||||
|
||||
test('runUpdateFriendshipsFlow syncs additions and stale removals', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.friendLog.set('usr_me', {
|
||||
displayName: 'Me'
|
||||
});
|
||||
deps.friendLog.set('usr_keep', {
|
||||
displayName: 'Keep'
|
||||
});
|
||||
deps.friendLog.set('usr_drop', {
|
||||
displayName: 'Drop'
|
||||
});
|
||||
const coordinator = createFriendRelationshipCoordinator(deps);
|
||||
|
||||
coordinator.runUpdateFriendshipsFlow({
|
||||
friends: ['usr_keep', 'usr_new']
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.addFriendship).toHaveBeenNthCalledWith(1, 'usr_keep');
|
||||
expect(deps.addFriendship).toHaveBeenNthCalledWith(2, 'usr_new');
|
||||
expect(deps.database.deleteFriendLogCurrent).toHaveBeenCalledWith(
|
||||
'usr_me'
|
||||
);
|
||||
expect(deps.requestFriendStatus).toHaveBeenCalledWith({
|
||||
userId: 'usr_drop',
|
||||
currentUserId: 'usr_me'
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores delayed status responses from stale current user', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.friendLog.set('usr_1', {
|
||||
displayName: 'User 1'
|
||||
});
|
||||
deps.requestFriendStatus.mockResolvedValue({
|
||||
params: {
|
||||
currentUserId: 'usr_old'
|
||||
},
|
||||
json: {
|
||||
isFriend: false
|
||||
}
|
||||
});
|
||||
const coordinator = createFriendRelationshipCoordinator(deps);
|
||||
|
||||
coordinator.runDeleteFriendshipFlow('usr_1');
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.handleFriendStatus).not.toHaveBeenCalled();
|
||||
expect(deps.friendLog.has('usr_1')).toBe(true);
|
||||
expect(deps.database.addFriendLogHistory).not.toHaveBeenCalled();
|
||||
expect(deps.deleteFriend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('respects unfriend notify switch', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.shouldNotifyUnfriend.mockReturnValue(false);
|
||||
deps.friendLog.set('usr_1', {
|
||||
displayName: 'User 1'
|
||||
});
|
||||
const coordinator = createFriendRelationshipCoordinator(deps);
|
||||
|
||||
coordinator.runDeleteFriendshipFlow('usr_1');
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.uiStore.notifyMenu).not.toHaveBeenCalled();
|
||||
expect(deps.favoriteStore.handleFavoriteDelete).toHaveBeenCalledWith(
|
||||
'usr_1'
|
||||
);
|
||||
});
|
||||
});
|
||||
133
src/coordinators/__tests__/friendSyncCoordinator.test.js
Normal file
133
src/coordinators/__tests__/friendSyncCoordinator.test.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createFriendSyncCoordinator } from '../friendSyncCoordinator';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {object} Mock dependencies for friend sync coordinator.
|
||||
*/
|
||||
function makeDeps() {
|
||||
return {
|
||||
getNextCurrentUserRefresh: vi.fn(() => 999),
|
||||
getCurrentUser: vi.fn().mockResolvedValue(undefined),
|
||||
refreshFriends: vi.fn().mockResolvedValue(undefined),
|
||||
reconnectWebSocket: vi.fn(),
|
||||
getCurrentUserId: vi.fn(() => 'usr_1'),
|
||||
getCurrentUserRef: vi.fn(() => ({ id: 'usr_1' })),
|
||||
setRefreshFriendsLoading: vi.fn(),
|
||||
setFriendsLoaded: vi.fn(),
|
||||
resetFriendLog: vi.fn(),
|
||||
isFriendLogInitialized: vi.fn().mockResolvedValue(true),
|
||||
getFriendLog: vi.fn().mockResolvedValue(undefined),
|
||||
initFriendLog: vi.fn().mockResolvedValue(undefined),
|
||||
isDontLogMeOut: vi.fn(() => false),
|
||||
showLoadFailedToast: vi.fn(),
|
||||
handleLogoutEvent: vi.fn(),
|
||||
tryApplyFriendOrder: vi.fn(),
|
||||
getAllUserStats: vi.fn(),
|
||||
hasLegacyFriendLogData: vi.fn().mockResolvedValue(false),
|
||||
removeLegacyFeedTable: vi.fn(),
|
||||
migrateMemos: vi.fn(),
|
||||
migrateFriendLog: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
describe('createFriendSyncCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('runRefreshFriendsListFlow refreshes current user before friends when refresh window is small', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.getNextCurrentUserRefresh.mockReturnValue(100);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await coordinator.runRefreshFriendsListFlow();
|
||||
|
||||
expect(deps.getCurrentUser).toHaveBeenCalledTimes(1);
|
||||
expect(deps.refreshFriends).toHaveBeenCalledTimes(1);
|
||||
expect(deps.reconnectWebSocket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('runRefreshFriendsListFlow skips current user refresh when window is large', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.getNextCurrentUserRefresh.mockReturnValue(300);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await coordinator.runRefreshFriendsListFlow();
|
||||
|
||||
expect(deps.getCurrentUser).not.toHaveBeenCalled();
|
||||
expect(deps.refreshFriends).toHaveBeenCalledTimes(1);
|
||||
expect(deps.reconnectWebSocket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('runInitFriendsListFlow loads existing friend log when initialized', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.isFriendLogInitialized.mockResolvedValue(true);
|
||||
deps.hasLegacyFriendLogData.mockResolvedValue(false);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await coordinator.runInitFriendsListFlow();
|
||||
|
||||
expect(deps.setRefreshFriendsLoading).toHaveBeenCalledWith(true);
|
||||
expect(deps.setFriendsLoaded).toHaveBeenCalledWith(false);
|
||||
expect(deps.resetFriendLog).toHaveBeenCalledTimes(1);
|
||||
expect(deps.isFriendLogInitialized).toHaveBeenCalledWith('usr_1');
|
||||
expect(deps.getFriendLog).toHaveBeenCalledWith({ id: 'usr_1' });
|
||||
expect(deps.initFriendLog).not.toHaveBeenCalled();
|
||||
expect(deps.tryApplyFriendOrder).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getAllUserStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('runInitFriendsListFlow initializes new friend log when not initialized', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.isFriendLogInitialized.mockResolvedValue(false);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await coordinator.runInitFriendsListFlow();
|
||||
|
||||
expect(deps.getFriendLog).not.toHaveBeenCalled();
|
||||
expect(deps.initFriendLog).toHaveBeenCalledWith({ id: 'usr_1' });
|
||||
});
|
||||
|
||||
test('runInitFriendsListFlow performs legacy migration when old data exists', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.hasLegacyFriendLogData.mockResolvedValue(true);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await coordinator.runInitFriendsListFlow();
|
||||
|
||||
expect(deps.removeLegacyFeedTable).toHaveBeenCalledWith('usr_1');
|
||||
expect(deps.migrateMemos).toHaveBeenCalledTimes(1);
|
||||
expect(deps.migrateFriendLog).toHaveBeenCalledWith('usr_1');
|
||||
});
|
||||
|
||||
test('runInitFriendsListFlow logs out and rethrows when load fails and dontLogMeOut is false', async () => {
|
||||
const deps = makeDeps();
|
||||
const err = new Error('load failed');
|
||||
deps.getFriendLog.mockRejectedValue(err);
|
||||
deps.isDontLogMeOut.mockReturnValue(false);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await expect(coordinator.runInitFriendsListFlow()).rejects.toThrow(
|
||||
'load failed'
|
||||
);
|
||||
expect(deps.showLoadFailedToast).toHaveBeenCalledTimes(1);
|
||||
expect(deps.handleLogoutEvent).toHaveBeenCalledTimes(1);
|
||||
expect(deps.tryApplyFriendOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runInitFriendsListFlow continues when load fails and dontLogMeOut is true', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.getFriendLog.mockRejectedValue(new Error('load failed'));
|
||||
deps.isDontLogMeOut.mockReturnValue(true);
|
||||
const coordinator = createFriendSyncCoordinator(deps);
|
||||
|
||||
await coordinator.runInitFriendsListFlow();
|
||||
|
||||
expect(deps.showLoadFailedToast).not.toHaveBeenCalled();
|
||||
expect(deps.handleLogoutEvent).not.toHaveBeenCalled();
|
||||
expect(deps.tryApplyFriendOrder).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getAllUserStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
125
src/coordinators/__tests__/gameCoordinator.test.js
Normal file
125
src/coordinators/__tests__/gameCoordinator.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createGameCoordinator } from '../gameCoordinator';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {object} Mock dependencies for game coordinator.
|
||||
*/
|
||||
function makeDeps() {
|
||||
return {
|
||||
userStore: {
|
||||
currentUser: {
|
||||
currentAvatar: 'avtr_1'
|
||||
},
|
||||
markCurrentUserGameStarted: vi.fn(),
|
||||
markCurrentUserGameStopped: vi.fn()
|
||||
},
|
||||
instanceStore: {
|
||||
removeAllQueuedInstances: vi.fn()
|
||||
},
|
||||
updateLoopStore: {
|
||||
setIpcTimeout: vi.fn(),
|
||||
setNextDiscordUpdate: vi.fn()
|
||||
},
|
||||
locationStore: {
|
||||
lastLocationReset: vi.fn()
|
||||
},
|
||||
gameLogStore: {
|
||||
clearNowPlaying: vi.fn()
|
||||
},
|
||||
vrStore: {
|
||||
updateVRLastLocation: vi.fn()
|
||||
},
|
||||
avatarStore: {
|
||||
addAvatarWearTime: vi.fn()
|
||||
},
|
||||
configRepository: {
|
||||
setBool: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
workerTimers: {
|
||||
setTimeout: vi.fn()
|
||||
},
|
||||
checkVRChatDebugLogging: vi.fn(),
|
||||
autoVRChatCacheManagement: vi.fn(),
|
||||
checkIfGameCrashed: vi.fn(),
|
||||
getIsGameNoVR: vi.fn(() => true)
|
||||
};
|
||||
}
|
||||
|
||||
describe('createGameCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('runGameRunningChangedFlow(true) runs start + shared side effects', async () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createGameCoordinator(deps);
|
||||
|
||||
await coordinator.runGameRunningChangedFlow(true);
|
||||
|
||||
expect(deps.userStore.markCurrentUserGameStarted).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(deps.configRepository.setBool).not.toHaveBeenCalled();
|
||||
expect(
|
||||
deps.userStore.markCurrentUserGameStopped
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
deps.instanceStore.removeAllQueuedInstances
|
||||
).not.toHaveBeenCalled();
|
||||
expect(deps.autoVRChatCacheManagement).not.toHaveBeenCalled();
|
||||
expect(deps.checkIfGameCrashed).not.toHaveBeenCalled();
|
||||
expect(deps.updateLoopStore.setIpcTimeout).not.toHaveBeenCalled();
|
||||
expect(deps.avatarStore.addAvatarWearTime).not.toHaveBeenCalled();
|
||||
|
||||
expect(deps.locationStore.lastLocationReset).toHaveBeenCalledTimes(1);
|
||||
expect(deps.gameLogStore.clearNowPlaying).toHaveBeenCalledTimes(1);
|
||||
expect(deps.vrStore.updateVRLastLocation).toHaveBeenCalledTimes(1);
|
||||
expect(deps.updateLoopStore.setNextDiscordUpdate).toHaveBeenCalledWith(
|
||||
0
|
||||
);
|
||||
|
||||
expect(deps.workerTimers.setTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(deps.workerTimers.setTimeout.mock.calls[0][1]).toBe(60000);
|
||||
const timeoutCb = deps.workerTimers.setTimeout.mock.calls[0][0];
|
||||
timeoutCb();
|
||||
expect(deps.checkVRChatDebugLogging).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('runGameRunningChangedFlow(false) runs stop + shared side effects', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.getIsGameNoVR.mockReturnValue(false);
|
||||
const coordinator = createGameCoordinator(deps);
|
||||
|
||||
await coordinator.runGameRunningChangedFlow(false);
|
||||
|
||||
expect(deps.getIsGameNoVR).toHaveBeenCalledTimes(1);
|
||||
expect(deps.configRepository.setBool).toHaveBeenCalledWith(
|
||||
'isGameNoVR',
|
||||
false
|
||||
);
|
||||
expect(deps.userStore.markCurrentUserGameStopped).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(
|
||||
deps.instanceStore.removeAllQueuedInstances
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(deps.autoVRChatCacheManagement).toHaveBeenCalledTimes(1);
|
||||
expect(deps.checkIfGameCrashed).toHaveBeenCalledTimes(1);
|
||||
expect(deps.updateLoopStore.setIpcTimeout).toHaveBeenCalledWith(0);
|
||||
expect(deps.avatarStore.addAvatarWearTime).toHaveBeenCalledWith(
|
||||
'avtr_1'
|
||||
);
|
||||
|
||||
expect(deps.locationStore.lastLocationReset).toHaveBeenCalledTimes(1);
|
||||
expect(deps.gameLogStore.clearNowPlaying).toHaveBeenCalledTimes(1);
|
||||
expect(deps.vrStore.updateVRLastLocation).toHaveBeenCalledTimes(1);
|
||||
expect(deps.updateLoopStore.setNextDiscordUpdate).toHaveBeenCalledWith(
|
||||
0
|
||||
);
|
||||
|
||||
expect(deps.workerTimers.setTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(deps.workerTimers.setTimeout.mock.calls[0][1]).toBe(60000);
|
||||
});
|
||||
});
|
||||
251
src/coordinators/__tests__/userEventCoordinator.test.js
Normal file
251
src/coordinators/__tests__/userEventCoordinator.test.js
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createUserEventCoordinator } from '../userEventCoordinator';
|
||||
|
||||
/**
|
||||
* @returns {object} Mock dependencies for user event tests.
|
||||
*/
|
||||
function makeDeps() {
|
||||
const friendRef = {
|
||||
id: 'usr_1'
|
||||
};
|
||||
|
||||
return {
|
||||
friendStore: {
|
||||
friends: new Map([['usr_1', friendRef]])
|
||||
},
|
||||
state: {
|
||||
instancePlayerCount: new Map()
|
||||
},
|
||||
parseLocation: vi.fn((location) => {
|
||||
if (location === 'loc_old') {
|
||||
return {
|
||||
tag: 'loc_old',
|
||||
worldId: 'world_old',
|
||||
groupId: 'group_old'
|
||||
};
|
||||
}
|
||||
if (location === 'loc_new') {
|
||||
return {
|
||||
tag: 'loc_new',
|
||||
worldId: 'world_new',
|
||||
groupId: 'group_new'
|
||||
};
|
||||
}
|
||||
return {
|
||||
tag: location,
|
||||
worldId: '',
|
||||
groupId: ''
|
||||
};
|
||||
}),
|
||||
userDialog: {
|
||||
value: {
|
||||
$location: {
|
||||
tag: 'loc_new'
|
||||
}
|
||||
}
|
||||
},
|
||||
applyUserDialogLocation: vi.fn(),
|
||||
worldStore: {
|
||||
worldDialog: {
|
||||
id: 'world_old'
|
||||
}
|
||||
},
|
||||
groupStore: {
|
||||
groupDialog: {
|
||||
id: 'group_new'
|
||||
}
|
||||
},
|
||||
instanceStore: {
|
||||
applyWorldDialogInstances: vi.fn(),
|
||||
applyGroupDialogInstances: vi.fn()
|
||||
},
|
||||
appDebug: {
|
||||
debugFriendState: false
|
||||
},
|
||||
getWorldName: vi.fn().mockResolvedValue('World'),
|
||||
getGroupName: vi.fn().mockResolvedValue('Group'),
|
||||
feedStore: {
|
||||
addFeed: vi.fn()
|
||||
},
|
||||
database: {
|
||||
addGPSToDatabase: vi.fn(),
|
||||
addAvatarToDatabase: vi.fn(),
|
||||
addStatusToDatabase: vi.fn(),
|
||||
addBioToDatabase: vi.fn()
|
||||
},
|
||||
avatarStore: {
|
||||
getAvatarName: vi.fn().mockResolvedValue({
|
||||
ownerId: 'usr_owner',
|
||||
avatarName: 'Avatar'
|
||||
})
|
||||
},
|
||||
generalSettingsStore: {
|
||||
logEmptyAvatars: false
|
||||
},
|
||||
checkNote: vi.fn(),
|
||||
now: vi.fn(() => 1000),
|
||||
nowIso: vi.fn(() => '2025-01-01T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
|
||||
describe('createUserEventCoordinator', () => {
|
||||
test('returns early when target user is not in friend map', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.friendStore.friends.clear();
|
||||
const coordinator = createUserEventCoordinator(deps);
|
||||
|
||||
await coordinator.runHandleUserUpdateFlow(
|
||||
{
|
||||
id: 'usr_404',
|
||||
displayName: 'Unknown'
|
||||
},
|
||||
{
|
||||
status: ['online', 'offline']
|
||||
}
|
||||
);
|
||||
|
||||
expect(deps.feedStore.addFeed).not.toHaveBeenCalled();
|
||||
expect(deps.database.addStatusToDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updates location counters and dialog instance hooks on location change', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.state.instancePlayerCount.set('loc_old', 2);
|
||||
const coordinator = createUserEventCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
displayName: 'User 1'
|
||||
};
|
||||
|
||||
await coordinator.runHandleUserUpdateFlow(ref, {
|
||||
location: ['loc_new', 'loc_old', 50],
|
||||
state: ['online', 'online']
|
||||
});
|
||||
|
||||
expect(deps.state.instancePlayerCount.get('loc_old')).toBe(1);
|
||||
expect(deps.state.instancePlayerCount.get('loc_new')).toBe(1);
|
||||
expect(deps.applyUserDialogLocation).toHaveBeenCalledWith(true);
|
||||
expect(deps.instanceStore.applyWorldDialogInstances).toHaveBeenCalled();
|
||||
expect(deps.instanceStore.applyGroupDialogInstances).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('writes GPS feed with adjusted traveling time contract', async () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createUserEventCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
displayName: 'User 1',
|
||||
$previousLocation: 'loc_old',
|
||||
$travelingToTime: 900,
|
||||
$location_at: 700
|
||||
};
|
||||
|
||||
await coordinator.runHandleUserUpdateFlow(ref, {
|
||||
location: ['loc_new', 'traveling', 300]
|
||||
});
|
||||
|
||||
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'GPS',
|
||||
userId: 'usr_1',
|
||||
previousLocation: 'loc_old',
|
||||
location: 'loc_new',
|
||||
worldName: 'World',
|
||||
groupName: 'Group',
|
||||
time: 200
|
||||
})
|
||||
);
|
||||
expect(deps.database.addGPSToDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ref.$previousLocation).toBe('');
|
||||
expect(ref.$travelingToTime).toBe(1000);
|
||||
});
|
||||
|
||||
test('stores previous location while user becomes traveling', async () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createUserEventCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
displayName: 'User 1',
|
||||
$previousLocation: '',
|
||||
$travelingToTime: 0
|
||||
};
|
||||
|
||||
await coordinator.runHandleUserUpdateFlow(ref, {
|
||||
location: ['traveling', 'loc_old']
|
||||
});
|
||||
|
||||
expect(ref.$previousLocation).toBe('loc_old');
|
||||
expect(ref.$travelingToTime).toBe(1000);
|
||||
});
|
||||
|
||||
test('writes status and bio feeds and triggers note check', async () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createUserEventCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
displayName: 'User 1',
|
||||
status: 'busy',
|
||||
statusDescription: 'old',
|
||||
currentAvatarImageUrl: '',
|
||||
currentAvatarThumbnailImageUrl: '',
|
||||
currentAvatarTags: [],
|
||||
profilePicOverride: ''
|
||||
};
|
||||
|
||||
await coordinator.runHandleUserUpdateFlow(ref, {
|
||||
status: ['join me', 'busy'],
|
||||
statusDescription: ['new desc', 'old desc'],
|
||||
bio: ['new bio', 'old bio'],
|
||||
note: ['new note', 'old note']
|
||||
});
|
||||
|
||||
expect(deps.feedStore.addFeed).toHaveBeenCalledTimes(2);
|
||||
expect(deps.database.addStatusToDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(deps.database.addBioToDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(deps.checkNote).toHaveBeenCalledWith('usr_1', 'new note');
|
||||
});
|
||||
|
||||
test('writes avatar change feed contract', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.generalSettingsStore.logEmptyAvatars = true;
|
||||
deps.avatarStore.getAvatarName
|
||||
.mockResolvedValueOnce({
|
||||
ownerId: 'usr_owner_new',
|
||||
avatarName: 'Avatar New'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ownerId: 'usr_owner_old',
|
||||
avatarName: 'Avatar Old'
|
||||
});
|
||||
const coordinator = createUserEventCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
displayName: 'User 1',
|
||||
currentAvatarImageUrl: 'img_old',
|
||||
currentAvatarThumbnailImageUrl: 'thumb_old',
|
||||
currentAvatarTags: ['tag_old'],
|
||||
profilePicOverride: ''
|
||||
};
|
||||
|
||||
await coordinator.runHandleUserUpdateFlow(ref, {
|
||||
currentAvatarImageUrl: ['img_new', 'img_old'],
|
||||
currentAvatarThumbnailImageUrl: ['thumb_new', 'thumb_old'],
|
||||
currentAvatarTags: [['tag_new'], ['tag_old']]
|
||||
});
|
||||
|
||||
expect(deps.database.addAvatarToDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(deps.feedStore.addFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'Avatar',
|
||||
userId: 'usr_1',
|
||||
ownerId: 'usr_owner_new',
|
||||
previousOwnerId: 'usr_owner_old',
|
||||
avatarName: 'Avatar New',
|
||||
previousAvatarName: 'Avatar Old',
|
||||
currentAvatarImageUrl: 'img_new',
|
||||
previousCurrentAvatarImageUrl: 'img_old'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
162
src/coordinators/__tests__/userSessionCoordinator.test.js
Normal file
162
src/coordinators/__tests__/userSessionCoordinator.test.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createUserSessionCoordinator } from '../userSessionCoordinator';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<void>} Promise flush helper.
|
||||
*/
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {object} Mock dependencies for user session coordinator.
|
||||
*/
|
||||
function makeDeps() {
|
||||
return {
|
||||
avatarStore: {
|
||||
addAvatarToHistory: vi.fn(),
|
||||
addAvatarWearTime: vi.fn()
|
||||
},
|
||||
gameStore: {
|
||||
isGameRunning: false
|
||||
},
|
||||
groupStore: {
|
||||
applyPresenceGroups: vi.fn()
|
||||
},
|
||||
instanceStore: {
|
||||
applyQueuedInstance: vi.fn()
|
||||
},
|
||||
friendStore: {
|
||||
updateUserCurrentStatus: vi.fn(),
|
||||
updateFriendships: vi.fn()
|
||||
},
|
||||
authStore: {
|
||||
loginComplete: vi.fn()
|
||||
},
|
||||
cachedUsers: {
|
||||
clear: vi.fn()
|
||||
},
|
||||
currentUser: {
|
||||
value: {
|
||||
homeLocation: 'wrld_current'
|
||||
}
|
||||
},
|
||||
userDialog: {
|
||||
value: {
|
||||
visible: false,
|
||||
id: '',
|
||||
$homeLocationName: ''
|
||||
}
|
||||
},
|
||||
getWorldName: vi.fn().mockResolvedValue('World Name'),
|
||||
parseLocation: vi.fn((tag) => ({ tag })),
|
||||
now: vi.fn(() => 111)
|
||||
};
|
||||
}
|
||||
|
||||
describe('createUserSessionCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('runAvatarSwapFlow does nothing when user is not logged in', () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createUserSessionCoordinator(deps);
|
||||
const ref = {
|
||||
currentAvatar: 'avtr_old',
|
||||
$previousAvatarSwapTime: null
|
||||
};
|
||||
|
||||
coordinator.runAvatarSwapFlow({
|
||||
json: { currentAvatar: 'avtr_new' },
|
||||
ref,
|
||||
isLoggedIn: false
|
||||
});
|
||||
|
||||
expect(deps.avatarStore.addAvatarToHistory).not.toHaveBeenCalled();
|
||||
expect(deps.avatarStore.addAvatarWearTime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runAvatarSwapFlow applies avatar swap side effects when game is running', () => {
|
||||
const deps = makeDeps();
|
||||
deps.gameStore.isGameRunning = true;
|
||||
const coordinator = createUserSessionCoordinator(deps);
|
||||
const ref = {
|
||||
currentAvatar: 'avtr_old',
|
||||
$previousAvatarSwapTime: null
|
||||
};
|
||||
|
||||
coordinator.runAvatarSwapFlow({
|
||||
json: { currentAvatar: 'avtr_new' },
|
||||
ref,
|
||||
isLoggedIn: true
|
||||
});
|
||||
|
||||
expect(deps.avatarStore.addAvatarToHistory).toHaveBeenCalledWith(
|
||||
'avtr_new'
|
||||
);
|
||||
expect(deps.avatarStore.addAvatarWearTime).toHaveBeenCalledWith(
|
||||
'avtr_old'
|
||||
);
|
||||
expect(ref.$previousAvatarSwapTime).toBe(111);
|
||||
});
|
||||
|
||||
test('runFirstLoginFlow clears cache, updates currentUser and completes login', () => {
|
||||
const deps = makeDeps();
|
||||
deps.gameStore.isGameRunning = true;
|
||||
const coordinator = createUserSessionCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
$previousAvatarSwapTime: null
|
||||
};
|
||||
|
||||
coordinator.runFirstLoginFlow(ref);
|
||||
|
||||
expect(deps.cachedUsers.clear).toHaveBeenCalledTimes(1);
|
||||
expect(deps.currentUser.value).toBe(ref);
|
||||
expect(deps.authStore.loginComplete).toHaveBeenCalledTimes(1);
|
||||
expect(ref.$previousAvatarSwapTime).toBe(111);
|
||||
});
|
||||
|
||||
test('runPostApplySyncFlow applies cross-store synchronization', () => {
|
||||
const deps = makeDeps();
|
||||
const coordinator = createUserSessionCoordinator(deps);
|
||||
const ref = { queuedInstance: 'wrld_1:123' };
|
||||
|
||||
coordinator.runPostApplySyncFlow(ref);
|
||||
|
||||
expect(deps.groupStore.applyPresenceGroups).toHaveBeenCalledWith(ref);
|
||||
expect(deps.instanceStore.applyQueuedInstance).toHaveBeenCalledWith(
|
||||
'wrld_1:123'
|
||||
);
|
||||
expect(deps.friendStore.updateUserCurrentStatus).toHaveBeenCalledWith(
|
||||
ref
|
||||
);
|
||||
expect(deps.friendStore.updateFriendships).toHaveBeenCalledWith(ref);
|
||||
});
|
||||
|
||||
test('runHomeLocationSyncFlow updates home location and visible dialog name', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.currentUser.value.homeLocation = 'wrld_home';
|
||||
deps.userDialog.value.visible = true;
|
||||
deps.userDialog.value.id = 'usr_1';
|
||||
const coordinator = createUserSessionCoordinator(deps);
|
||||
const ref = {
|
||||
id: 'usr_1',
|
||||
homeLocation: 'wrld_home',
|
||||
$homeLocation: { tag: 'wrld_other' }
|
||||
};
|
||||
|
||||
coordinator.runHomeLocationSyncFlow(ref);
|
||||
await flushPromises();
|
||||
|
||||
expect(deps.parseLocation).toHaveBeenCalledWith('wrld_home');
|
||||
expect(ref.$homeLocation).toEqual({ tag: 'wrld_home' });
|
||||
expect(deps.getWorldName).toHaveBeenCalledWith('wrld_home');
|
||||
expect(deps.userDialog.value.$homeLocationName).toBe('World Name');
|
||||
});
|
||||
});
|
||||
80
src/coordinators/authAutoLoginCoordinator.js
Normal file
80
src/coordinators/authAutoLoginCoordinator.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} Auto-login flow coordinator methods.
|
||||
*/
|
||||
export function createAuthAutoLoginCoordinator(deps) {
|
||||
const {
|
||||
getIsAttemptingAutoLogin,
|
||||
setAttemptingAutoLogin,
|
||||
getLastUserLoggedIn,
|
||||
getSavedCredentials,
|
||||
isPrimaryPasswordEnabled,
|
||||
handleLogoutEvent,
|
||||
autoLoginAttempts,
|
||||
relogin,
|
||||
notifyAutoLoginSuccess,
|
||||
notifyAutoLoginFailed,
|
||||
notifyOffline,
|
||||
flashWindow,
|
||||
isOnline,
|
||||
now
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Runs the full auto-login orchestration flow.
|
||||
*/
|
||||
async function runHandleAutoLoginFlow() {
|
||||
if (getIsAttemptingAutoLogin()) {
|
||||
return;
|
||||
}
|
||||
setAttemptingAutoLogin(true);
|
||||
const user = await getSavedCredentials(getLastUserLoggedIn());
|
||||
if (!user) {
|
||||
setAttemptingAutoLogin(false);
|
||||
return;
|
||||
}
|
||||
if (isPrimaryPasswordEnabled()) {
|
||||
console.error(
|
||||
'Primary password is enabled, this disables auto login.'
|
||||
);
|
||||
setAttemptingAutoLogin(false);
|
||||
await handleLogoutEvent();
|
||||
return;
|
||||
}
|
||||
const currentTimestamp = now();
|
||||
const attemptsInLastHour = Array.from(autoLoginAttempts).filter(
|
||||
(timestamp) => timestamp > currentTimestamp - 3600000
|
||||
).length;
|
||||
if (attemptsInLastHour >= 3) {
|
||||
console.error(
|
||||
'More than 3 auto login attempts within the past hour, logging out instead of attempting auto login.'
|
||||
);
|
||||
setAttemptingAutoLogin(false);
|
||||
await handleLogoutEvent();
|
||||
flashWindow();
|
||||
return;
|
||||
}
|
||||
autoLoginAttempts.add(currentTimestamp);
|
||||
console.log('Attempting automatic login...');
|
||||
relogin(user)
|
||||
.then(() => {
|
||||
notifyAutoLoginSuccess();
|
||||
console.log('Automatically logged in.');
|
||||
})
|
||||
.catch((err) => {
|
||||
notifyAutoLoginFailed();
|
||||
console.error('Failed to login automatically.', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setAttemptingAutoLogin(false);
|
||||
if (!isOnline()) {
|
||||
notifyOffline();
|
||||
console.error(`You're offline.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
runHandleAutoLoginFlow
|
||||
};
|
||||
}
|
||||
56
src/coordinators/authCoordinator.js
Normal file
56
src/coordinators/authCoordinator.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} Auth flow coordinator methods.
|
||||
*/
|
||||
export function createAuthCoordinator(deps) {
|
||||
const {
|
||||
userStore,
|
||||
notificationStore,
|
||||
updateLoopStore,
|
||||
initWebsocket,
|
||||
updateStoredUser,
|
||||
webApiService,
|
||||
loginForm,
|
||||
configRepository,
|
||||
setAttemptingAutoLogin,
|
||||
autoLoginAttempts,
|
||||
closeWebSocket,
|
||||
queryClient,
|
||||
watchState
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Runs the shared logout side effects.
|
||||
*/
|
||||
async function runLogoutFlow() {
|
||||
userStore.setUserDialogVisible(false);
|
||||
watchState.isLoggedIn = false;
|
||||
watchState.isFriendsLoaded = false;
|
||||
watchState.isFavoritesLoaded = false;
|
||||
notificationStore.setNotificationInitStatus(false);
|
||||
await updateStoredUser(userStore.currentUser);
|
||||
webApiService.clearCookies();
|
||||
loginForm.value.lastUserLoggedIn = '';
|
||||
// workerTimers.setTimeout(() => location.reload(), 500);
|
||||
await configRepository.remove('lastUserLoggedIn');
|
||||
setAttemptingAutoLogin(false);
|
||||
autoLoginAttempts.clear();
|
||||
closeWebSocket();
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs post-login side effects after a successful auth response.
|
||||
* @param {object} json Current user payload from auth API.
|
||||
*/
|
||||
function runLoginSuccessFlow(json) {
|
||||
updateLoopStore.setNextCurrentUserRefresh(420); // 7mins
|
||||
userStore.applyCurrentUser(json);
|
||||
initWebsocket();
|
||||
}
|
||||
|
||||
return {
|
||||
runLogoutFlow,
|
||||
runLoginSuccessFlow
|
||||
};
|
||||
}
|
||||
289
src/coordinators/friendPresenceCoordinator.js
Normal file
289
src/coordinators/friendPresenceCoordinator.js
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} Friend presence coordinator methods.
|
||||
*/
|
||||
export function createFriendPresenceCoordinator(deps) {
|
||||
const {
|
||||
friends,
|
||||
localFavoriteFriends,
|
||||
pendingOfflineMap,
|
||||
pendingOfflineDelay,
|
||||
watchState,
|
||||
appDebug,
|
||||
getCachedUsers,
|
||||
isRealInstance,
|
||||
requestUser,
|
||||
getWorldName,
|
||||
getGroupName,
|
||||
feedStore,
|
||||
database,
|
||||
updateOnlineFriendCounter,
|
||||
now,
|
||||
nowIso
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {string} newState
|
||||
* @param {string} location
|
||||
* @param {number} $location_at
|
||||
*/
|
||||
async function runUpdateFriendDelayedCheckFlow(
|
||||
ctx,
|
||||
newState,
|
||||
location,
|
||||
$location_at
|
||||
) {
|
||||
let feed;
|
||||
let groupName;
|
||||
let worldName;
|
||||
const id = ctx.id;
|
||||
if (appDebug.debugFriendState) {
|
||||
console.log(
|
||||
`${ctx.name} updateFriendState ${ctx.state} -> ${newState}`
|
||||
);
|
||||
if (
|
||||
typeof ctx.ref !== 'undefined' &&
|
||||
location !== ctx.ref.location
|
||||
) {
|
||||
console.log(
|
||||
`${ctx.name} pendingOfflineLocation ${location} -> ${ctx.ref.location}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!friends.has(id)) {
|
||||
console.log('Friend not found', id);
|
||||
return;
|
||||
}
|
||||
const isVIP = localFavoriteFriends.has(id);
|
||||
const ref = ctx.ref;
|
||||
if (ctx.state !== newState && typeof ctx.ref !== 'undefined') {
|
||||
if (
|
||||
(newState === 'offline' || newState === 'active') &&
|
||||
ctx.state === 'online'
|
||||
) {
|
||||
ctx.ref.$online_for = '';
|
||||
ctx.ref.$offline_for = now();
|
||||
ctx.ref.$active_for = '';
|
||||
if (newState === 'active') {
|
||||
ctx.ref.$active_for = now();
|
||||
}
|
||||
const ts = now();
|
||||
const time = ts - $location_at;
|
||||
worldName = await getWorldName(location);
|
||||
groupName = await getGroupName(location);
|
||||
feed = {
|
||||
created_at: nowIso(),
|
||||
type: 'Offline',
|
||||
userId: ref.id,
|
||||
displayName: ref.displayName,
|
||||
location,
|
||||
worldName,
|
||||
groupName,
|
||||
time
|
||||
};
|
||||
feedStore.addFeed(feed);
|
||||
database.addOnlineOfflineToDatabase(feed);
|
||||
} else if (
|
||||
newState === 'online' &&
|
||||
(ctx.state === 'offline' || ctx.state === 'active')
|
||||
) {
|
||||
ctx.ref.$previousLocation = '';
|
||||
ctx.ref.$travelingToTime = now();
|
||||
ctx.ref.$location_at = now();
|
||||
ctx.ref.$online_for = now();
|
||||
ctx.ref.$offline_for = '';
|
||||
ctx.ref.$active_for = '';
|
||||
worldName = await getWorldName(location);
|
||||
groupName = await getGroupName(location);
|
||||
feed = {
|
||||
created_at: nowIso(),
|
||||
type: 'Online',
|
||||
userId: id,
|
||||
displayName: ctx.name,
|
||||
location,
|
||||
worldName,
|
||||
groupName,
|
||||
time: ''
|
||||
};
|
||||
feedStore.addFeed(feed);
|
||||
database.addOnlineOfflineToDatabase(feed);
|
||||
}
|
||||
if (newState === 'active') {
|
||||
ctx.ref.$active_for = now();
|
||||
}
|
||||
}
|
||||
if (ctx.state !== newState) {
|
||||
ctx.state = newState;
|
||||
updateOnlineFriendCounter();
|
||||
}
|
||||
if (ref?.displayName) {
|
||||
ctx.name = ref.displayName;
|
||||
}
|
||||
ctx.isVIP = isVIP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles immediate friend presence updates and pending-offline orchestration.
|
||||
* @param {string} id Friend id.
|
||||
* @param {string | undefined} stateInput Optional incoming state.
|
||||
*/
|
||||
async function runUpdateFriendFlow(id, stateInput = undefined) {
|
||||
const ctx = friends.get(id);
|
||||
if (typeof ctx === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const ref = getCachedUsers().get(id);
|
||||
|
||||
if (stateInput === 'online') {
|
||||
const pendingOffline = pendingOfflineMap.get(id);
|
||||
if (appDebug.debugFriendState && pendingOffline) {
|
||||
const time = (now() - pendingOffline.startTime) / 1000;
|
||||
console.log(`${ctx.name} pendingOfflineCancelTime ${time}`);
|
||||
}
|
||||
ctx.pendingOffline = false;
|
||||
pendingOfflineMap.delete(id);
|
||||
}
|
||||
const isVIP = localFavoriteFriends.has(id);
|
||||
let location = '';
|
||||
let $location_at = undefined;
|
||||
if (typeof ref !== 'undefined') {
|
||||
location = ref.location;
|
||||
$location_at = ref.$location_at;
|
||||
|
||||
const currentState = stateInput || ctx.state;
|
||||
// wtf, fetch user if offline in an instance
|
||||
if (
|
||||
currentState !== 'online' &&
|
||||
isRealInstance(ref.location) &&
|
||||
ref.$lastFetch < now() - 10000 // 10 seconds
|
||||
) {
|
||||
console.log(
|
||||
`Fetching offline friend in an instance ${ctx.name}`
|
||||
);
|
||||
requestUser(id);
|
||||
}
|
||||
// wtf, fetch user if online in an offline location
|
||||
if (
|
||||
currentState === 'online' &&
|
||||
ref.location === 'offline' &&
|
||||
ref.$lastFetch < now() - 10000 // 10 seconds
|
||||
) {
|
||||
console.log(
|
||||
`Fetching online friend in an offline location ${ctx.name}`
|
||||
);
|
||||
requestUser(id);
|
||||
}
|
||||
}
|
||||
if (typeof stateInput === 'undefined' || ctx.state === stateInput) {
|
||||
// this is should be: undefined -> user
|
||||
if (ctx.ref !== ref) {
|
||||
ctx.ref = ref;
|
||||
// NOTE
|
||||
// AddFriend (CurrentUser) 이후,
|
||||
// 서버에서 오는 순서라고 보면 될 듯.
|
||||
if (ctx.state === 'online') {
|
||||
if (watchState.isFriendsLoaded) {
|
||||
requestUser(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ctx.isVIP !== isVIP) {
|
||||
ctx.isVIP = isVIP;
|
||||
}
|
||||
if (typeof ref !== 'undefined' && ctx.name !== ref.displayName) {
|
||||
ctx.name = ref.displayName;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
ctx.state === 'online' &&
|
||||
(stateInput === 'active' || stateInput === 'offline')
|
||||
) {
|
||||
ctx.ref = ref;
|
||||
ctx.isVIP = isVIP;
|
||||
if (typeof ref !== 'undefined') {
|
||||
ctx.name = ref.displayName;
|
||||
}
|
||||
if (!watchState.isFriendsLoaded) {
|
||||
await runUpdateFriendDelayedCheckFlow(
|
||||
ctx,
|
||||
stateInput,
|
||||
location,
|
||||
$location_at
|
||||
);
|
||||
return;
|
||||
}
|
||||
// prevent status flapping
|
||||
if (pendingOfflineMap.has(id)) {
|
||||
if (appDebug.debugFriendState) {
|
||||
console.log(ctx.name, 'pendingOfflineAlreadyWaiting');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (appDebug.debugFriendState) {
|
||||
console.log(ctx.name, 'pendingOfflineBegin');
|
||||
}
|
||||
pendingOfflineMap.set(id, {
|
||||
startTime: now(),
|
||||
newState: stateInput,
|
||||
previousLocation: location,
|
||||
previousLocationAt: $location_at
|
||||
});
|
||||
ctx.pendingOffline = true;
|
||||
return;
|
||||
}
|
||||
ctx.ref = ref;
|
||||
ctx.isVIP = isVIP;
|
||||
if (typeof ref !== 'undefined') {
|
||||
ctx.name = ref.displayName;
|
||||
await runUpdateFriendDelayedCheckFlow(
|
||||
ctx,
|
||||
ctx.ref.state,
|
||||
location,
|
||||
$location_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes pending-offline entries and executes delayed transitions.
|
||||
*/
|
||||
async function runPendingOfflineTickFlow() {
|
||||
const currentTime = now();
|
||||
for (const [id, pending] of pendingOfflineMap.entries()) {
|
||||
if (currentTime - pending.startTime >= pendingOfflineDelay) {
|
||||
const ctx = friends.get(id);
|
||||
if (typeof ctx === 'undefined') {
|
||||
pendingOfflineMap.delete(id);
|
||||
continue;
|
||||
}
|
||||
ctx.pendingOffline = false;
|
||||
if (pending.newState === ctx.state) {
|
||||
console.error(
|
||||
ctx.name,
|
||||
'pendingOfflineCancelledStateMatched, this should never happen'
|
||||
);
|
||||
pendingOfflineMap.delete(id);
|
||||
continue;
|
||||
}
|
||||
if (appDebug.debugFriendState) {
|
||||
console.log(ctx.name, 'pendingOfflineEnd');
|
||||
}
|
||||
pendingOfflineMap.delete(id);
|
||||
await runUpdateFriendDelayedCheckFlow(
|
||||
ctx,
|
||||
pending.newState,
|
||||
pending.previousLocation,
|
||||
pending.previousLocationAt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runUpdateFriendFlow,
|
||||
runUpdateFriendDelayedCheckFlow,
|
||||
runPendingOfflineTickFlow
|
||||
};
|
||||
}
|
||||
88
src/coordinators/friendRelationshipCoordinator.js
Normal file
88
src/coordinators/friendRelationshipCoordinator.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} Friend relationship coordinator methods.
|
||||
*/
|
||||
export function createFriendRelationshipCoordinator(deps) {
|
||||
const {
|
||||
friendLog,
|
||||
friendLogTable,
|
||||
getCurrentUserId,
|
||||
requestFriendStatus,
|
||||
handleFriendStatus,
|
||||
addFriendship,
|
||||
deleteFriend,
|
||||
database,
|
||||
notificationStore,
|
||||
sharedFeedStore,
|
||||
favoriteStore,
|
||||
uiStore,
|
||||
shouldNotifyUnfriend,
|
||||
nowIso
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Validates and applies unfriend transition side effects.
|
||||
* @param {string} id User id.
|
||||
*/
|
||||
function runDeleteFriendshipFlow(id) {
|
||||
const ctx = friendLog.get(id);
|
||||
if (typeof ctx === 'undefined') {
|
||||
return;
|
||||
}
|
||||
requestFriendStatus({
|
||||
userId: id,
|
||||
currentUserId: getCurrentUserId()
|
||||
}).then((args) => {
|
||||
if (args.params.currentUserId !== getCurrentUserId()) {
|
||||
// safety check for delayed response
|
||||
return;
|
||||
}
|
||||
handleFriendStatus(args);
|
||||
if (!args.json.isFriend && friendLog.has(id)) {
|
||||
const friendLogHistory = {
|
||||
created_at: nowIso(),
|
||||
type: 'Unfriend',
|
||||
userId: id,
|
||||
displayName: ctx.displayName || id
|
||||
};
|
||||
friendLogTable.value.data.push(friendLogHistory);
|
||||
database.addFriendLogHistory(friendLogHistory);
|
||||
notificationStore.queueFriendLogNoty(friendLogHistory);
|
||||
sharedFeedStore.addEntry(friendLogHistory);
|
||||
friendLog.delete(id);
|
||||
database.deleteFriendLogCurrent(id);
|
||||
favoriteStore.handleFavoriteDelete(id);
|
||||
if (shouldNotifyUnfriend()) {
|
||||
uiStore.notifyMenu('friend-log');
|
||||
}
|
||||
deleteFriend(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles current friend list against local friend log.
|
||||
* @param {object} ref Current user reference.
|
||||
*/
|
||||
function runUpdateFriendshipsFlow(ref) {
|
||||
let id;
|
||||
const set = new Set();
|
||||
for (id of ref.friends) {
|
||||
set.add(id);
|
||||
addFriendship(id);
|
||||
}
|
||||
for (id of friendLog.keys()) {
|
||||
if (id === getCurrentUserId()) {
|
||||
friendLog.delete(id);
|
||||
database.deleteFriendLogCurrent(id);
|
||||
} else if (!set.has(id)) {
|
||||
runDeleteFriendshipFlow(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runDeleteFriendshipFlow,
|
||||
runUpdateFriendshipsFlow
|
||||
};
|
||||
}
|
||||
81
src/coordinators/friendSyncCoordinator.js
Normal file
81
src/coordinators/friendSyncCoordinator.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} Friend sync coordinator methods.
|
||||
*/
|
||||
export function createFriendSyncCoordinator(deps) {
|
||||
const {
|
||||
getNextCurrentUserRefresh,
|
||||
getCurrentUser,
|
||||
refreshFriends,
|
||||
reconnectWebSocket,
|
||||
getCurrentUserId,
|
||||
getCurrentUserRef,
|
||||
setRefreshFriendsLoading,
|
||||
setFriendsLoaded,
|
||||
resetFriendLog,
|
||||
isFriendLogInitialized,
|
||||
getFriendLog,
|
||||
initFriendLog,
|
||||
isDontLogMeOut,
|
||||
showLoadFailedToast,
|
||||
handleLogoutEvent,
|
||||
tryApplyFriendOrder,
|
||||
getAllUserStats,
|
||||
hasLegacyFriendLogData,
|
||||
removeLegacyFeedTable,
|
||||
migrateMemos,
|
||||
migrateFriendLog
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Runs friend list refresh orchestration.
|
||||
*/
|
||||
async function runRefreshFriendsListFlow() {
|
||||
// If we just got user less then 2 min before code call, don't call it again
|
||||
if (getNextCurrentUserRefresh() < 300) {
|
||||
await getCurrentUser();
|
||||
}
|
||||
await refreshFriends();
|
||||
reconnectWebSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs full friend list initialization orchestration.
|
||||
*/
|
||||
async function runInitFriendsListFlow() {
|
||||
const userId = getCurrentUserId();
|
||||
setRefreshFriendsLoading(true);
|
||||
setFriendsLoaded(false);
|
||||
resetFriendLog();
|
||||
|
||||
try {
|
||||
const currentUser = getCurrentUserRef();
|
||||
if (await isFriendLogInitialized(userId)) {
|
||||
await getFriendLog(currentUser);
|
||||
} else {
|
||||
await initFriendLog(currentUser);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isDontLogMeOut()) {
|
||||
showLoadFailedToast();
|
||||
handleLogoutEvent();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
tryApplyFriendOrder(); // once again
|
||||
getAllUserStats(); // joinCount, lastSeen, timeSpent
|
||||
|
||||
// remove old data from json file and migrate to SQLite (July 2021)
|
||||
if (await hasLegacyFriendLogData(userId)) {
|
||||
removeLegacyFeedTable(userId);
|
||||
migrateMemos();
|
||||
migrateFriendLog(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runRefreshFriendsListFlow,
|
||||
runInitFriendsListFlow
|
||||
};
|
||||
}
|
||||
49
src/coordinators/gameCoordinator.js
Normal file
49
src/coordinators/gameCoordinator.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} Game flow coordinator methods.
|
||||
*/
|
||||
export function createGameCoordinator(deps) {
|
||||
const {
|
||||
userStore,
|
||||
instanceStore,
|
||||
updateLoopStore,
|
||||
locationStore,
|
||||
gameLogStore,
|
||||
vrStore,
|
||||
avatarStore,
|
||||
configRepository,
|
||||
workerTimers,
|
||||
checkVRChatDebugLogging,
|
||||
autoVRChatCacheManagement,
|
||||
checkIfGameCrashed,
|
||||
getIsGameNoVR
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Runs shared side effects when game running state changes.
|
||||
* @param {boolean} isGameRunning Whether VRChat is running.
|
||||
*/
|
||||
async function runGameRunningChangedFlow(isGameRunning) {
|
||||
if (isGameRunning) {
|
||||
userStore.markCurrentUserGameStarted();
|
||||
} else {
|
||||
await configRepository.setBool('isGameNoVR', getIsGameNoVR());
|
||||
userStore.markCurrentUserGameStopped();
|
||||
instanceStore.removeAllQueuedInstances();
|
||||
autoVRChatCacheManagement();
|
||||
checkIfGameCrashed();
|
||||
updateLoopStore.setIpcTimeout(0);
|
||||
avatarStore.addAvatarWearTime(userStore.currentUser.currentAvatar);
|
||||
}
|
||||
|
||||
locationStore.lastLocationReset();
|
||||
gameLogStore.clearNowPlaying();
|
||||
vrStore.updateVRLastLocation();
|
||||
workerTimers.setTimeout(() => checkVRChatDebugLogging(), 60000);
|
||||
updateLoopStore.setNextDiscordUpdate(0);
|
||||
}
|
||||
|
||||
return {
|
||||
runGameRunningChangedFlow
|
||||
};
|
||||
}
|
||||
334
src/coordinators/userEventCoordinator.js
Normal file
334
src/coordinators/userEventCoordinator.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} User event coordinator methods.
|
||||
*/
|
||||
export function createUserEventCoordinator(deps) {
|
||||
const {
|
||||
friendStore,
|
||||
state,
|
||||
parseLocation,
|
||||
userDialog,
|
||||
applyUserDialogLocation,
|
||||
worldStore,
|
||||
groupStore,
|
||||
instanceStore,
|
||||
appDebug,
|
||||
getWorldName,
|
||||
getGroupName,
|
||||
feedStore,
|
||||
database,
|
||||
avatarStore,
|
||||
generalSettingsStore,
|
||||
checkNote,
|
||||
now,
|
||||
nowIso
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Handles user diff events and applies cross-store side effects.
|
||||
* @param {object} ref Updated user reference.
|
||||
* @param {object} props Changed props with [new, old] tuples.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function runHandleUserUpdateFlow(ref, props) {
|
||||
let feed;
|
||||
let newLocation;
|
||||
let previousLocation;
|
||||
const friend = friendStore.friends.get(ref.id);
|
||||
if (typeof friend === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (props.location) {
|
||||
// update instancePlayerCount
|
||||
previousLocation = props.location[1];
|
||||
newLocation = props.location[0];
|
||||
let oldCount = state.instancePlayerCount.get(previousLocation);
|
||||
if (typeof oldCount !== 'undefined') {
|
||||
oldCount--;
|
||||
if (oldCount <= 0) {
|
||||
state.instancePlayerCount.delete(previousLocation);
|
||||
} else {
|
||||
state.instancePlayerCount.set(previousLocation, oldCount);
|
||||
}
|
||||
}
|
||||
let newCount = state.instancePlayerCount.get(newLocation);
|
||||
if (typeof newCount === 'undefined') {
|
||||
newCount = 0;
|
||||
}
|
||||
newCount++;
|
||||
state.instancePlayerCount.set(newLocation, newCount);
|
||||
|
||||
const previousLocationL = parseLocation(previousLocation);
|
||||
const newLocationL = parseLocation(newLocation);
|
||||
if (
|
||||
previousLocationL.tag === userDialog.value.$location.tag ||
|
||||
newLocationL.tag === userDialog.value.$location.tag
|
||||
) {
|
||||
// update user dialog instance occupants
|
||||
applyUserDialogLocation(true);
|
||||
}
|
||||
if (
|
||||
previousLocationL.worldId === worldStore.worldDialog.id ||
|
||||
newLocationL.worldId === worldStore.worldDialog.id
|
||||
) {
|
||||
instanceStore.applyWorldDialogInstances();
|
||||
}
|
||||
if (
|
||||
previousLocationL.groupId === groupStore.groupDialog.id ||
|
||||
newLocationL.groupId === groupStore.groupDialog.id
|
||||
) {
|
||||
instanceStore.applyGroupDialogInstances();
|
||||
}
|
||||
}
|
||||
if (
|
||||
!props.state &&
|
||||
props.location &&
|
||||
props.location[0] !== 'offline' &&
|
||||
props.location[0] !== '' &&
|
||||
props.location[1] !== 'offline' &&
|
||||
props.location[1] !== '' &&
|
||||
props.location[0] !== 'traveling'
|
||||
) {
|
||||
// skip GPS if user is offline or traveling
|
||||
previousLocation = props.location[1];
|
||||
newLocation = props.location[0];
|
||||
let time = props.location[2];
|
||||
if (previousLocation === 'traveling' && ref.$previousLocation) {
|
||||
previousLocation = ref.$previousLocation;
|
||||
const travelTime = now() - ref.$travelingToTime;
|
||||
time -= travelTime;
|
||||
if (time < 0) {
|
||||
time = 0;
|
||||
}
|
||||
}
|
||||
if (appDebug.debugFriendState && previousLocation) {
|
||||
console.log(
|
||||
`${ref.displayName} GPS ${previousLocation} -> ${newLocation}`
|
||||
);
|
||||
}
|
||||
if (previousLocation === 'offline') {
|
||||
previousLocation = '';
|
||||
}
|
||||
if (!previousLocation) {
|
||||
// no previous location
|
||||
if (appDebug.debugFriendState) {
|
||||
console.log(
|
||||
ref.displayName,
|
||||
'Ignoring GPS, no previous location',
|
||||
newLocation
|
||||
);
|
||||
}
|
||||
} else if (ref.$previousLocation === newLocation) {
|
||||
// location traveled to is the same
|
||||
ref.$location_at = now() - time;
|
||||
} else {
|
||||
const worldName = await getWorldName(newLocation);
|
||||
const groupName = await getGroupName(newLocation);
|
||||
feed = {
|
||||
created_at: nowIso(),
|
||||
type: 'GPS',
|
||||
userId: ref.id,
|
||||
displayName: ref.displayName,
|
||||
location: newLocation,
|
||||
worldName,
|
||||
groupName,
|
||||
previousLocation,
|
||||
time
|
||||
};
|
||||
feedStore.addFeed(feed);
|
||||
database.addGPSToDatabase(feed);
|
||||
// clear previousLocation after GPS
|
||||
ref.$previousLocation = '';
|
||||
ref.$travelingToTime = now();
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.location &&
|
||||
props.location[0] === 'traveling' &&
|
||||
props.location[1] !== 'traveling'
|
||||
) {
|
||||
// store previous location when user is traveling
|
||||
ref.$previousLocation = props.location[1];
|
||||
ref.$travelingToTime = now();
|
||||
}
|
||||
let imageMatches = false;
|
||||
if (
|
||||
props.currentAvatarThumbnailImageUrl &&
|
||||
props.currentAvatarThumbnailImageUrl[0] &&
|
||||
props.currentAvatarThumbnailImageUrl[1] &&
|
||||
props.currentAvatarThumbnailImageUrl[0] ===
|
||||
props.currentAvatarThumbnailImageUrl[1]
|
||||
) {
|
||||
imageMatches = true;
|
||||
}
|
||||
if (
|
||||
(((props.currentAvatarImageUrl ||
|
||||
props.currentAvatarThumbnailImageUrl) &&
|
||||
!ref.profilePicOverride) ||
|
||||
props.currentAvatarTags) &&
|
||||
!imageMatches
|
||||
) {
|
||||
let currentAvatarImageUrl = '';
|
||||
let previousCurrentAvatarImageUrl = '';
|
||||
let currentAvatarThumbnailImageUrl = '';
|
||||
let previousCurrentAvatarThumbnailImageUrl = '';
|
||||
let currentAvatarTags = '';
|
||||
let previousCurrentAvatarTags = '';
|
||||
if (props.currentAvatarImageUrl) {
|
||||
currentAvatarImageUrl = props.currentAvatarImageUrl[0];
|
||||
previousCurrentAvatarImageUrl = props.currentAvatarImageUrl[1];
|
||||
} else {
|
||||
currentAvatarImageUrl = ref.currentAvatarImageUrl;
|
||||
previousCurrentAvatarImageUrl = ref.currentAvatarImageUrl;
|
||||
}
|
||||
if (props.currentAvatarThumbnailImageUrl) {
|
||||
currentAvatarThumbnailImageUrl =
|
||||
props.currentAvatarThumbnailImageUrl[0];
|
||||
previousCurrentAvatarThumbnailImageUrl =
|
||||
props.currentAvatarThumbnailImageUrl[1];
|
||||
} else {
|
||||
currentAvatarThumbnailImageUrl =
|
||||
ref.currentAvatarThumbnailImageUrl;
|
||||
previousCurrentAvatarThumbnailImageUrl =
|
||||
ref.currentAvatarThumbnailImageUrl;
|
||||
}
|
||||
if (props.currentAvatarTags) {
|
||||
currentAvatarTags = props.currentAvatarTags[0];
|
||||
previousCurrentAvatarTags = props.currentAvatarTags[1];
|
||||
if (
|
||||
ref.profilePicOverride &&
|
||||
!props.currentAvatarThumbnailImageUrl
|
||||
) {
|
||||
// forget last seen avatar
|
||||
ref.currentAvatarImageUrl = '';
|
||||
ref.currentAvatarThumbnailImageUrl = '';
|
||||
}
|
||||
} else {
|
||||
currentAvatarTags = ref.currentAvatarTags;
|
||||
previousCurrentAvatarTags = ref.currentAvatarTags;
|
||||
}
|
||||
if (
|
||||
generalSettingsStore.logEmptyAvatars ||
|
||||
ref.currentAvatarImageUrl
|
||||
) {
|
||||
let avatarInfo = {
|
||||
ownerId: '',
|
||||
avatarName: ''
|
||||
};
|
||||
try {
|
||||
avatarInfo = await avatarStore.getAvatarName(
|
||||
currentAvatarImageUrl
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
let previousAvatarInfo = {
|
||||
ownerId: '',
|
||||
avatarName: ''
|
||||
};
|
||||
try {
|
||||
previousAvatarInfo = await avatarStore.getAvatarName(
|
||||
previousCurrentAvatarImageUrl
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
feed = {
|
||||
created_at: nowIso(),
|
||||
type: 'Avatar',
|
||||
userId: ref.id,
|
||||
displayName: ref.displayName,
|
||||
ownerId: avatarInfo.ownerId,
|
||||
previousOwnerId: previousAvatarInfo.ownerId,
|
||||
avatarName: avatarInfo.avatarName,
|
||||
previousAvatarName: previousAvatarInfo.avatarName,
|
||||
currentAvatarImageUrl,
|
||||
currentAvatarThumbnailImageUrl,
|
||||
previousCurrentAvatarImageUrl,
|
||||
previousCurrentAvatarThumbnailImageUrl,
|
||||
currentAvatarTags,
|
||||
previousCurrentAvatarTags
|
||||
};
|
||||
feedStore.addFeed(feed);
|
||||
database.addAvatarToDatabase(feed);
|
||||
}
|
||||
}
|
||||
// if status is offline, ignore status and statusDescription
|
||||
if (
|
||||
(props.status &&
|
||||
props.status[0] !== 'offline' &&
|
||||
props.status[1] !== 'offline') ||
|
||||
(!props.status && props.statusDescription)
|
||||
) {
|
||||
let status = '';
|
||||
let previousStatus = '';
|
||||
let statusDescription = '';
|
||||
let previousStatusDescription = '';
|
||||
if (props.status) {
|
||||
if (props.status[0]) {
|
||||
status = props.status[0];
|
||||
}
|
||||
if (props.status[1]) {
|
||||
previousStatus = props.status[1];
|
||||
}
|
||||
} else if (ref.status) {
|
||||
status = ref.status;
|
||||
previousStatus = ref.status;
|
||||
}
|
||||
if (props.statusDescription) {
|
||||
if (props.statusDescription[0]) {
|
||||
statusDescription = props.statusDescription[0];
|
||||
}
|
||||
if (props.statusDescription[1]) {
|
||||
previousStatusDescription = props.statusDescription[1];
|
||||
}
|
||||
} else if (ref.statusDescription) {
|
||||
statusDescription = ref.statusDescription;
|
||||
previousStatusDescription = ref.statusDescription;
|
||||
}
|
||||
feed = {
|
||||
created_at: nowIso(),
|
||||
type: 'Status',
|
||||
userId: ref.id,
|
||||
displayName: ref.displayName,
|
||||
status,
|
||||
statusDescription,
|
||||
previousStatus,
|
||||
previousStatusDescription
|
||||
};
|
||||
feedStore.addFeed(feed);
|
||||
database.addStatusToDatabase(feed);
|
||||
}
|
||||
if (props.bio && props.bio[0] && props.bio[1]) {
|
||||
let bio = '';
|
||||
let previousBio = '';
|
||||
if (props.bio[0]) {
|
||||
bio = props.bio[0];
|
||||
}
|
||||
if (props.bio[1]) {
|
||||
previousBio = props.bio[1];
|
||||
}
|
||||
feed = {
|
||||
created_at: nowIso(),
|
||||
type: 'Bio',
|
||||
userId: ref.id,
|
||||
displayName: ref.displayName,
|
||||
bio,
|
||||
previousBio
|
||||
};
|
||||
feedStore.addFeed(feed);
|
||||
database.addBioToDatabase(feed);
|
||||
}
|
||||
if (
|
||||
props.note &&
|
||||
props.note[0] !== null &&
|
||||
props.note[0] !== props.note[1]
|
||||
) {
|
||||
checkNote(ref.id, props.note[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runHandleUserUpdateFlow
|
||||
};
|
||||
}
|
||||
88
src/coordinators/userSessionCoordinator.js
Normal file
88
src/coordinators/userSessionCoordinator.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @param {object} deps Coordinator dependencies.
|
||||
* @returns {object} User session coordinator methods.
|
||||
*/
|
||||
export function createUserSessionCoordinator(deps) {
|
||||
const {
|
||||
avatarStore,
|
||||
gameStore,
|
||||
groupStore,
|
||||
instanceStore,
|
||||
friendStore,
|
||||
authStore,
|
||||
cachedUsers,
|
||||
currentUser,
|
||||
userDialog,
|
||||
getWorldName,
|
||||
parseLocation,
|
||||
now
|
||||
} = deps;
|
||||
|
||||
/**
|
||||
* Runs avatar transition side effects for current user updates.
|
||||
* @param {object} args Avatar transition context.
|
||||
* @param {object} args.json Current user payload.
|
||||
* @param {object} args.ref Current user state reference.
|
||||
* @param {boolean} args.isLoggedIn Whether current user is already logged in.
|
||||
*/
|
||||
function runAvatarSwapFlow({ json, ref, isLoggedIn }) {
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
if (json.currentAvatar !== ref.currentAvatar) {
|
||||
avatarStore.addAvatarToHistory(json.currentAvatar);
|
||||
if (gameStore.isGameRunning) {
|
||||
avatarStore.addAvatarWearTime(ref.currentAvatar);
|
||||
ref.$previousAvatarSwapTime = now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one-time side effects for first current-user hydration after login.
|
||||
* @param {object} ref Current user state reference.
|
||||
*/
|
||||
function runFirstLoginFlow(ref) {
|
||||
if (gameStore.isGameRunning) {
|
||||
ref.$previousAvatarSwapTime = now();
|
||||
}
|
||||
cachedUsers.clear(); // clear before running applyUser
|
||||
currentUser.value = ref;
|
||||
authStore.loginComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs cross-store synchronization after current-user data is applied.
|
||||
* @param {object} ref Current user state reference.
|
||||
*/
|
||||
function runPostApplySyncFlow(ref) {
|
||||
groupStore.applyPresenceGroups(ref);
|
||||
instanceStore.applyQueuedInstance(ref.queuedInstance);
|
||||
friendStore.updateUserCurrentStatus(ref);
|
||||
friendStore.updateFriendships(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs home location derived state and visible dialog display name.
|
||||
* @param {object} ref Current user state reference.
|
||||
*/
|
||||
function runHomeLocationSyncFlow(ref) {
|
||||
if (ref.homeLocation === ref.$homeLocation?.tag) {
|
||||
return;
|
||||
}
|
||||
ref.$homeLocation = parseLocation(ref.homeLocation);
|
||||
// apply home location name to user dialog
|
||||
if (userDialog.value.visible && userDialog.value.id === ref.id) {
|
||||
getWorldName(currentUser.value.homeLocation).then((worldName) => {
|
||||
userDialog.value.$homeLocationName = worldName;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runAvatarSwapFlow,
|
||||
runFirstLoginFlow,
|
||||
runPostApplySyncFlow,
|
||||
runHomeLocationSyncFlow
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user