mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
test(app): remove useless frontend tests (#2819)
This commit is contained in:
1 parent
ed9528834d
commit
deac653a9e
341 files changed
-62758
No files matched your search
@@ -1,88 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {decideComposerTyping} from '@app/features/typing/rolling/RollingComposerTypingGate';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
type ComposerTypingChange = Parameters<typeof decideComposerTyping>[0];
|
||||
|
||||
function decide(change: Partial<ComposerTypingChange>): ReturnType<typeof decideComposerTyping> {
|
||||
return decideComposerTyping({
|
||||
previousValue: 'hell',
|
||||
value: 'hello',
|
||||
isEditingMessageInComposer: false,
|
||||
enabled: true,
|
||||
...change,
|
||||
});
|
||||
}
|
||||
|
||||
describe('decideComposerTyping', () => {
|
||||
it('starts typing when the value changes to non-empty text', () => {
|
||||
expect(decide({})).toBe('start');
|
||||
expect(decide({previousValue: '', value: 'h'})).toBe('start');
|
||||
expect(decide({previousValue: 'hello', value: 'hell'})).toBe('start');
|
||||
});
|
||||
|
||||
it('stops typing for a whitespace-only draft', () => {
|
||||
expect(decide({previousValue: '', value: ' '})).toBe('stop');
|
||||
expect(decide({previousValue: 'hi', value: ' \n\t'})).toBe('stop');
|
||||
});
|
||||
|
||||
it('stops typing when the value becomes exactly empty', () => {
|
||||
expect(decide({previousValue: 'h', value: ''})).toBe('stop');
|
||||
});
|
||||
|
||||
it('does nothing when the value is unchanged', () => {
|
||||
expect(decide({previousValue: 'hello', value: 'hello'})).toBe('none');
|
||||
expect(decide({previousValue: '', value: ''})).toBe('none');
|
||||
});
|
||||
|
||||
it('does nothing on the first run after mount', () => {
|
||||
expect(decide({previousValue: null, value: 'saved draft'})).toBe('none');
|
||||
});
|
||||
|
||||
it('does nothing when the channel changed since the previous value', () => {
|
||||
expect(decide({previousValue: null, value: ''})).toBe('none');
|
||||
expect(decide({previousValue: null, value: 'draft in the next channel'})).toBe('none');
|
||||
});
|
||||
|
||||
it('does nothing while editing a message in the composer', () => {
|
||||
expect(decide({isEditingMessageInComposer: true})).toBe('none');
|
||||
expect(decide({isEditingMessageInComposer: true, value: ''})).toBe('none');
|
||||
});
|
||||
|
||||
it('does nothing while the composer is disabled', () => {
|
||||
expect(decide({enabled: false})).toBe('none');
|
||||
expect(decide({enabled: false, value: ''})).toBe('none');
|
||||
});
|
||||
|
||||
it('suppresses a leading slash', () => {
|
||||
expect(decide({previousValue: '', value: '/'})).toBe('none');
|
||||
expect(decide({previousValue: '/gif', value: '/gif cats'})).toBe('none');
|
||||
});
|
||||
|
||||
it('suppresses a bare plus', () => {
|
||||
expect(decide({previousValue: '', value: '+'})).toBe('none');
|
||||
expect(decide({previousValue: '+', value: '+1'})).toBe('start');
|
||||
});
|
||||
|
||||
it('suppresses the plus colon emoji shorthand', () => {
|
||||
expect(decide({previousValue: '+', value: '+:'})).toBe('none');
|
||||
expect(decide({previousValue: '+:thumbsup', value: '+:thumbsup:'})).toBe('none');
|
||||
expect(decide({previousValue: '+:thumbs', value: '+:thumbs-up'})).toBe('none');
|
||||
});
|
||||
|
||||
it('suppresses the reaction shorthand with leading whitespace', () => {
|
||||
expect(decide({previousValue: ' +:', value: ' +:smile'})).toBe('none');
|
||||
});
|
||||
|
||||
it('suppresses a replace command', () => {
|
||||
expect(decide({previousValue: 's/teh/th', value: 's/teh/the/'})).toBe('none');
|
||||
expect(decide({previousValue: ' s/teh/th', value: ' s/teh/the '})).toBe('none');
|
||||
});
|
||||
|
||||
it('starts typing while autocomplete is open', () => {
|
||||
expect(decide({previousValue: '@al', value: '@ali'})).toBe('start');
|
||||
expect(decide({previousValue: 'nice :smi', value: 'nice :smil'})).toBe('start');
|
||||
expect(decide({previousValue: '#gen', value: '#gene'})).toBe('start');
|
||||
});
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {runInAction} from 'mobx';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const doubles = await vi.hoisted(async () => {
|
||||
const {observable} = await import('mobx');
|
||||
return {
|
||||
auth: observable({currentUserId: 'me' as string | null}),
|
||||
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = doubles.logError;
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: doubles.post}}));
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth}));
|
||||
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
|
||||
|
||||
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
|
||||
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
|
||||
|
||||
const START_TIME = 1_000_000;
|
||||
const CHANNEL = 'channel';
|
||||
const OTHER_CHANNEL = 'other-channel';
|
||||
|
||||
let postTimes: Array<number> = [];
|
||||
|
||||
function elapsed(): number {
|
||||
return Date.now() - START_TIME;
|
||||
}
|
||||
|
||||
function typeContinuously(durationMs: number): void {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < durationMs) {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceTo(elapsedMs: number): void {
|
||||
vi.advanceTimersByTime(elapsedMs - elapsed());
|
||||
}
|
||||
|
||||
function startOtherTypists(count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
RollingTypingStore.start(CHANNEL, `typist-${index}`, 'gateway');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(START_TIME);
|
||||
postTimes = [];
|
||||
doubles.post.mockImplementation((_path: string) => {
|
||||
postTimes.push(elapsed());
|
||||
return Promise.resolve({ok: true});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
RollingTypingSender.reset();
|
||||
RollingTypingStore.reset();
|
||||
runInAction(() => {
|
||||
doubles.auth.currentUserId = 'me';
|
||||
});
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('RollingTypingSender', () => {
|
||||
it('posts once 1500 ms after the first keystroke and not before', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(CHANNEL));
|
||||
|
||||
vi.advanceTimersByTime(20000);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not push a pending send back on further keystrokes', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(500);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(1400);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
advanceTo(1500);
|
||||
|
||||
expect(postTimes).toEqual([1500]);
|
||||
});
|
||||
|
||||
it('writes the self entry at schedule time', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
|
||||
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(false);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing on a throttled keystroke', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
RollingTypingStore.remove(CHANNEL, 'me');
|
||||
const timersBefore = vi.getTimerCount();
|
||||
|
||||
advanceTo(700);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(timersBefore);
|
||||
});
|
||||
|
||||
it('sends every 8000 ms while typing continuously', () => {
|
||||
typeContinuously(20000);
|
||||
vi.advanceTimersByTime(20000);
|
||||
|
||||
expect(postTimes).toEqual([1500, 9500, 17500]);
|
||||
});
|
||||
|
||||
it('posts the second time about 9500 ms after the first keystroke', () => {
|
||||
typeContinuously(12000);
|
||||
|
||||
expect(postTimes[1]).toBe(9500);
|
||||
expect(postTimes[1]! - postTimes[0]!).toBe(8000);
|
||||
});
|
||||
|
||||
it('posts immediately when typing resumes 16000 ms after the last schedule', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(16000);
|
||||
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(postTimes).toEqual([1500, 16000]);
|
||||
});
|
||||
|
||||
it('skips the post when six entries including self are typing', () => {
|
||||
startOtherTypists(5);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(6);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts with five entries including self typing', () => {
|
||||
startOtherTypists(4);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(5);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('advances the throttle clock when the post is skipped', () => {
|
||||
startOtherTypists(5);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(1500);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
|
||||
advanceTo(5000);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
RollingTypingStore.remove(CHANNEL, `typist-${index}`);
|
||||
}
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(7999);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
|
||||
advanceTo(8000);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(9500);
|
||||
expect(postTimes).toEqual([9500]);
|
||||
});
|
||||
|
||||
it('does not retry a failed post', async () => {
|
||||
doubles.post.mockImplementationOnce(() => Promise.reject(new Error('Too many requests')));
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
expect(doubles.logError).toHaveBeenCalledExactlyOnceWith(
|
||||
`Failed to send typing indicator to channel ${CHANNEL}:`,
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the throttle after a failed post', async () => {
|
||||
doubles.post.mockImplementationOnce(() => Promise.reject(new Error('Network down')));
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
advanceTo(3000);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(7999);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(8000);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(9500);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drops the slot and cancels a pending send on a keystroke in another channel', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(500);
|
||||
|
||||
RollingTypingSender.startTyping(OTHER_CHANNEL);
|
||||
vi.advanceTimersByTime(20000);
|
||||
|
||||
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(OTHER_CHANNEL));
|
||||
expect(postTimes).toEqual([2000]);
|
||||
});
|
||||
|
||||
it('leaves the self entry in the previous channel when typing moves on', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(500);
|
||||
|
||||
RollingTypingSender.startTyping(OTHER_CHANNEL);
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
|
||||
expect(RollingTypingStore.isTyping(OTHER_CHANNEL, 'me')).toBe(true);
|
||||
advanceTo(10000);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
expect(RollingTypingStore.isTyping(OTHER_CHANNEL, 'me')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a pending send alive across a plain channel switch', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(400);
|
||||
|
||||
RollingTypingSender.stopTyping(OTHER_CHANNEL);
|
||||
advanceTo(1500);
|
||||
|
||||
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(CHANNEL));
|
||||
});
|
||||
|
||||
it('cancels only a pending send and removes the self entry when the field is emptied', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
advanceTo(500);
|
||||
|
||||
RollingTypingSender.stopTyping(CHANNEL);
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(postTimes).toEqual([7000]);
|
||||
});
|
||||
|
||||
it('leaves the self entry and the throttle alone when emptied after the post fired', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(1500);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
|
||||
RollingTypingSender.stopTyping(CHANNEL);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
|
||||
|
||||
advanceTo(3000);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(7999);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the slot and the self entry on own message sent', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(500);
|
||||
|
||||
RollingTypingSender.handleOwnMessageSent(CHANNEL);
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.advanceTimersByTime(20000);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules a fresh 1500 ms send for a quick follow-up after own message sent', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(2000);
|
||||
RollingTypingSender.handleOwnMessageSent(CHANNEL);
|
||||
|
||||
advanceTo(2100);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(3599);
|
||||
expect(postTimes).toEqual([1500]);
|
||||
|
||||
advanceTo(3600);
|
||||
expect(postTimes).toEqual([1500, 3600]);
|
||||
});
|
||||
|
||||
it('bails inside the timer when the signed in account changed', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(1000);
|
||||
|
||||
runInAction(() => {
|
||||
doubles.auth.currentUserId = 'someone-else';
|
||||
});
|
||||
advanceTo(5000);
|
||||
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the slot when the store is reset on reconnect', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(500);
|
||||
|
||||
RollingTypingStore.reset();
|
||||
advanceTo(1500);
|
||||
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
advanceTo(3000);
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
advanceTo(7999);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends no trailing post after a burst ends', () => {
|
||||
typeContinuously(3000);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
|
||||
expect(postTimes).toEqual([1500]);
|
||||
});
|
||||
|
||||
it('releases the pending timer on reset', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
const timersBefore = vi.getTimerCount();
|
||||
|
||||
RollingTypingSender.reset();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(timersBefore - 1);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing without a current user', () => {
|
||||
runInAction(() => {
|
||||
doubles.auth.currentUserId = null;
|
||||
});
|
||||
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {autorun, runInAction} from 'mobx';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const doubles = await vi.hoisted(async () => {
|
||||
const {observable} = await import('mobx');
|
||||
return {
|
||||
auth: observable({currentUserId: 'me' as string | null}),
|
||||
developerOptions: observable({showMyselfTyping: false}),
|
||||
blockedUserIds: observable.set<string>(),
|
||||
users: observable.map<string, {id: string}>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth}));
|
||||
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: doubles.developerOptions}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({
|
||||
default: {isBlocked: (userId: string) => doubles.blockedUserIds.has(userId)},
|
||||
}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({
|
||||
default: {getUser: (userId: string) => doubles.users.get(userId)},
|
||||
}));
|
||||
|
||||
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
|
||||
|
||||
const CHANNEL = 'channel';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
runInAction(() => {
|
||||
doubles.auth.currentUserId = 'me';
|
||||
doubles.developerOptions.showMyselfTyping = false;
|
||||
doubles.blockedUserIds.clear();
|
||||
doubles.users.clear();
|
||||
for (const id of ['me', 'alice', 'bob', 'carol']) {
|
||||
doubles.users.set(id, {id});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
RollingTypingStore.reset();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('RollingTypingStore', () => {
|
||||
it('arms a 10000 ms expiry for a new typist', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(9999);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('restarts the window on a repeated typing start', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
vi.advanceTimersByTime(6000);
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(9999);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps insertion order when a typist refreshes', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
|
||||
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice', 'bob', 'carol']);
|
||||
});
|
||||
|
||||
it('does not notify observers when an existing entry is refreshed', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
const runs = vi.fn();
|
||||
const dispose = autorun(() => {
|
||||
runs(
|
||||
RollingTypingStore.getTypingUserIds(CHANNEL),
|
||||
RollingTypingStore.countTypists(CHANNEL),
|
||||
RollingTypingStore.isTyping(CHANNEL, 'alice'),
|
||||
RollingTypingStore.isConfirmedTyping(CHANNEL, 'alice'),
|
||||
);
|
||||
});
|
||||
expect(runs).toHaveBeenCalledTimes(1);
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'local');
|
||||
vi.advanceTimersByTime(9000);
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
|
||||
expect(runs).toHaveBeenCalledTimes(1);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('marks an entry confirmed only from a gateway typing start', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'local');
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
|
||||
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(false);
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'gateway');
|
||||
|
||||
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(true);
|
||||
});
|
||||
|
||||
it('never lowers a confirmed entry on a local write', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'local');
|
||||
|
||||
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the author on message create for any author', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'local');
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
|
||||
RollingTypingStore.remove(CHANNEL, 'alice');
|
||||
RollingTypingStore.remove(CHANNEL, 'me');
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('wipes every entry and clears every timer on reset', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'local');
|
||||
RollingTypingStore.start('other-channel', 'bob', 'gateway');
|
||||
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice']);
|
||||
expect(vi.getTimerCount()).toBe(3);
|
||||
|
||||
RollingTypingStore.reset();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0);
|
||||
expect(RollingTypingStore.countTypists('other-channel')).toBe(0);
|
||||
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not let a timer from before a reset expire a fresh entry', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
vi.advanceTimersByTime(6000);
|
||||
RollingTypingStore.reset();
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(5999);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores an expiry callback whose handle has been replaced', () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
const staleExpiry = setTimeoutSpy.mock.calls[0]![0] as () => void;
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
staleExpiry();
|
||||
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('counts raw entries including self and blocked users', () => {
|
||||
runInAction(() => {
|
||||
doubles.blockedUserIds.add('bob');
|
||||
doubles.users.delete('carol');
|
||||
});
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'local');
|
||||
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
|
||||
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(4);
|
||||
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice']);
|
||||
});
|
||||
|
||||
it('keeps no cap on tracked typists', () => {
|
||||
runInAction(() => {
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
doubles.users.set(`typist-${index}`, {id: `typist-${index}`});
|
||||
}
|
||||
});
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
RollingTypingStore.start(CHANNEL, `typist-${index}`, 'gateway');
|
||||
}
|
||||
|
||||
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(50);
|
||||
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('drops the channel map when its last typist leaves', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
|
||||
|
||||
RollingTypingStore.remove(CHANNEL, 'alice');
|
||||
expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(true);
|
||||
|
||||
RollingTypingStore.remove(CHANNEL, 'bob');
|
||||
expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(false);
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(false);
|
||||
});
|
||||
|
||||
it('notifies a member row reader only for its own user', () => {
|
||||
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
|
||||
const aliceRow = vi.fn();
|
||||
const bobRow = vi.fn();
|
||||
const disposeAliceRow = autorun(() => aliceRow(RollingTypingStore.isTyping(CHANNEL, 'alice')));
|
||||
const disposeBobRow = autorun(() => bobRow(RollingTypingStore.isTyping(CHANNEL, 'bob')));
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'local');
|
||||
RollingTypingStore.start(CHANNEL, 'me', 'gateway');
|
||||
RollingTypingStore.remove(CHANNEL, 'carol');
|
||||
expect(aliceRow).toHaveBeenCalledTimes(1);
|
||||
expect(bobRow).toHaveBeenCalledTimes(1);
|
||||
|
||||
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
|
||||
expect(bobRow).toHaveBeenCalledTimes(2);
|
||||
expect(bobRow).toHaveBeenLastCalledWith(true);
|
||||
expect(aliceRow).toHaveBeenCalledTimes(1);
|
||||
|
||||
RollingTypingStore.remove(CHANNEL, 'bob');
|
||||
expect(bobRow).toHaveBeenCalledTimes(3);
|
||||
expect(bobRow).toHaveBeenLastCalledWith(false);
|
||||
expect(aliceRow).toHaveBeenCalledTimes(1);
|
||||
|
||||
disposeAliceRow();
|
||||
disposeBobRow();
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {createElement, Fragment, type ReactNode} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const doubles = vi.hoisted(() => ({
|
||||
getNickname: vi.fn((user: {id: string}, guildId: string | null) => `${user.id} in ${guildId ?? 'no guild'}`),
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor}));
|
||||
vi.mock('@lingui/react/macro', async () => {
|
||||
const {createElement: create, Fragment: ReactFragment} = await import('react');
|
||||
return {Trans: ({children}: {children?: ReactNode}) => create(ReactFragment, null, children)};
|
||||
});
|
||||
vi.mock('@app/features/user/utils/NicknameUtils', () => ({getNickname: doubles.getNickname}));
|
||||
vi.mock('@app/features/member/state/GuildMembers', () => ({
|
||||
default: {getMember: () => ({getColorString: () => 'rgb(1, 2, 3)'})},
|
||||
}));
|
||||
|
||||
const {getRollingTypingAnnouncement, getRollingTypingText} = await import(
|
||||
'@app/features/typing/rolling/RollingTypingText'
|
||||
);
|
||||
|
||||
const APP_SRC = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const GUILD_CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel;
|
||||
const DM_CHANNEL = {id: 'dm'} as unknown as Channel;
|
||||
|
||||
function i18nFor(locale: string): I18n {
|
||||
return {
|
||||
locale,
|
||||
_: (descriptor: {message: string}, values: Record<string, string> = {}) =>
|
||||
descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''),
|
||||
} as unknown as I18n;
|
||||
}
|
||||
|
||||
const EN_US = i18nFor('en-US');
|
||||
const DE = i18nFor('de');
|
||||
|
||||
function typists(count: number): Array<User> {
|
||||
return Array.from({length: count}, (_, index) => ({id: `typist-${index + 1}`}) as unknown as User);
|
||||
}
|
||||
|
||||
function markupOf(node: ReactNode): string {
|
||||
return renderToStaticMarkup(createElement(Fragment, null, node));
|
||||
}
|
||||
|
||||
function textOf(node: ReactNode): string {
|
||||
return markupOf(node).replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
function transMessages(source: string): Array<string> {
|
||||
return [...source.matchAll(/<Trans>([\s\S]*?)<\/Trans>/g)].map((match) => match[1]!.replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
function catalogueIds(locale: string): Set<string> {
|
||||
const catalogue = readFileSync(join(APP_SRC, `features/i18n/locales/${locale}/messages.po`), 'utf8');
|
||||
return new Set([...catalogue.matchAll(/^msgid "(.*)"$/gm)].map((match) => match[1]!));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
doubles.getNickname.mockClear();
|
||||
});
|
||||
|
||||
describe('getRollingTypingText', () => {
|
||||
it('names a single typist', () => {
|
||||
const node = getRollingTypingText(EN_US, typists(1), GUILD_CHANNEL, false);
|
||||
|
||||
expect(textOf(node)).toBe('typist-1 in guild is typing...');
|
||||
expect(markupOf(node)).toContain('data-flx="channel.typing-users.get-typing-text.username"');
|
||||
expect(markupOf(node)).toContain('color:rgb(1, 2, 3)');
|
||||
});
|
||||
|
||||
it('names two typists', () => {
|
||||
expect(textOf(getRollingTypingText(EN_US, typists(2), GUILD_CHANNEL, false))).toBe(
|
||||
'typist-1 in guild and typist-2 in guild are typing...',
|
||||
);
|
||||
});
|
||||
|
||||
it('names three typists without an Oxford comma', () => {
|
||||
expect(textOf(getRollingTypingText(EN_US, typists(3), GUILD_CHANNEL, false))).toBe(
|
||||
'typist-1 in guild, typist-2 in guild and typist-3 in guild are typing...',
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses four typists to several people', () => {
|
||||
expect(getRollingTypingText(EN_US, typists(4), GUILD_CHANNEL, false)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingText(DE, typists(4), GUILD_CHANNEL, false)).toBe('Several people are typing...');
|
||||
expect(doubles.getNickname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collapses twenty typists to several people', () => {
|
||||
expect(getRollingTypingText(DE, typists(20), GUILD_CHANNEL, false)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingText(EN_US, typists(20), GUILD_CHANNEL, false)).toBe("Whoa, it's a typing apocalypse");
|
||||
});
|
||||
|
||||
it('shows multiple people for one to three typists when overflowing', () => {
|
||||
for (const count of [1, 2, 3]) {
|
||||
expect(getRollingTypingText(EN_US, typists(count), GUILD_CHANNEL, true)).toBe('Multiple people are typing...');
|
||||
}
|
||||
});
|
||||
|
||||
it('never shows the overflow string for four or more typists', () => {
|
||||
expect(getRollingTypingText(EN_US, typists(4), GUILD_CHANNEL, true)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingText(DE, typists(12), GUILD_CHANNEL, true)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingText(EN_US, typists(12), GUILD_CHANNEL, true)).toBe(
|
||||
'A symphony of clacking keys is underway...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRollingTypingAnnouncement', () => {
|
||||
it('announces the full count-based string for three typists', () => {
|
||||
expect(getRollingTypingAnnouncement(EN_US, typists(1), GUILD_CHANNEL)).toBe('typist-1 in guild is typing...');
|
||||
expect(getRollingTypingAnnouncement(EN_US, typists(2), GUILD_CHANNEL)).toBe(
|
||||
'typist-1 in guild and typist-2 in guild are typing...',
|
||||
);
|
||||
expect(getRollingTypingAnnouncement(EN_US, typists(3), GUILD_CHANNEL)).toBe(
|
||||
'typist-1 in guild, typist-2 in guild and typist-3 in guild are typing...',
|
||||
);
|
||||
});
|
||||
|
||||
it('announces several people for four or more typists', () => {
|
||||
expect(getRollingTypingAnnouncement(EN_US, typists(4), GUILD_CHANNEL)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingAnnouncement(DE, typists(4), GUILD_CHANNEL)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingAnnouncement(DE, typists(16), GUILD_CHANNEL)).toBe('Several people are typing...');
|
||||
expect(getRollingTypingAnnouncement(EN_US, typists(16), GUILD_CHANNEL)).toBe(
|
||||
"It's a full-blown typing fiesta in here",
|
||||
);
|
||||
});
|
||||
|
||||
it('announces nothing when nobody is typing', () => {
|
||||
expect(getRollingTypingAnnouncement(EN_US, [], GUILD_CHANNEL)).toBe('');
|
||||
expect(getRollingTypingText(EN_US, [], GUILD_CHANNEL, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolling typing catalogue', () => {
|
||||
it('keeps each named form in the source catalogue', () => {
|
||||
const rollingSource = readFileSync(join(APP_SRC, 'features/typing/rolling/RollingTypingText.tsx'), 'utf8');
|
||||
const namedForms = ['{a} is typing...', '{a} and {b} are typing...', '{a}, {b} and {c} are typing...'];
|
||||
const declaredMessages = [...rollingSource.matchAll(/message: '([^']*)'/g)].map((match) => match[1]);
|
||||
|
||||
expect(transMessages(rollingSource)).toEqual(namedForms);
|
||||
expect(declaredMessages).toEqual([...namedForms, 'Multiple people are typing...']);
|
||||
const sourceCatalogue = catalogueIds('en-US');
|
||||
for (const message of [...namedForms, 'Several people are typing...']) {
|
||||
expect(sourceCatalogue.has(message), message).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves guild nicknames for names', () => {
|
||||
const [typist] = typists(1);
|
||||
|
||||
expect(textOf(getRollingTypingText(EN_US, [typist!], GUILD_CHANNEL, false))).toBe('typist-1 in guild is typing...');
|
||||
expect(getRollingTypingAnnouncement(EN_US, [typist!], DM_CHANNEL)).toBe('typist-1 in no guild is typing...');
|
||||
expect(doubles.getNickname.mock.calls).toEqual([
|
||||
[typist, 'guild'],
|
||||
[typist, null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {planTypingSend, type TypingSendSlot} from '@app/features/typing/rolling/TypingSendThrottle';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const PREVIOUS_SEND = 100000;
|
||||
const PENDING_TIMEOUT = {} as NodeJS.Timeout;
|
||||
|
||||
function slot(overrides: Partial<TypingSendSlot> = {}): TypingSendSlot {
|
||||
return {channelId: 'channel', userId: 'me', timeout: null, prevSend: PREVIOUS_SEND, ...overrides};
|
||||
}
|
||||
|
||||
describe('planTypingSend', () => {
|
||||
it('schedules the first ever send after 1500 ms', () => {
|
||||
expect(planTypingSend(null, 'channel', 'me', PREVIOUS_SEND)).toEqual({
|
||||
dropSlot: false,
|
||||
action: 'schedule',
|
||||
delayMs: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it('throttles while a send is pending', () => {
|
||||
expect(planTypingSend(slot({timeout: PENDING_TIMEOUT, prevSend: 0}), 'channel', 'me', PREVIOUS_SEND)).toEqual({
|
||||
dropSlot: false,
|
||||
action: 'throttled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throttles when the previous schedule is less than 8000 ms old', () => {
|
||||
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 7999)).toEqual({
|
||||
dropSlot: false,
|
||||
action: 'throttled',
|
||||
});
|
||||
});
|
||||
|
||||
it('schedules exactly 8000 ms after the previous schedule', () => {
|
||||
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 8000)).toEqual({
|
||||
dropSlot: false,
|
||||
action: 'schedule',
|
||||
delayMs: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it('schedules after 1500 ms when the previous schedule is less than 16000 ms old', () => {
|
||||
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 15999)).toEqual({
|
||||
dropSlot: false,
|
||||
action: 'schedule',
|
||||
delayMs: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it('schedules after 0 ms when the previous schedule is exactly 16000 ms old', () => {
|
||||
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 16000)).toEqual({
|
||||
dropSlot: false,
|
||||
action: 'schedule',
|
||||
delayMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a slot for another channel and then schedules after 1500 ms', () => {
|
||||
expect(
|
||||
planTypingSend(slot({channelId: 'other', timeout: PENDING_TIMEOUT}), 'channel', 'me', PREVIOUS_SEND + 1),
|
||||
).toEqual({dropSlot: true, action: 'schedule', delayMs: 1500});
|
||||
});
|
||||
|
||||
it('drops a slot for another account before checking the throttle', () => {
|
||||
expect(
|
||||
planTypingSend(slot({userId: 'previous', timeout: PENDING_TIMEOUT}), 'channel', 'me', PREVIOUS_SEND + 1),
|
||||
).toEqual({dropSlot: true, action: 'schedule', delayMs: 1500});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const doubles = vi.hoisted(() => ({
|
||||
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {get: vi.fn(), post: doubles.post}}));
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
|
||||
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
|
||||
|
||||
const {default: TypingIndicator} = await import('@app/features/typing/state/TypingIndicator');
|
||||
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
|
||||
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
|
||||
|
||||
const CHANNEL = 'channel';
|
||||
|
||||
function messageFrom(authorId: string): Message {
|
||||
return {channel_id: CHANNEL, author: {id: authorId}} as unknown as Message;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
RollingTypingSender.reset();
|
||||
RollingTypingStore.reset();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TypingIndicator', () => {
|
||||
it('records gateway typing starts as confirmed', () => {
|
||||
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
|
||||
|
||||
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'alice')).toBe(true);
|
||||
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true);
|
||||
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'alice', 'me')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports self in the member list only after the server echo', () => {
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(true);
|
||||
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
|
||||
|
||||
TypingIndicator.startRemoteTyping(CHANNEL, 'me');
|
||||
|
||||
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the self row dark when the post was skipped', () => {
|
||||
for (const userId of ['a', 'b', 'c', 'd', 'e']) {
|
||||
TypingIndicator.startRemoteTyping(CHANNEL, userId);
|
||||
}
|
||||
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
|
||||
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'a', 'me')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the send slot when the gateway resets', () => {
|
||||
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
TypingIndicator.reset();
|
||||
|
||||
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
|
||||
expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears any author on message create', () => {
|
||||
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
|
||||
RollingTypingSender.startTyping(CHANNEL);
|
||||
|
||||
TypingIndicator.stopTypingOnMessageCreate(messageFrom('alice'));
|
||||
TypingIndicator.stopTypingOnMessageCreate(messageFrom('me'));
|
||||
|
||||
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
|
||||
expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor}));
|
||||
|
||||
const {getTypingTierText} = await import('@app/features/typing/utils/TypingTierText');
|
||||
|
||||
function i18nFor(locale: string): I18n {
|
||||
return {locale, _: (descriptor: {message: string}) => `[${locale}] ${descriptor.message}`} as unknown as I18n;
|
||||
}
|
||||
|
||||
describe('getTypingTierText', () => {
|
||||
it('uses the translatable several people line for exactly four typists in every locale', () => {
|
||||
for (const locale of ['en-US', 'en-GB', 'de', 'ja']) {
|
||||
expect(getTypingTierText(i18nFor(locale), 4)).toBe(`[${locale}] Several people are typing...`);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['en-US', 5, 'A handful of keyboard warriors are assembling...'],
|
||||
['en-US', 9, 'A handful of keyboard warriors are assembling...'],
|
||||
['en-GB', 10, 'A symphony of clacking keys is underway...'],
|
||||
['en-US', 14, 'A symphony of clacking keys is underway...'],
|
||||
['en-GB', 15, "It's a full-blown typing fiesta in here"],
|
||||
['en-US', 19, "It's a full-blown typing fiesta in here"],
|
||||
['en-GB', 20, "Whoa, it's a typing apocalypse"],
|
||||
['en-US', 250, "Whoa, it's a typing apocalypse"],
|
||||
])('keeps the untranslated joke line in %s for %i typists', (locale, count, line) => {
|
||||
expect(getTypingTierText(i18nFor(locale), count)).toBe(line);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'ar',
|
||||
'de',
|
||||
'es-419',
|
||||
'fr',
|
||||
'ja',
|
||||
'pt-BR',
|
||||
'sv-SE',
|
||||
'zh-TW',
|
||||
])('collapses every crowd to the several people line in %s', (locale) => {
|
||||
for (const count of [5, 9, 10, 14, 15, 19, 20, 250]) {
|
||||
expect(getTypingTierText(i18nFor(locale), count)).toBe(`[${locale}] Several people are typing...`);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a bare language code as a non-English locale', () => {
|
||||
expect(getTypingTierText(i18nFor('en'), 12)).toBe('[en] Several people are typing...');
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const doubles = vi.hoisted(() => ({
|
||||
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {get: vi.fn(), post: doubles.post}}));
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
|
||||
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
|
||||
|
||||
const {TypingUtils} = await import('@app/features/typing/utils/TypingUtils');
|
||||
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
|
||||
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
|
||||
|
||||
type ComposerTypingInput = Parameters<typeof TypingUtils.handleComposerChange>[0];
|
||||
|
||||
const CHANNEL = 'channel';
|
||||
|
||||
function composerChange(change: Partial<ComposerTypingInput>): ComposerTypingInput {
|
||||
return {
|
||||
channelId: CHANNEL,
|
||||
value: 'hello',
|
||||
previousValue: 'hell',
|
||||
enabled: true,
|
||||
typingEnabled: true,
|
||||
isEditingMessageInComposer: false,
|
||||
...change,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
RollingTypingSender.reset();
|
||||
RollingTypingStore.reset();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TypingUtils', () => {
|
||||
it('cancels a pending send for clear', () => {
|
||||
TypingUtils.handleComposerChange(composerChange({}));
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
TypingUtils.clear(CHANNEL);
|
||||
vi.advanceTimersByTime(20000);
|
||||
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the send slot on own message sent', () => {
|
||||
TypingUtils.handleComposerChange(composerChange({}));
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
|
||||
TypingUtils.handleOwnMessageSent(CHANNEL);
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
vi.advanceTimersByTime(100);
|
||||
TypingUtils.handleComposerChange(composerChange({previousValue: '', value: 'n'}));
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
expect(doubles.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts typing while an autocomplete trigger is typed', () => {
|
||||
TypingUtils.handleComposerChange(composerChange({previousValue: '@al', value: '@ali'}));
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
expect(doubles.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('posts nothing for a spaces-only draft', () => {
|
||||
TypingUtils.handleComposerChange(composerChange({previousValue: '', value: ' '}));
|
||||
TypingUtils.handleComposerChange(composerChange({previousValue: ' ', value: ' '}));
|
||||
vi.advanceTimersByTime(20000);
|
||||
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a composer with typing turned off as disabled', () => {
|
||||
TypingUtils.handleComposerChange(composerChange({typingEnabled: false}));
|
||||
vi.advanceTimersByTime(20000);
|
||||
|
||||
expect(doubles.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user