test(app): remove useless frontend tests (#2819)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-17 15:05:36 +02:00
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();
});
});