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,110 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {act, createElement, type ReactNode} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const ChannelStreamType = {
MESSAGE: 'MESSAGE',
MESSAGE_GROUP_BLOCKED: 'MESSAGE_GROUP_BLOCKED',
MESSAGE_GROUP_IGNORED: 'MESSAGE_GROUP_IGNORED',
MESSAGE_GROUP_SPAMMER: 'MESSAGE_GROUP_SPAMMER',
DIVIDER: 'DIVIDER',
} as const;
vi.mock('@app/features/messaging/utils/MessageGroupingUtils', () => ({ChannelStreamType}));
vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value}));
vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => 'blocked messages'}})}));
vi.mock('@app/features/channel/components/ChannelDivider', () => ({
Divider: ({children}: {children?: ReactNode}) => createElement('div', {'data-divider': true}, children),
}));
vi.mock('@app/features/channel/components/MessageGroup', () => ({
MessageGroup: () => createElement('div', {'data-message-group': true}),
}));
const {BlockedMessageGroups} = await import('@app/features/channel/components/BlockedMessageGroups');
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const CHANNEL = {id: 'channel-1', guild_id: null} as never;
const SPACER_SELECTOR = '[data-flx="channel.blocked-message-groups.group-spacer"]';
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
function message(id: string): Record<string, unknown> {
return {id, author: {id: 'author-1'}, blocked: true};
}
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
consoleError.mockRestore();
});
function renderRevealedGroup(messageGroups: Array<unknown>): void {
act(() => {
root.render(
createElement(BlockedMessageGroups, {
channel: CHANNEL,
messageGroups: messageGroups as never,
onReveal: () => undefined,
revealed: true,
compact: false,
messageGroupSpacing: 8,
variant: 'blocked',
}),
);
});
}
function duplicateKeyWarnings(): Array<unknown> {
return consoleError.mock.calls.filter((call: Array<unknown>) => String(call[0]).includes('same key'));
}
const DIVIDER_INSIDE_GROUP = [
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
{type: ChannelStreamType.DIVIDER, content: '', unreadId: '200', contentKey: 'divider-200'},
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
];
describe('BlockedMessageGroups', () => {
it('keys an unread divider apart from the message group below it', () => {
renderRevealedGroup(DIVIDER_INSIDE_GROUP);
expect(duplicateKeyWarnings()).toEqual([]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(2);
});
it('skips a divider that leads the revealed group', () => {
renderRevealedGroup([
{type: ChannelStreamType.DIVIDER, content: '', unreadId: '100', contentKey: 'divider-100'},
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
]);
expect(duplicateKeyWarnings()).toEqual([]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(1);
expect(container.querySelectorAll('[data-divider]')).toHaveLength(0);
});
it('spaces consecutive groups apart inside the revealed block', () => {
renderRevealedGroup([
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
{type: ChannelStreamType.MESSAGE, content: message('300'), contentKey: '300', groupId: 'g3'},
]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(3);
expect(container.querySelectorAll(SPACER_SELECTOR)).toHaveLength(2);
});
});
@@ -1,89 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type MessageFocusCandidate,
resolveBottommostFocusableMessageId,
} from '@app/features/channel/components/ChannelMessageFocusTarget';
import {describe, expect, it} from 'vitest';
function candidate(messageId: string, top: number, height: number): MessageFocusCandidate {
return {messageId, top, bottom: top + height, height};
}
const VIEWPORT_TOP = 0;
const VIEWPORT_BOTTOM = 600;
describe('resolveBottommostFocusableMessageId', () => {
it('picks the bottom-most mostly visible message', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 300, 100)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('c');
});
it('skips a message that is mostly scrolled past the bottom edge', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 550, 100)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('falls back to the most visible message when nothing clears the visibility threshold', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', -900, 1000), candidate('b', 200, 1000)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('prefers the most visible message over the last one in the DOM below the viewport', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('tall', -100, 1000), candidate('offscreen', 2000, 50)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('tall');
});
it('breaks a tie on visible height by taking the bottom-most message', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', -800, 900), candidate('b', 500, 900)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('falls back to the last message when nothing overlaps the viewport at all', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', 900, 100), candidate('b', 1200, 100)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('falls back to the last message when the newest one is hidden behind the composer', () => {
const result = resolveBottommostFocusableMessageId([candidate('only', 400, 400)], VIEWPORT_TOP, VIEWPORT_BOTTOM);
expect(result).toBe('only');
});
it('ignores zero-height rows when scoring visibility', () => {
expect(
resolveBottommostFocusableMessageId(
[candidate('a', 10, 100), candidate('b', 200, 0)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
),
).toBe('a');
});
it('returns null when there are no candidates', () => {
expect(resolveBottommostFocusableMessageId([], VIEWPORT_TOP, VIEWPORT_BOTTOM)).toBeNull();
});
});
@@ -1,25 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getUnreadDividerBeforeMessageId} from '@app/features/channel/components/ChannelMessageStreamUtils';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import type {ChannelStreamItem} from '@app/features/messaging/utils/MessageGroupingUtils';
import {describe, expect, it} from 'vitest';
function item(messageId: string, showUnreadDividerBefore = false): ChannelStreamItem {
return {
type: 'MESSAGE',
content: {id: messageId} as Message,
groupId: 'group-1',
showUnreadDividerBefore,
};
}
describe('ChannelMessageStreamUtils', () => {
it('returns the first pending message with a visible unread divider', () => {
expect(getUnreadDividerBeforeMessageId([item('a'), item('b', true), item('c', true)])).toBe('b');
});
it('returns null when unread indicators are suppressed', () => {
expect(getUnreadDividerBeforeMessageId([item('a', true)], true)).toBeNull();
});
});
@@ -1,68 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {splitMediaAndFileAttachments} from '@app/features/channel/components/MessageAttachmentUtils';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {describe, expect, it} from 'vitest';
const baseAttachment = {
id: 'attachment-1',
filename: 'file.bin',
size: 1,
url: 'https://example.com/file.bin',
proxy_url: 'https://example.com/file.bin',
flags: 0,
} satisfies MessageAttachment;
function attachment(overrides: Partial<MessageAttachment>): MessageAttachment {
return {
...baseAttachment,
...overrides,
};
}
describe('Message attachment utils', () => {
it('does not put audio attachments in the mosaic media bucket', () => {
const audio = attachment({
id: 'audio-1',
filename: 'same-channel-message.mp3',
content_type: 'audio/mpeg',
width: 0,
height: 0,
duration: 1,
});
const {mediaAttachments, fileAttachments} = splitMediaAndFileAttachments([audio]);
expect(mediaAttachments).toEqual([]);
expect(fileAttachments).toEqual([audio]);
});
it('puts positive-dimension visual attachments in the mosaic media bucket', () => {
const image = attachment({
id: 'image-1',
filename: 'image.png',
content_type: 'image/png',
width: 640,
height: 480,
});
const video = attachment({
id: 'video-1',
filename: 'video.mp4',
content_type: 'video/mp4',
width: 1280,
height: 720,
});
const {mediaAttachments, fileAttachments} = splitMediaAndFileAttachments([image, video]);
expect(mediaAttachments).toEqual([image, video]);
expect(fileAttachments).toEqual([]);
});
it('does not put zero-dimension visual attachments in the mosaic media bucket', () => {
const image = attachment({
id: 'image-1',
filename: 'image.png',
content_type: 'image/png',
width: 0,
height: 0,
});
const {mediaAttachments, fileAttachments} = splitMediaAndFileAttachments([image]);
expect(mediaAttachments).toEqual([]);
expect(fileAttachments).toEqual([image]);
});
});
@@ -1,236 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MessageViewContextProvider} from '@app/features/channel/components/MessageViewContext';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type React from 'react';
import {act, cloneElement, useState} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const matureMedia = vi.hoisted(() => ({blurred: false, canReveal: true, blocked: false}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: ({children}: {children?: React.ReactNode}) => <span data-flx="test.trans">{children}</span>,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
vi.mock('@app/features/messaging/hooks/useMatureMedia', () => ({
useMatureMedia: () => {
const [isRevealed, setIsRevealed] = useState(false);
return {
shouldBlur: matureMedia.blurred && !isRevealed,
shouldBlock: matureMedia.blocked,
canReveal: matureMedia.canReveal,
gateReason: 'none',
reveal: () => {
if (matureMedia.canReveal) setIsRevealed(true);
},
};
},
}));
vi.mock('@app/features/ui/popover/PopoverPopout', () => ({
openPopout: vi.fn(),
Popout: ({
children,
render,
tooltip,
shouldOpenOnClick,
}: {
children: React.ReactElement<{onClick?: (event: React.MouseEvent<HTMLElement>) => void}>;
render?: (props: {popoutKey: number; onClose: () => void}) => React.ReactNode;
tooltip?: string | (() => React.ReactNode);
shouldOpenOnClick?: (event: React.MouseEvent<HTMLElement>) => boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<span data-test-popout="true" data-flx="test.popout">
{cloneElement(children, {
onClick: (event: React.MouseEvent<HTMLElement>) => {
if (shouldOpenOnClick?.(event) ?? true) setIsOpen(true);
},
})}
<span data-test-popout-tooltip="true" data-flx="test.popout-tooltip">
{typeof tooltip === 'function' ? tooltip() : tooltip}
</span>
{isOpen ? (
<span data-test-popout-card="true" data-flx="test.popout-card">
{render?.({popoutKey: 1, onClose: () => setIsOpen(false)})}
</span>
) : null}
</span>
);
},
}));
vi.mock('@app/features/expressions/components/ExpressionInfoCard', () => ({
ExpressionInfoCard: ({displayName}: {displayName: string}) => (
<span data-test-info-card={displayName} data-flx="test.info-card" />
),
}));
vi.mock('@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet', () => ({
ExpressionInfoBottomSheet: ({isOpen}: {isOpen: boolean}) => (
<span data-test-bottom-sheet={String(isOpen)} data-flx="test.bottom-sheet" />
),
}));
vi.mock('@app/features/presence/state/LocalPresence', () => ({
default: {updatePresence: vi.fn()},
setLocalPresenceUserSettings: vi.fn(),
ACCOUNT_PRESENCE_INTENT_MAX_AGE_MS: 60_000,
}));
vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({openFromEvent: vi.fn()}));
vi.mock('@app/features/ui/action_menu/MessageContextMenu', () => ({MessageContextMenu: () => null}));
vi.mock('@app/features/ui/action_menu/items/StickerContextMenuItems', () => ({StickerInlineMenuItems: () => null}));
vi.mock('@app/features/channel/components/MessageReactions', () => ({MessageReactions: () => null}));
vi.mock('@app/features/channel/components/embeds/ChannelEmbed', () => ({Embed: () => null}));
vi.mock('@app/features/channel/components/embeds/attachments/Attachment', () => ({Attachment: () => null}));
vi.mock('@app/features/channel/components/embeds/attachments/AttachmentMosaic', () => ({AttachmentMosaic: () => null}));
vi.mock('@app/features/channel/components/InviteEmbed', () => ({InviteEmbed: () => null}));
vi.mock('@app/features/channel/components/GiftEmbed', () => ({GiftEmbed: () => null}));
vi.mock('@app/features/channel/components/ThemeEmbed', () => ({ThemeEmbed: () => null}));
vi.mock('@app/features/messaging/components/markdown', () => ({SafeMarkdown: () => null}));
vi.mock('@app/features/channel/components/TimestampWithTooltip', () => ({TimestampWithTooltip: () => null}));
vi.mock('@app/features/ui/components/Avatar', () => ({Avatar: () => null}));
vi.mock('@app/features/app/components/shared/GroupDMAvatar', () => ({GroupDMAvatar: () => null}));
vi.mock('@app/features/app/components/shared/SpoilerOverlay', () => ({SpoilerOverlay: () => null}));
vi.mock('@app/features/messaging/utils/MessageNavigator', () => ({goToMessage: vi.fn()}));
vi.mock('@app/features/messaging/utils/MessageCopyTextUtils', () => ({buildMessageSnapshotCopyText: () => ''}));
installVoiceMenuTestBootstrap();
const {MessageAttachments} = await import('@app/features/channel/components/MessageAttachments');
const STICKER_NAME = 'blobsticker';
const message = {
id: '1',
channelId: '2',
content: '',
attachments: [],
embeds: [],
invites: [],
themes: [],
gifts: [],
stickers: [{id: '20', name: STICKER_NAME, format_type: 1}],
messageSnapshots: null,
suppressEmbeds: false,
};
let host: HTMLDivElement;
let root: Root;
function renderMessage(): void {
act(() => {
root.render(
<MessageViewContextProvider
value={
{
channel: {id: '2'},
message,
shouldGroup: false,
isHovering: false,
messageDisplayCompact: false,
handleDelete: () => {},
} as never
}
data-flx="test.message-view-context-provider"
>
<MessageAttachments data-flx="test.message-attachments" />
</MessageViewContextProvider>,
);
});
}
function stickerButton(): HTMLButtonElement {
const button = host.querySelector<HTMLButtonElement>('button[data-message-sticker="true"]');
if (button == null) throw new Error('sticker trigger is missing');
return button;
}
function clickSticker(): void {
const button = stickerButton();
act(() => {
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
}
function isBlurred(): boolean {
return host.querySelector('img')?.className.includes('matureStickerBlurred') === true;
}
function isCardOpen(): boolean {
return host.querySelector('[data-test-popout-card]') != null;
}
function tooltipText(): string {
return host.querySelector('[data-test-popout-tooltip]')?.textContent ?? '';
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
matureMedia.blurred = false;
matureMedia.canReveal = true;
matureMedia.blocked = false;
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
describe('message stickers', () => {
it('renders the sticker as an info card trigger', () => {
renderMessage();
expect(host.querySelector('[data-test-popout]')).not.toBeNull();
expect(stickerButton().getAttribute('aria-label')).toBe(STICKER_NAME);
expect(isCardOpen()).toBe(false);
});
it('opens the info card on the first activation when nothing is blurred', () => {
renderMessage();
clickSticker();
expect(isCardOpen()).toBe(true);
expect(host.querySelector(`[data-test-info-card="${STICKER_NAME}"]`)).not.toBeNull();
});
it('reveals instead of opening the card while the sticker is blurred', () => {
matureMedia.blurred = true;
renderMessage();
expect(isBlurred()).toBe(true);
clickSticker();
expect(isBlurred()).toBe(false);
expect(isCardOpen()).toBe(false);
});
it('opens the info card once the blurred sticker has been revealed', () => {
matureMedia.blurred = true;
renderMessage();
clickSticker();
clickSticker();
expect(isCardOpen()).toBe(true);
});
it('neither reveals nor opens the card when the blur cannot be revealed', () => {
matureMedia.blurred = true;
matureMedia.canReveal = false;
renderMessage();
clickSticker();
expect(isBlurred()).toBe(true);
expect(isCardOpen()).toBe(false);
});
it('hides the card hint from the hover tooltip while the sticker is blurred', () => {
matureMedia.blurred = true;
renderMessage();
expect(tooltipText()).toBe(STICKER_NAME);
clickSticker();
expect(tooltipText()).toContain(STICKER_NAME);
expect(tooltipText()).toContain('Click to learn more');
});
});
@@ -1,97 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
}
const messageCss = readSource('../../theme/styles/Message.module.css');
const actionBarCss = readSource('./MessageActionBar.module.css');
const focusRingCss = readSource('../../ui/focus_ring/FocusRing.module.css');
const channelMessageSource = readSource('./ChannelMessage.tsx');
const channelMessagesSource = readSource('./ChannelMessages.tsx');
const messageFocusRing = channelMessageSource.match(/<FocusRing\b[^>]*>/)?.[0] ?? '';
interface CssRule {
readonly selector: string;
readonly body: string;
}
function parseCssRules(css: string): Array<CssRule> {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules: Array<CssRule> = [];
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
rules.push({selector: match[1].replace(/\s+/g, ' ').trim(), body: match[2].trim()});
}
return rules;
}
const messageRules = parseCssRules(messageCss);
describe('message focus ring contract', () => {
it('never draws a ring from a bare :focus selector on a message row', () => {
expect(messageCss).not.toMatch(/\.message(Compact)?:focus(?!-visible)/);
});
it('routes the row ring through the FocusRing framework', () => {
expect(channelMessageSource).toMatch(/from '@app\/features\/ui\/focus_ring\/FocusRing'/);
expect(messageFocusRing).not.toBe('');
});
it('only enables the ring in keyboard navigation mode', () => {
expect(messageFocusRing).toMatch(/enabled=\{keyboardModeEnabled\}/);
});
it('leaves focused descendants their own rings instead of drawing the row ring over them', () => {
expect(messageFocusRing).not.toMatch(/\bwithin\b/);
});
it('scopes message list rings inside the scroll content', () => {
const scopedList = channelMessagesSource.match(
/<FocusRingScope containerRef=\{scrollerInnerRef\}[^>]*>\s*<NearViewportSurfaceContext\.Provider[\s\S]*?\{scrollerInner\}[\s\S]*?<\/FocusRingScope>/,
);
expect(scopedList).not.toBeNull();
});
it('insets the ring inside the row', () => {
expect(focusRingCss).toMatch(/pointer-events:\s*none/);
expect(messageFocusRing).toMatch(/offset=\{-2\}/);
});
it('stacks the ring below the action bar', () => {
expect(actionBarCss).toMatch(/z-index:\s*var\(--z-index-elevated-1\)/);
expect(messageFocusRing).not.toMatch(/zIndex=/);
});
it('measures the ring from an anchor inset by the row bleed', () => {
expect(messageFocusRing).toMatch(/ringTarget=\{focusRingAnchorRef\}/);
expect(channelMessageSource).toMatch(/className=\{styles\.focusRingAnchor\}/);
const anchor = messageRules.find((rule) => rule.selector === '.focusRingAnchor');
expect(anchor).toBeDefined();
expect(anchor?.body).toMatch(/position:\s*absolute/);
expect(anchor?.body).toMatch(/inset-inline:\s*var\(--message-inline-bleed/);
expect(anchor?.body).toMatch(/pointer-events:\s*none/);
});
it('derives every message row bleed from the shared bleed variable', () => {
const bleedingRows = messageRules.filter(
(rule) => /^\.message(Compact|Preview)?$/.test(rule.selector) && /margin-inline\s*:/.test(rule.body),
);
expect(bleedingRows.length).toBeGreaterThan(0);
for (const rule of bleedingRows) {
const margin = rule.body.match(/margin-inline:\s*([^;]+)/)?.[1]?.trim();
expect(margin).toBeDefined();
expect(margin === '0' || margin === 'calc(-1 * var(--message-inline-bleed))').toBe(true);
}
});
it('falls back to a system outline under forced colors', () => {
const forcedColors = focusRingCss.match(/@media \(forced-colors: active\) \{\n\t\.focusRing \{([^{}]*)\}/)?.[1];
expect(forcedColors).toBeDefined();
expect(forcedColors).toMatch(/box-shadow:\s*none/);
expect(forcedColors).toMatch(/outline-color:\s*Highlight/);
});
});
@@ -1,70 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
interface CssRule {
readonly selector: string;
readonly body: string;
}
function readSource(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
}
function parseCssRules(css: string): Array<CssRule> {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules: Array<CssRule> = [];
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
rules.push({selector: match[1].replace(/\s+/g, ''), body: match[2].trim()});
}
return rules;
}
const actionBarCss = readSource('./MessageActionBar.module.css');
const messageCss = readSource('../../theme/styles/Message.module.css');
const channelMessageSource = readSource('./ChannelMessage.tsx');
const hoverStateSource = readSource('./MessageHoverState.ts');
const actionBarContainerRules = parseCssRules(actionBarCss).filter((rule) =>
rule.selector.includes('.actionBarContainer'),
);
const revealingRules = actionBarContainerRules.filter((rule) => /visibility:\s*visible/.test(rule.body));
describe('message hover style contract', () => {
it('reveals the action bar from the tracked hover state', () => {
const hoverRevealRules = revealingRules.filter((rule) => !rule.selector.includes(':focus-visible'));
expect(hoverRevealRules.length).toBeGreaterThan(0);
for (const rule of hoverRevealRules) {
expect(rule.selector).not.toMatch(/:hover/);
expect(rule.selector).toMatch(/data-flx-action-bar-active/);
}
});
it('paints the row highlight from the class React drives', () => {
const highlightRules = parseCssRules(messageCss).filter(
(rule) =>
rule.selector.includes('.messageHovered') &&
!rule.selector.includes(':hover') &&
/(background-color|opacity):/.test(rule.body),
);
expect(highlightRules.length).toBeGreaterThan(0);
});
it('derives the row highlight and the action bar from the same hover state', () => {
const highlightSource = channelMessageSource.match(/(\w+) && !isPreview && styles\.messageHovered/)?.[1];
const actionBarSourceState = channelMessageSource.match(/const isActionBarActive = (\w+) \|\|/)?.[1];
expect(highlightSource).toBeDefined();
expect(actionBarSourceState).toBe(highlightSource);
expect(channelMessageSource).toMatch(
/data-flx-action-bar-active=\{shouldShowActionBar && isActionBarActive \? 'true' : undefined\}/,
);
});
it('resolves the tracked hover state without the :hover chain', () => {
expect(readSource('./MessageHoverTracking.ts')).not.toMatch(/matches\(':hover'\)/);
expect(hoverStateSource).not.toMatch(/matches\(':hover'\)/);
expect(hoverStateSource).toMatch(/registerMessageHoverTarget\(element, setDesktopHoverState\)/);
});
});
@@ -1,309 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
refreshMessageHoverTargets,
registerMessageHoverTarget,
resetMessageHoverTrackingForTests,
} from '@app/features/channel/components/MessageHoverTracking';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
let hitTarget: Element | null = null;
let elementFromPointSpy: ReturnType<typeof vi.spyOn>;
let matchesSpy: ReturnType<typeof vi.spyOn>;
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function createRow(): {row: HTMLElement; content: HTMLElement} {
const row = document.createElement('div');
const content = document.createElement('span');
row.append(content);
document.body.append(row);
return {row, content};
}
function movePointerTo(target: Element | null, x = 10, y = 10): void {
hitTarget = target;
window.dispatchEvent(new MouseEvent('pointermove', {clientX: x, clientY: y, bubbles: true}));
}
function scrollTo(target: Element | null): void {
hitTarget = target;
window.dispatchEvent(new Event('scroll'));
}
beforeEach(() => {
hitTarget = null;
elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockImplementation(() => hitTarget);
matchesSpy = vi.spyOn(Element.prototype, 'matches');
});
afterEach(() => {
vi.useRealTimers();
resetMessageHoverTrackingForTests();
elementFromPointSpy.mockRestore();
matchesSpy.mockRestore();
document.body.replaceChildren();
});
describe('MessageHoverTracking', () => {
it('arms the row that owns the element under the pointer', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
await nextFrame();
expect(listener).not.toHaveBeenCalled();
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
});
it('keeps at most one row armed when the pointer moves between adjacent rows', async () => {
const first = createRow();
const second = createRow();
const firstListener = vi.fn();
const secondListener = vi.fn();
registerMessageHoverTarget(first.row, firstListener);
registerMessageHoverTarget(second.row, secondListener);
movePointerTo(first.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(true);
expect(secondListener).not.toHaveBeenCalled();
movePointerTo(second.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(false);
expect(secondListener).toHaveBeenLastCalledWith(true);
});
it('does not re-arm a row on window refocus when the pointer left during the blur', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
window.dispatchEvent(new Event('blur'));
expect(listener).toHaveBeenLastCalledWith(false);
window.dispatchEvent(new Event('focus'));
refreshMessageHoverTargets();
await nextFrame();
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(false);
expect(listener.mock.calls.filter(([isHovered]) => isHovered === true)).toHaveLength(1);
});
it('never consults the retained :hover chain as a hover oracle', async () => {
const {row, content} = createRow();
registerMessageHoverTarget(row, vi.fn());
movePointerTo(content);
await nextFrame();
window.dispatchEvent(new Event('blur'));
window.dispatchEvent(new Event('focus'));
refreshMessageHoverTargets();
await nextFrame();
expect(matchesSpy.mock.calls.flat()).not.toContain(':hover');
});
it('disarms and does not re-arm while rows scroll under a stationary pointer', async () => {
const first = createRow();
const second = createRow();
const firstListener = vi.fn();
const secondListener = vi.fn();
registerMessageHoverTarget(first.row, firstListener);
registerMessageHoverTarget(second.row, secondListener);
movePointerTo(first.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(true);
scrollTo(second.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(false);
expect(secondListener).not.toHaveBeenCalled();
});
it('arms the row under the pointer once scrolling settles', async () => {
vi.useFakeTimers();
const first = createRow();
const second = createRow();
const firstListener = vi.fn();
const secondListener = vi.fn();
registerMessageHoverTarget(first.row, firstListener);
registerMessageHoverTarget(second.row, secondListener);
hitTarget = first.content;
window.dispatchEvent(new MouseEvent('pointermove', {clientX: 10, clientY: 10, bubbles: true}));
await vi.advanceTimersByTimeAsync(20);
expect(firstListener).toHaveBeenLastCalledWith(true);
scrollTo(second.content);
await vi.advanceTimersByTimeAsync(20);
expect(secondListener).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(200);
expect(secondListener).toHaveBeenLastCalledWith(true);
});
it('stays suspended for as long as momentum keeps firing scroll events', async () => {
vi.useFakeTimers();
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
hitTarget = content;
window.dispatchEvent(new MouseEvent('pointermove', {clientX: 10, clientY: 10, bubbles: true}));
await vi.advanceTimersByTimeAsync(20);
expect(listener).toHaveBeenLastCalledWith(true);
scrollTo(content);
await vi.advanceTimersByTimeAsync(20);
expect(listener).toHaveBeenLastCalledWith(false);
for (let i = 0; i < 6; i++) {
await vi.advanceTimersByTimeAsync(100);
scrollTo(content);
}
expect(listener).toHaveBeenLastCalledWith(false);
await vi.advanceTimersByTimeAsync(200);
expect(listener).toHaveBeenLastCalledWith(true);
});
it('re-arms immediately when the pointer actually moves during a scroll', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
scrollTo(content);
await nextFrame();
expect(listener).not.toHaveBeenCalledWith(true);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
});
it('does not let a scroll-driven pointerover re-arm a row', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
scrollTo(content);
await nextFrame();
hitTarget = content;
window.dispatchEvent(new MouseEvent('pointerover', {clientX: 10, clientY: 10, bubbles: true}));
await nextFrame();
expect(listener).not.toHaveBeenCalledWith(true);
});
it('re-arms the row underneath after an overlay that covered it is dismissed', async () => {
const {row, content} = createRow();
const overlay = document.createElement('div');
document.body.append(overlay);
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
hitTarget = overlay;
refreshMessageHoverTargets();
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(false);
overlay.remove();
hitTarget = content;
refreshMessageHoverTargets();
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
});
it('does not treat a touch contact as hover', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
hitTarget = content;
window.dispatchEvent(new PointerEvent('pointerdown', {clientX: 10, clientY: 10, pointerType: 'touch'}));
await nextFrame();
expect(listener).not.toHaveBeenCalledWith(true);
});
it('disarms when the pointer leaves the window', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
window.dispatchEvent(new MouseEvent('pointerout', {relatedTarget: null}));
expect(listener).toHaveBeenLastCalledWith(false);
});
it('releases ownership when an armed row unregisters', async () => {
const {row, content} = createRow();
const listener = vi.fn();
const unregister = registerMessageHoverTarget(row, listener);
const other = createRow();
const otherListener = vi.fn();
registerMessageHoverTarget(other.row, otherListener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
unregister();
hitTarget = other.content;
await nextFrame();
expect(otherListener).toHaveBeenLastCalledWith(true);
expect(listener).not.toHaveBeenLastCalledWith(true);
});
it('detaches every global listener once the last row unregisters', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const {row} = createRow();
const unregister = registerMessageHoverTarget(row, vi.fn());
const added = addEventListenerSpy.mock.calls.map(([type, listener]) => ({type, listener}));
unregister();
const removed = removeEventListenerSpy.mock.calls.map(([type, listener]) => ({type, listener}));
expect(added.length).toBeGreaterThan(0);
for (const addedListener of added) {
expect(
removed.some(
(removedListener) =>
removedListener.type === addedListener.type && removedListener.listener === addedListener.listener,
),
).toBe(true);
}
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
});
@@ -1,110 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type React from 'react';
import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, plural: () => '', t: descriptor};
});
vi.mock('@lingui/react/macro', () => ({
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
vi.mock('@app/features/app/components/LongPressable', () => ({
LongPressable: ({children, onLongPress}: {children?: React.ReactNode; onLongPress?: () => void}) => (
<div data-flx="test.long-pressable">
<button type="button" data-test-long-press="true" onClick={onLongPress} data-flx="test.long-press">
{'press'}
</button>
{children}
</div>
),
}));
vi.mock('@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet', () => ({
ExpressionInfoBottomSheet: ({isOpen}: {isOpen: boolean}) => (
<span data-test-expression-sheet={String(isOpen)} data-flx="test.expression-sheet" />
),
}));
vi.mock('@app/features/ui/state/MobileLayout', () => ({default: {isMobileLayout: () => true, enabled: true}}));
vi.mock('@app/features/channel/components/MessageActionUtils', () => ({
createMessageActionHandlers: () => ({handleEmojiSelect: vi.fn()}),
isClientSystemMessage: () => false,
useMessagePermissions: () => ({canAddReactions: false, channel: null}),
}));
vi.mock('@app/features/messaging/hooks/useMessageReactionStore', () => ({
useMessageReactions: () => [{emoji: {id: '30', name: 'blob', animated: false}, count: 1, me: false}],
}));
vi.mock('@app/features/messaging/hooks/useMatureMedia', () => ({
useMatureMedia: () => ({shouldBlur: false, shouldBlock: false, canReveal: true, gateReason: 'none', reveal: vi.fn()}),
}));
vi.mock('@app/features/messaging/utils/ReactionUtils', () => ({
getEmojiName: () => ':blob:',
getReactionKey: () => 'reaction-key',
useEmojiURL: () => 'https://cdn.test/blob.webp',
}));
vi.mock('@app/features/messaging/components/ReactionImage', () => ({ReactionImage: () => null}));
vi.mock('@app/features/messaging/components/popouts/ReactionTooltip', () => ({
ReactionTooltip: ({children}: {children?: React.ReactNode}) => (
<span data-flx="test.reaction-tooltip">{children}</span>
),
}));
vi.mock('@app/features/emoji/components/popouts/EmojiPickerPopout', () => ({EmojiPickerPopout: () => null}));
vi.mock('@app/features/expressions/components/modals/ExpressionPickerSheet', () => ({
ExpressionPickerSheet: () => null,
}));
vi.mock('@app/features/ui/popover/PopoverPopout', () => ({Popout: () => null, openPopout: vi.fn()}));
vi.mock('@app/features/emoji/state/Emoji', () => ({default: {getEmojiById: () => null}}));
vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({openFromEvent: vi.fn()}));
vi.mock('@app/features/ui/action_menu/items/EmojiContextMenuItems', () => ({EmojiContextMenuItems: () => null}));
vi.mock('@app/features/accessibility/state/Accessibility', () => ({default: {useReducedMotion: true}}));
vi.mock('@app/features/ui/state/KeyboardMode', () => ({default: {keyboardModeEnabled: false}}));
installVoiceMenuTestBootstrap();
const {MessageReactions} = await import('@app/features/channel/components/MessageReactions');
const message = {id: '1', channelId: '2'} as never;
let host: HTMLDivElement;
let root: Root;
function renderReactions(): void {
act(() => {
root.render(<MessageReactions message={message} onPopoutToggle={() => {}} data-flx="test.message-reactions" />);
});
}
function longPress(): void {
const trigger = host.querySelector<HTMLButtonElement>('[data-test-long-press="true"]');
if (trigger == null) throw new Error('long press trigger is missing');
act(() => {
trigger.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
describe('reaction long press', () => {
it('opens the expression info sheet', () => {
renderReactions();
expect(host.querySelector('[data-test-expression-sheet="true"]')).toBeNull();
longPress();
expect(host.querySelector('[data-test-expression-sheet="true"]')).not.toBeNull();
});
});
@@ -1,65 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from '@app/features/channel/components/SearchResultGrouping';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {describe, expect, it} from 'vitest';
const message = (id: string, channelId: string): Message => ({id, channelId}) as Message;
describe('SearchResultGrouping', () => {
it('keeps the order the server returned instead of bucketing per channel', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(groups.map((group) => group.messages.map((result) => result.id))).toEqual([['500'], ['400'], ['300']]);
expect(groups.map((group) => group.channelId)).toEqual(['general', 'random', 'general']);
});
it('merges consecutive results from the same channel into one group', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('490', 'general'),
message('400', 'random'),
]);
expect(groups).toHaveLength(2);
expect(groups[0].messages.map((result) => result.id)).toEqual(['500', '490']);
expect(groups[1].messages.map((result) => result.id)).toEqual(['400']);
});
it('gives every group a unique key even when a channel appears more than once', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(new Set(groups.map((group) => group.key)).size).toBe(3);
});
it('indexes each message to the group it renders in', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
const groupsByMessageId = buildSearchResultGroupsByMessageId(groups);
expect(groupsByMessageId.get('500')).toBe(groups[0]);
expect(groupsByMessageId.get('400')).toBe(groups[1]);
expect(groupsByMessageId.get('300')).toBe(groups[2]);
});
it('counts distinct channels rather than groups', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(countSearchResultChannels(groups)).toBe(2);
expect(countSearchResultChannels(buildSearchResultGroups([]))).toBe(0);
});
});
@@ -1,46 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveSearchScope} from '@app/features/channel/components/SearchScopeResolution';
import type {MessageSearchScope} from '@app/features/search/utils/SearchUtils';
import {describe, expect, it} from 'vitest';
const GUILD_SCOPE_OPTIONS = new Set<MessageSearchScope>([
'current',
'all_guilds',
'all_dms',
'open_dms',
'all',
'open_dms_and_all_guilds',
]);
describe('resolveSearchScope', () => {
it('uses an explicit control override before captured active scope', () => {
expect(
resolveSearchScope({
activeScope: 'current',
fallbackScope: 'current',
scopeOverride: 'all_guilds',
scopeOptionValues: GUILD_SCOPE_OPTIONS,
}),
).toBe('all_guilds');
});
it('uses a parsed query scope when there is no control override', () => {
expect(
resolveSearchScope({
activeScope: 'current',
fallbackScope: 'current',
parsedScope: 'all',
scopeOptionValues: GUILD_SCOPE_OPTIONS,
}),
).toBe('all');
});
it('falls back when the selected scope is not valid for the current channel', () => {
expect(
resolveSearchScope({
activeScope: 'all_guilds',
fallbackScope: 'current',
scopeOptionValues: new Set<MessageSearchScope>(['current', 'all_dms', 'open_dms']),
}),
).toBe('current');
});
});
@@ -1,54 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createSearchMachineSnapshot,
selectSearchMachineState,
transitionSearchMachineSnapshot,
} from '@app/features/channel/components/SearchStateMachine';
import {describe, expect, it} from 'vitest';
describe('SearchStateMachine', () => {
it('starts idle and transitions through loading to success', () => {
let snapshot = createSearchMachineSnapshot();
expect(selectSearchMachineState(snapshot)).toEqual({status: 'idle'});
snapshot = transitionSearchMachineSnapshot(snapshot, {type: 'channelSearch.loading'});
expect(selectSearchMachineState(snapshot)).toEqual({status: 'loading'});
snapshot = transitionSearchMachineSnapshot(snapshot, {
type: 'channelSearch.succeeded',
results: [],
channels: [],
total: 0,
hitsPerPage: 25,
page: 1,
});
expect(selectSearchMachineState(snapshot)).toEqual({
status: 'success',
results: [],
channels: [],
total: 0,
hitsPerPage: 25,
page: 1,
});
});
it('tracks indexing poll attempts in machine context', () => {
let snapshot = createSearchMachineSnapshot();
snapshot = transitionSearchMachineSnapshot(snapshot, {type: 'channelSearch.indexingStarted'});
expect(selectSearchMachineState(snapshot)).toEqual({status: 'indexing', pollCount: 0});
snapshot = transitionSearchMachineSnapshot(snapshot, {type: 'channelSearch.indexingPolled'});
snapshot = transitionSearchMachineSnapshot(snapshot, {type: 'channelSearch.indexingPolled'});
expect(selectSearchMachineState(snapshot)).toEqual({status: 'indexing', pollCount: 2});
});
it('clears retained context on reset', () => {
let snapshot = createSearchMachineSnapshot();
snapshot = transitionSearchMachineSnapshot(snapshot, {type: 'channelSearch.failed', error: 'Search failed'});
expect(selectSearchMachineState(snapshot)).toEqual({status: 'error', error: 'Search failed'});
snapshot = transitionSearchMachineSnapshot(snapshot, {type: 'channelSearch.reset'});
expect(selectSearchMachineState(snapshot)).toEqual({status: 'idle'});
});
});
@@ -1,377 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import messageStyles from '@app/features/theme/styles/Message.module.css';
import {runInAction} from 'mobx';
import {act, createElement, Fragment, Profiler, type ReactNode} from 'react';
import {createRoot, type Root} from 'react-dom/client';
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; username: string}>(),
};
});
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),
useLingui: () => ({
i18n: {
locale: 'en-US',
_: (descriptor: {message: string}, values: Record<string, string> = {}) =>
descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''),
},
}),
};
});
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)},
}));
vi.mock('@app/features/user/utils/NicknameUtils', () => ({
getNickname: (user: {username: string}) => user.username,
}));
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => undefined}}));
vi.mock('@app/features/channel/components/ChannelTyping', async () => {
const {createElement: create} = await import('react');
return {Typing: () => create('svg')};
});
vi.mock('@app/features/ui/avatars/AvatarStack', async () => {
const {createElement: create} = await import('react');
return {
AvatarStack: ({className, users}: {className?: string; users: ReadonlyArray<unknown>}) =>
create('div', {className, 'data-avatar-count': users.length}),
};
});
const {TypingAnnouncer, TypingUsers} = await import('@app/features/channel/components/TypingUsers');
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
class StubResizeObserver {
static instances: Array<StubResizeObserver> = [];
readonly targets: Array<Element> = [];
disconnected = false;
constructor(private readonly callback: () => void) {
StubResizeObserver.instances.push(this);
}
observe(target: Element): void {
this.targets.push(target);
}
disconnect(): void {
this.disconnected = true;
}
trigger(): void {
this.callback();
}
}
const CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel;
const TYPIST_IDS = ['alice', 'bob', 'carol', 'dave', 'erin'];
let host: HTMLDivElement;
let root: Root;
let overflowContainer: HTMLElement;
let overflowContainerRef: {current: HTMLElement | null};
function setWidth(element: Element, property: 'clientWidth' | 'scrollWidth', width: number): void {
Object.defineProperty(element, property, {configurable: true, value: width});
}
function render(node: ReactNode): void {
act(() => {
root.render(node);
});
}
function renderRow(props: {showAvatars?: boolean} = {}): void {
render(
createElement(
Fragment,
null,
createElement(TypingUsers, {channel: CHANNEL, overflowContainerRef, showAvatars: props.showAvatars}),
createElement(TypingAnnouncer, {channel: CHANNEL}),
),
);
}
function startTyping(...userIds: Array<string>): void {
act(() => {
for (const userId of userIds) {
RollingTypingStore.start(CHANNEL.id, userId, userId === 'me' ? 'local' : 'gateway');
}
});
}
function query(flx: string): HTMLElement | null {
return host.querySelector<HTMLElement>(`[data-flx="${flx}"]`);
}
function visibleText(): string | null {
return query('channel.typing-users.span')?.textContent ?? null;
}
function announcer(): HTMLElement {
return query('channel.typing-users.announcer')!;
}
function measure(): HTMLElement | null {
return query('channel.typing-users.measure');
}
function activeResizeObservers(): Array<StubResizeObserver> {
return StubResizeObserver.instances.filter((instance) => !instance.disconnected);
}
function remeasure(): void {
act(() => {
for (const instance of activeResizeObservers()) {
instance.trigger();
}
});
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
StubResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
host = document.createElement('div');
overflowContainer = document.createElement('div');
document.body.append(overflowContainer, host);
setWidth(overflowContainer, 'clientWidth', 1000);
overflowContainerRef = {current: overflowContainer};
root = createRoot(host);
runInAction(() => {
doubles.auth.currentUserId = 'me';
doubles.developerOptions.showMyselfTyping = false;
doubles.blockedUserIds.clear();
doubles.users.clear();
for (const id of ['me', ...TYPIST_IDS]) {
doubles.users.set(id, {id, username: id});
}
});
});
afterEach(() => {
act(() => {
root.unmount();
});
RollingTypingStore.reset();
document.body.replaceChildren();
vi.unstubAllGlobals();
});
describe('TypingUsers', () => {
it('hides the current user from the typing row', () => {
renderRow();
startTyping('me', 'alice');
expect(visibleText()).toBe('alice is typing...');
expect(host.querySelector('[data-avatar-count]')?.getAttribute('data-avatar-count')).toBe('1');
});
it('shows the current user when the developer option is on', () => {
runInAction(() => {
doubles.developerOptions.showMyselfTyping = true;
});
renderRow();
startTyping('me', 'alice');
expect(visibleText()).toBe('me and alice are typing...');
});
it('hides blocked users from the typing row', () => {
runInAction(() => {
doubles.blockedUserIds.add('bob');
});
renderRow();
startTyping('bob', 'alice');
expect(visibleText()).toBe('alice is typing...');
});
it('skips typists missing from the user cache', () => {
renderRow();
startTyping('stranger', 'alice');
expect(RollingTypingStore.countTypists(CHANNEL.id)).toBe(2);
expect(visibleText()).toBe('alice is typing...');
});
it('renders nothing visible when only self is typing', () => {
renderRow();
startTyping('me');
expect(query('channel.typing-users.div')).toBeNull();
expect(announcer().textContent).toBe('');
});
it('marks the visible text as hidden from assistive technology', () => {
renderRow();
startTyping('alice');
const visible = query('channel.typing-users.span')!;
expect(visible.getAttribute('aria-hidden')).toBe('true');
expect(visible.hasAttribute('aria-live')).toBe(false);
expect(measure()?.getAttribute('aria-hidden')).toBe('true');
});
it('keeps the announcer mounted when nobody is typing', () => {
renderRow();
const mounted = announcer();
expect(mounted.textContent).toBe('');
startTyping('alice');
expect(announcer()).toBe(mounted);
expect(mounted.textContent).toBe('alice is typing...');
act(() => {
RollingTypingStore.remove(CHANNEL.id, 'alice');
});
expect(announcer()).toBe(mounted);
expect(mounted.textContent).toBe('');
});
it('announces through a polite atomic live region', () => {
renderRow();
startTyping('alice', 'bob', 'carol', 'dave');
expect(announcer().getAttribute('aria-live')).toBe('polite');
expect(announcer().getAttribute('aria-atomic')).toBe('true');
expect(announcer().textContent).toBe('Several people are typing...');
});
it('swaps to multiple people when the measured text plus 48 px exceeds the available width', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice', 'bob');
expect(visibleText()).toBe('alice and bob are typing...');
setWidth(measure()!, 'scrollWidth', 253);
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
expect(measure()?.textContent).toBe('alice and bob are typing...');
});
it('keeps the names when they fit with 48 px to spare', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice', 'bob');
setWidth(measure()!, 'scrollWidth', 252);
remeasure();
expect(visibleText()).toBe('alice and bob are typing...');
});
it('keeps the full string in the announcer while the visible text overflows', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice', 'bob', 'carol');
setWidth(measure()!, 'scrollWidth', 400);
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
expect(announcer().textContent).toBe('alice, bob and carol are typing...');
});
it('restores the names when the container grows again', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice');
setWidth(measure()!, 'scrollWidth', 280);
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
setWidth(overflowContainer, 'clientWidth', 400);
remeasure();
expect(visibleText()).toBe('alice is typing...');
});
it('does not measure overflow with four or more typists', () => {
setWidth(overflowContainer, 'clientWidth', 10);
renderRow();
startTyping('alice', 'bob', 'carol');
expect(visibleText()).toBe('Multiple people are typing...');
expect(activeResizeObservers()).toHaveLength(1);
startTyping('dave');
expect(visibleText()).toBe('Several people are typing...');
expect(measure()).toBeNull();
expect(activeResizeObservers()).toHaveLength(0);
});
it('subtracts the avatar stack width from the available width', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: true});
startTyping('alice');
setWidth(measure()!, 'scrollWidth', 200);
remeasure();
expect(visibleText()).toBe('alice is typing...');
const avatarStack = host.querySelector(`.${messageStyles.typingAvatarContainer}`)!;
avatarStack.getBoundingClientRect = () => ({width: 60}) as DOMRect;
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
});
it('disconnects the resize observer on unmount', () => {
renderRow();
startTyping('alice');
const [resizeObserver] = activeResizeObservers();
expect(resizeObserver?.targets).toEqual([overflowContainer, measure()]);
act(() => {
root.unmount();
});
root = createRoot(host);
expect(resizeObserver?.disconnected).toBe(true);
expect(activeResizeObservers()).toHaveLength(0);
});
it("does not re-render on the current user's own typing", () => {
const onRender = vi.fn();
render(
createElement(
Profiler,
{id: 'typing-row', onRender},
createElement(TypingUsers, {channel: CHANNEL, overflowContainerRef}),
),
);
startTyping('alice');
const commits = onRender.mock.calls.length;
startTyping('me');
act(() => {
RollingTypingStore.start(CHANNEL.id, 'me', 'gateway');
RollingTypingStore.start(CHANNEL.id, 'alice', 'gateway');
});
act(() => {
RollingTypingStore.remove(CHANNEL.id, 'me');
});
expect(onRender).toHaveBeenCalledTimes(commits);
expect(visibleText()).toBe('alice is typing...');
});
});
@@ -1,41 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH,
BLUESKY_EMBED_MIN_CONTENT_WIDTH,
calculateBlueskyMediaContainerWidth,
calculateBlueskyOuterMaxWidth,
EMBED_TEXT_OUTER_WIDTH,
formatResponsiveEmbedWidth,
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import {describe, expect, it} from 'vitest';
describe('formatResponsiveEmbedWidth', () => {
it('caps in rem so the width tracks the root font size at any zoom', () => {
expect(formatResponsiveEmbedWidth(EMBED_TEXT_OUTER_WIDTH)).toBe('min(100%, 32.25rem)');
expect(formatResponsiveEmbedWidth(BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH)).toBe('min(100%, 27rem)');
});
it('rounds to whole pixels before converting', () => {
expect(formatResponsiveEmbedWidth(400.4)).toBe('min(100%, 25rem)');
});
it('clamps non-positive and non-finite widths', () => {
expect(formatResponsiveEmbedWidth(0)).toBe('min(100%, 0rem)');
expect(formatResponsiveEmbedWidth(Number.NaN)).toBe('min(100%, 0rem)');
});
});
describe('calculateBlueskyOuterMaxWidth', () => {
it('falls back to the shared text width when there is no media', () => {
expect(calculateBlueskyOuterMaxWidth({hasMedia: false, chromeWidth: 29})).toBe(EMBED_TEXT_OUTER_WIDTH);
});
it('adds chrome around the media container width', () => {
expect(calculateBlueskyOuterMaxWidth({mediaWidth: 480, hasMedia: true, chromeWidth: 29})).toBe(509);
});
it('never goes below the minimum content width', () => {
expect(calculateBlueskyMediaContainerWidth(120)).toBe(BLUESKY_EMBED_MIN_CONTENT_WIDTH);
});
});
@@ -1,64 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildCsvTableNode,
isCsvAttachment,
parseCsvRows,
} from '@app/features/channel/components/embeds/attachments/CsvAttachmentPreviewUtils';
import {TableAlignment} from '@app/features/messaging/utils/markdown/parser/Enums';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {describe, expect, it} from 'vitest';
function attachmentFixture(fields: Partial<MessageAttachment>): MessageAttachment {
return fields as MessageAttachment;
}
describe('CSV attachment preview utils', () => {
it('detects CSV attachments by extension or content type', () => {
expect(
isCsvAttachment(attachmentFixture({id: '1', filename: 'data.csv', content_type: 'application/octet-stream'})),
).toBe(true);
expect(
isCsvAttachment(attachmentFixture({id: '2', filename: 'data.txt', content_type: 'text/csv; charset=utf-8'})),
).toBe(true);
expect(isCsvAttachment(attachmentFixture({id: '3', filename: 'data.txt', content_type: 'text/plain'}))).toBe(false);
});
it('parses quoted values, escaped quotes, and CRLF rows', () => {
expect(parseCsvRows('name,note\r\n"Ada, Lovelace","said ""hello"""\r\nGrace,Compiler')).toEqual([
['name', 'note'],
['Ada, Lovelace', 'said "hello"'],
['Grace', 'Compiler'],
]);
});
it('preserves empty cells and ignores one trailing row break', () => {
expect(parseCsvRows('a,,c\n1,2,\n')).toEqual([
['a', '', 'c'],
['1', '2', ''],
]);
});
it('normalizes ragged rows into a markdown table node', () => {
expect(buildCsvTableNode([['a'], ['1', '2']])).toEqual({
type: 'Table',
header: {
type: 'TableRow',
cells: [
{type: 'TableCell', children: [{type: 'Text', content: 'a'}]},
{type: 'TableCell', children: [{type: 'Text', content: ''}]},
],
},
alignments: [TableAlignment.None, TableAlignment.None],
rows: [
{
type: 'TableRow',
cells: [
{type: 'TableCell', children: [{type: 'Text', content: '1'}]},
{type: 'TableCell', children: [{type: 'Text', content: '2'}]},
],
},
],
});
});
});
@@ -1,106 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getEffectiveVideoLayoutDimensions,
hasDifferentAspectRatio,
isInlinePlayableVideoSize,
resolveVideoLayout,
} from '@app/features/channel/components/embeds/media/VideoDimensionUtils';
import {describe, expect, it} from 'vitest';
describe('getEffectiveVideoLayoutDimensions', () => {
it('prefers decoded dimensions over declared upload dimensions', () => {
expect(getEffectiveVideoLayoutDimensions({width: 1280, height: 700}, {width: 1920, height: 700})).toEqual({
width: 1920,
height: 700,
});
});
it('prefers decoded dimensions for rotated portrait video', () => {
expect(getEffectiveVideoLayoutDimensions({width: 1920, height: 1080}, {width: 1080, height: 1920})).toEqual({
width: 1080,
height: 1920,
});
});
it('falls back to declared dimensions until metadata decodes', () => {
expect(getEffectiveVideoLayoutDimensions({width: 640, height: 360}, null)).toEqual({width: 640, height: 360});
});
it('falls back to a 16:9 default when nothing is known', () => {
expect(getEffectiveVideoLayoutDimensions(null, null)).toEqual({width: 16, height: 9});
});
it('ignores invalid decoded dimensions', () => {
expect(getEffectiveVideoLayoutDimensions({width: 640, height: 360}, {width: 0, height: 0})).toEqual({
width: 640,
height: 360,
});
});
});
describe('poster aspect-ratio mismatch detection', () => {
it('flags an anamorphic thumbnail as mismatched against the decoded video', () => {
expect(hasDifferentAspectRatio({width: 1920, height: 700}, {width: 1280, height: 700}, 0.05)).toBe(true);
});
it('flags a rotated (inverted) thumbnail as mismatched', () => {
expect(hasDifferentAspectRatio({width: 1080, height: 1920}, {width: 1920, height: 1080}, 0.05)).toBe(true);
});
it('treats a matching thumbnail as consistent within tolerance', () => {
expect(hasDifferentAspectRatio({width: 1920, height: 1080}, {width: 1280, height: 720}, 0.05)).toBe(false);
});
});
describe('resolveVideoLayout', () => {
it('produces an aspect ratio matching the decoded display dimensions', () => {
const {aspectRatio} = resolveVideoLayout({width: 1920, height: 700}, {maxWidth: 400, maxHeight: 400});
const [w, h] = aspectRatio.split(' / ').map(Number);
expect(w / h).toBeCloseTo(1920 / 700, 1);
});
});
describe('isInlinePlayableVideoSize', () => {
it('accepts a landscape source sitting exactly on both edges', () => {
expect(isInlinePlayableVideoSize({width: 6016, height: 3384})).toBe(true);
});
it('accepts a portrait source sitting exactly on the swapped edges', () => {
expect(isInlinePlayableVideoSize({width: 3384, height: 6016})).toBe(true);
});
it('rejects one pixel past the landscape long edge', () => {
expect(isInlinePlayableVideoSize({width: 6017, height: 3384})).toBe(false);
});
it('rejects one pixel past the landscape short edge', () => {
expect(isInlinePlayableVideoSize({width: 6016, height: 3385})).toBe(false);
});
it('rejects one pixel past the portrait long edge', () => {
expect(isInlinePlayableVideoSize({width: 3384, height: 6017})).toBe(false);
});
it('rejects one pixel past the portrait short edge', () => {
expect(isInlinePlayableVideoSize({width: 3385, height: 6016})).toBe(false);
});
it('accepts a source that only fits the swapped clause', () => {
expect(isInlinePlayableVideoSize({width: 3000, height: 5000})).toBe(true);
});
it('rejects a square source larger than the short edge', () => {
expect(isInlinePlayableVideoSize({width: 4000, height: 4000})).toBe(false);
});
it('accepts a square source on the short edge', () => {
expect(isInlinePlayableVideoSize({width: 3384, height: 3384})).toBe(true);
});
it('accepts unknown dimensions so metadata gaps never silence playback', () => {
expect(isInlinePlayableVideoSize(null)).toBe(true);
expect(isInlinePlayableVideoSize({width: 0, height: 0})).toBe(true);
expect(isInlinePlayableVideoSize({width: Number.NaN, height: Number.NaN})).toBe(true);
});
});
@@ -1,63 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type FavoriteGifEntry,
slimFavoriteGifEntry,
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
import {describe, expect, it} from 'vitest';
function format(src: string, width: number) {
return {src, proxy_src: `https://media.test/external/sig/${src}`, width, height: Math.round(width * 0.84)};
}
const FAT: FavoriteGifEntry = {
url: 'https://klipy.com/gifs/reaction',
proxy_url: 'https://media.test/external/sig/tiny.webm',
width: 220,
height: 185,
media: {
nanowebm: format('nano.webm', 90),
tinywebm: format('tiny.webm', 220),
mediumwebm: format('medium.webm', 320),
webm: format('full.webm', 498),
gif: format('full.gif', 498),
},
content_type: 'video/webm',
placeholder: 'placeholder',
};
describe('slimFavoriteGifEntry', () => {
it('drops the media map so a favorite is cheap to sync', () => {
expect(slimFavoriteGifEntry(FAT).media).toEqual({});
});
it('promotes a preview that covers a 200px tile at 2x into the top-level fields', () => {
const slim = slimFavoriteGifEntry(FAT);
expect(slim.proxy_url).toBe(FAT.media.webm.proxy_src);
expect(slim.width).toBe(498);
expect(slim.content_type).toBe('video/webm');
});
it('keeps url and placeholder untouched', () => {
const slim = slimFavoriteGifEntry(FAT);
expect(slim.url).toBe(FAT.url);
expect(slim.placeholder).toBe(FAT.placeholder);
});
it('is idempotent so the synced roundtrip stays stable', () => {
const once = slimFavoriteGifEntry(FAT);
expect(slimFavoriteGifEntry(once)).toEqual(once);
});
it('leaves an entry that already carries no media alone', () => {
const urlOnly: FavoriteGifEntry = {...FAT, media: {}};
expect(slimFavoriteGifEntry(urlOnly)).toBe(urlOnly);
});
it('does not invent a preview when every format is unusable', () => {
const broken: FavoriteGifEntry = {...FAT, media: {webm: {src: '', proxy_src: '', width: 0, height: 0}}};
const slim = slimFavoriteGifEntry(broken);
expect(slim.media).toEqual({});
expect(slim.proxy_url).toBe(FAT.proxy_url);
});
});
@@ -1,186 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {FavoriteGifEntry} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
import {buildGifPickerGridData} from '@app/features/channel/components/pickers/gif/GifPickerGridData';
import type {FavoriteAwareGif} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
import {describe, expect, it} from 'vitest';
function gif(id: string, width = 200, height = 120): FavoriteAwareGif {
return {
id,
slug: id,
provider: 'klipy',
title: id,
url: `https://example.test/${id}`,
src: `https://cdn.example.test/${id}.webp`,
proxy_src: `https://proxy.example.test/${id}.webp`,
width,
height,
media: {},
};
}
const OPAQUE_PROXY_URL =
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
function slimFavorite(overrides: Partial<FavoriteGifEntry> = {}): FavoriteGifEntry {
return {
url: 'https://klipy.com/gifs/doc-brown-bttf',
proxy_url: OPAQUE_PROXY_URL,
width: 640,
height: 360,
media: {},
content_type: 'video/webm',
placeholder: null,
...overrides,
};
}
describe('GifPickerGridData', () => {
it('carries the stored content type of a slimmed favorite into its tile', () => {
const data = buildGifPickerGridData({
surface: 'favorites',
loading: false,
columns: 3,
provider: 'klipy',
featured: {gifs: [], categories: []},
gifs: [],
favoriteGifs: [slimFavorite()],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data[0]).toMatchObject({
type: 'gif',
gif: {proxy_src: OPAQUE_PROXY_URL, contentType: 'video/webm'},
});
});
it('carries the stored content type of a slimmed favorite into the featured favorites tile', () => {
const data = buildGifPickerGridData({
surface: 'featured',
loading: false,
columns: 3,
provider: 'klipy',
featured: {gifs: [gif('featured')], categories: []},
gifs: [],
favoriteGifs: [slimFavorite()],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data[0]).toMatchObject({
type: 'category',
previewProxySrc: OPAQUE_PROXY_URL,
previewContentType: 'video/webm',
});
});
it('takes the content type of the saved media standing in for an empty favorites tile', () => {
const data = buildGifPickerGridData({
surface: 'featured',
loading: false,
columns: 3,
provider: 'klipy',
featured: {gifs: [gif('featured')], categories: []},
gifs: [],
favoriteGifs: [],
favoriteMemes: [{contentType: 'video/mp4', url: 'meme-0'}],
useSavedMediaForGifFavorites: true,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data[0]).toMatchObject({type: 'category', previewProxySrc: 'meme-0', previewContentType: 'video/mp4'});
});
it('keeps featured data deterministic while choosing preview tiles', () => {
const data = buildGifPickerGridData({
surface: 'featured',
loading: false,
columns: 3,
provider: 'klipy',
featured: {
gifs: [gif('featured')],
categories: [{name: 'cats', src: 'cat-src', proxy_src: 'cat-proxy', gif: null}],
},
gifs: [],
favoriteGifs: [],
favoriteMemes: [
{contentType: 'video/mp4', url: 'meme-0'},
{contentType: 'image/png', url: 'ignored'},
{contentType: 'image/gif', url: 'meme-1'},
],
useSavedMediaForGifFavorites: true,
featuredFavoritePreviewSeed: 0.75,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data.map((item) => item.key)).toEqual(['favorites', 'trending', 'cats']);
expect(data[0]).toMatchObject({type: 'category', previewUrl: 'meme-1', previewProxySrc: 'meme-1'});
expect(data[1]).toMatchObject({type: 'category', previewUrl: 'https://cdn.example.test/featured.webp'});
});
it('omits the favorites tile when includeFavoritesTile is false', () => {
const data = buildGifPickerGridData({
surface: 'featured',
loading: false,
columns: 3,
provider: 'klipy',
featured: {
gifs: [gif('featured')],
categories: [{name: 'cats', src: 'cat-src', proxy_src: 'cat-proxy', gif: null}],
},
gifs: [],
favoriteGifs: [],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
includeFavoritesTile: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data.map((item) => item.key)).toEqual(['trending', 'cats']);
});
it('builds skeletons only when result data is empty during loading', () => {
const data = buildGifPickerGridData({
surface: 'results',
loading: true,
columns: 4,
provider: 'klipy',
featured: {gifs: [], categories: []},
gifs: [],
favoriteGifs: [],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data).toHaveLength(12);
expect(data.every((item) => item.type === 'skeleton')).toBe(true);
});
it('keeps old result items instead of skeletonizing while a newer query is loading', () => {
const data = buildGifPickerGridData({
surface: 'results',
loading: true,
columns: 4,
provider: 'klipy',
featured: {gifs: [], categories: []},
gifs: [gif('old-result')],
favoriteGifs: [],
favoriteMemes: [],
useSavedMediaForGifFavorites: false,
featuredFavoritePreviewSeed: 0,
favoriteTitle: 'Favorites',
trendingTitle: 'Trending',
});
expect(data).toEqual([{type: 'gif', key: 'old-result', gif: gif('old-result')}]);
});
});
@@ -1,38 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {buildGifPickerLoadingSkeletonLayout} from '@app/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout';
import {describe, expect, it} from 'vitest';
describe('buildGifPickerLoadingSkeletonLayout', () => {
it('lays out GIF-like placeholders with the same masonry constraints as the real grid', () => {
const layout = buildGifPickerLoadingSkeletonLayout({
viewportWidth: 436,
viewportHeight: 1200,
});
expect(layout.length).toBeGreaterThan(12);
expect(layout[0]).toMatchObject({
key: 'square-255',
column: 0,
left: 12,
top: 12,
renderedWidth: 202,
renderedHeight: 202,
});
const wideItem = layout.find((item) => item.key === 'wide-640-358');
const portraitItem = layout.find((item) => item.key === 'portrait-325-498');
expect(wideItem).toBeDefined();
expect(portraitItem).toBeDefined();
expect(wideItem!.renderedHeight / wideItem!.renderedWidth).toBeCloseTo(358 / 640, 4);
expect(portraitItem!.renderedHeight / portraitItem!.renderedWidth).toBeCloseTo(498 / 325, 4);
for (const item of layout) {
expect(item.left).toBeGreaterThanOrEqual(12);
expect(item.left + item.renderedWidth).toBeLessThanOrEqual(436 - 12);
expect(item.top).toBeGreaterThanOrEqual(12);
}
});
it('returns no placeholders before the skeleton viewport has measurable dimensions', () => {
expect(buildGifPickerLoadingSkeletonLayout({viewportWidth: 0, viewportHeight: 600})).toEqual([]);
expect(buildGifPickerLoadingSkeletonLayout({viewportWidth: 436, viewportHeight: 0})).toEqual([]);
});
});
@@ -1,38 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolvesToVideo} from '@app/features/channel/components/pickers/gif/GifPickerMediaKind';
import {describe, expect, it} from 'vitest';
const OPAQUE_PROXY_SRC =
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
const PLAIN_PROXY_SRC = 'https://media.test/external/sig/https/static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm';
describe('resolvesToVideo', () => {
it('trusts a stored video content type when no url states its kind', () => {
expect(resolvesToVideo('video/webm', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(true);
});
it('trusts a stored image content type when no url states its kind', () => {
expect(resolvesToVideo('image/gif', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
});
it('falls back to an image when nothing states the kind', () => {
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
});
it('reads the extension off a plain proxy path', () => {
expect(resolvesToVideo('', PLAIN_PROXY_SRC, null)).toBe(true);
});
it('reads the extension off the media source when the proxy path is opaque', () => {
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, 'https://static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm')).toBe(true);
});
it('keeps images as images when the proxy path states its kind', () => {
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.gif', null)).toBe(false);
});
it('ignores a query string when reading the extension', () => {
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.mp4?width=200', null)).toBe(true);
});
});
@@ -1,308 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createGifPickerRequestId,
createGifPickerSnapshot,
selectGifPickerModel,
transitionGifPickerSnapshot,
} from '@app/features/channel/components/pickers/gif/GifPickerStateMachine';
import {describe, expect, it} from 'vitest';
describe('GifPickerStateMachine', () => {
it('starts on the featured surface without showing a loading state', () => {
const model = selectGifPickerModel(createGifPickerSnapshot());
expect(model.view).toBe('default');
expect(model.isShowingFeatured).toBe(true);
expect(model.initialFeaturedLoading).toBe(false);
expect(model.shouldShowNoResults).toBe(false);
});
it('shows the initial spinner only while the first featured request has no cached content', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.featuredRequested',
requestId: createGifPickerRequestId(1),
hasContent: false,
});
expect(selectGifPickerModel(snapshot).initialFeaturedLoading).toBe(true);
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.featuredSucceeded',
requestId: createGifPickerRequestId(1),
hasContent: true,
});
const model = selectGifPickerModel(snapshot);
expect(model.initialFeaturedLoading).toBe(false);
expect(model.isShowingFeatured).toBe(true);
});
it('keeps featured content visible while a new search is pending', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.featuredSucceeded',
requestId: createGifPickerRequestId(1),
hasContent: true,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'cats',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(2),
term: 'cats',
showLoadingSkeleton: false,
});
const model = selectGifPickerModel(snapshot);
expect(model.searchTerm).toBe('cats');
expect(model.pendingSearchTerm).toBe('cats');
expect(model.isResultsLoading).toBe(true);
expect(model.isShowingFeatured).toBe(true);
expect(model.isShowingResults).toBe(false);
});
it('keeps old search results visible until the newer query succeeds', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'cats',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(1),
term: 'cats',
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchSucceeded',
requestId: createGifPickerRequestId(1),
term: 'cats',
resultCount: 20,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'dogs',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(2),
term: 'dogs',
showLoadingSkeleton: false,
});
const model = selectGifPickerModel(snapshot);
expect(model.searchTerm).toBe('dogs');
expect(model.committedSearchTerm).toBe('cats');
expect(model.isResultsLoading).toBe(true);
expect(model.isShowingResults).toBe(true);
expect(model.shouldShowNoResults).toBe(false);
});
it('ignores stale search completions after the term changes', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'cats',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(1),
term: 'cats',
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'dogs',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchSucceeded',
requestId: createGifPickerRequestId(1),
term: 'cats',
resultCount: 20,
});
const model = selectGifPickerModel(snapshot);
expect(model.searchTerm).toBe('dogs');
expect(model.committedSearchTerm).toBe('');
expect(model.isResultsLoading).toBe(true);
expect(model.isShowingFeatured).toBe(true);
});
it('shows no-results only after an authoritative empty result succeeds', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'zzzzzz',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(1),
term: 'zzzzzz',
showLoadingSkeleton: false,
});
expect(selectGifPickerModel(snapshot).shouldShowNoResults).toBe(false);
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchSucceeded',
requestId: createGifPickerRequestId(1),
term: 'zzzzzz',
resultCount: 0,
});
const model = selectGifPickerModel(snapshot);
expect(model.isShowingResults).toBe(true);
expect(model.shouldShowNoResults).toBe(true);
expect(model.shouldShowError).toBe(false);
});
it('keeps old results visible when a newer search fails', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'cats',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(1),
term: 'cats',
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchSucceeded',
requestId: createGifPickerRequestId(1),
term: 'cats',
resultCount: 20,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'dogs',
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(2),
term: 'dogs',
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchFailed',
requestId: createGifPickerRequestId(2),
term: 'dogs',
});
const model = selectGifPickerModel(snapshot);
expect(model.isShowingResults).toBe(true);
expect(model.committedSearchTerm).toBe('cats');
expect(model.shouldShowNoResults).toBe(false);
expect(model.shouldShowError).toBe(false);
});
it('shows an error state when trending fails without any retained results', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.trendingRequested',
requestId: createGifPickerRequestId(1),
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.trendingFailed',
requestId: createGifPickerRequestId(1),
});
const model = selectGifPickerModel(snapshot);
expect(model.isLoading).toBe(false);
expect(model.shouldShowError).toBe(true);
expect(model.errorKind).toBe('trending');
});
it('can replace the current surface with a loading skeleton immediately for uncached trending results', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.trendingRequested',
requestId: createGifPickerRequestId(1),
showLoadingSkeleton: true,
});
const model = selectGifPickerModel(snapshot);
expect(model.isResultsLoading).toBe(true);
expect(model.shouldShowLoadingSkeleton).toBe(true);
expect(model.shouldShowNoResults).toBe(false);
expect(model.shouldShowError).toBe(false);
});
it('keeps featured content visible until a delayed search skeleton event matches the active request', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.featuredSucceeded',
requestId: createGifPickerRequestId(1),
hasContent: true,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'cats',
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchRequested',
requestId: createGifPickerRequestId(2),
term: 'cats',
showLoadingSkeleton: false,
});
expect(selectGifPickerModel(snapshot).shouldShowLoadingSkeleton).toBe(false);
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.loadingSkeletonElapsed',
requestId: createGifPickerRequestId(99),
kind: 'search',
term: 'cats',
});
expect(selectGifPickerModel(snapshot).shouldShowLoadingSkeleton).toBe(false);
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.loadingSkeletonElapsed',
requestId: createGifPickerRequestId(2),
kind: 'search',
term: 'cats',
});
const model = selectGifPickerModel(snapshot);
expect(model.isShowingFeatured).toBe(true);
expect(model.shouldShowLoadingSkeleton).toBe(true);
});
it('shows the skeleton immediately when typing from a cleared non-search results surface', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.trendingRequested',
requestId: createGifPickerRequestId(1),
showLoadingSkeleton: false,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.trendingSucceeded',
requestId: createGifPickerRequestId(1),
resultCount: 20,
});
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.searchTermChanged',
term: 'dogs',
showLoadingSkeleton: true,
});
const model = selectGifPickerModel(snapshot);
expect(model.view).toBe('default');
expect(model.resultKind).toBe('trending');
expect(model.isResultsLoading).toBe(true);
expect(model.shouldShowLoadingSkeleton).toBe(true);
});
it('opens cached category results without showing a loading skeleton', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {
type: 'gifPicker.categoryCacheOpened',
term: 'cats',
resultCount: 12,
});
const model = selectGifPickerModel(snapshot);
expect(model.searchTerm).toBe('cats');
expect(model.committedSearchTerm).toBe('cats');
expect(model.isShowingResults).toBe(true);
expect(model.isResultsLoading).toBe(false);
expect(model.shouldShowLoadingSkeleton).toBe(false);
});
it('moves to favorites as an explicit non-fetching surface', () => {
let snapshot = createGifPickerSnapshot();
snapshot = transitionGifPickerSnapshot(snapshot, {type: 'gifPicker.favoritesOpened'});
const model = selectGifPickerModel(snapshot);
expect(model.view).toBe('favorites');
expect(model.isShowingFavorites).toBe(true);
expect(model.isLoading).toBe(false);
});
});
@@ -1,102 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type MobileTextareaButtonSignals,
transitionMobileTextareaButtonState,
} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import {describe, expect, it} from 'vitest';
function signals(overrides: Partial<MobileTextareaButtonSignals> = {}): MobileTextareaButtonSignals {
return {
disabled: false,
canRecordVoice: true,
value: '',
isSlowmodeActive: false,
isOverCharacterLimit: false,
isEditingMessage: false,
hasContent: false,
hasAttachments: false,
hasPendingSticker: false,
...overrides,
};
}
function state(overrides: Partial<MobileTextareaButtonSignals> = {}) {
return transitionMobileTextareaButtonState(signals(overrides));
}
describe('MobileTextareaButtonStateMachine', () => {
it('shows the voice button for an empty normal composer', () => {
expect(state()).toMatchObject({
mode: 'voice',
visibleButton: 'voice',
sendButton: {disabled: true},
voiceButton: {disabled: false},
hasSubmissionContent: false,
});
});
it('shows an enabled send button as soon as raw visible textarea text exists', () => {
expect(state({value: 'hello', hasContent: false})).toMatchObject({
mode: 'sendReady',
visibleButton: 'send',
sendButton: {disabled: false},
hasTypedText: true,
hasTextContent: true,
hasSubmissionContent: true,
});
});
it('keeps the voice button for whitespace and invisible-only textarea content', () => {
expect(state({value: ' \n\t\u200b'})).toMatchObject({
mode: 'voice',
visibleButton: 'voice',
hasTypedText: false,
hasSubmissionContent: false,
});
});
it('shows send for attachment and sticker payloads without text', () => {
expect(state({hasAttachments: true})).toMatchObject({
mode: 'sendReady',
visibleButton: 'send',
sendButton: {disabled: false},
hasSubmissionContent: true,
});
expect(state({hasPendingSticker: true})).toMatchObject({
mode: 'sendReady',
visibleButton: 'send',
sendButton: {disabled: false},
hasSubmissionContent: true,
});
});
it('shows send while blocked by slowmode or character limits', () => {
expect(state({value: 'hello', isSlowmodeActive: true})).toMatchObject({
mode: 'sendBlocked',
visibleButton: 'send',
sendButton: {disabled: true},
});
expect(state({value: 'hello', isOverCharacterLimit: true})).toMatchObject({
mode: 'sendBlocked',
visibleButton: 'send',
sendButton: {disabled: true},
});
});
it('keeps edit mode on send even when the edited content is empty', () => {
expect(state({isEditingMessage: true})).toMatchObject({
mode: 'sendReady',
visibleButton: 'send',
sendButton: {disabled: false},
});
});
it('falls back to a disabled send button when voice recording is unavailable and the composer is empty', () => {
expect(state({canRecordVoice: false})).toMatchObject({
mode: 'sendBlocked',
visibleButton: 'send',
sendButton: {disabled: true},
});
});
});
@@ -1,107 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {act, useRef} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const {canFocusTextareaMock} = vi.hoisted(() => ({canFocusTextareaMock: vi.fn(() => true)}));
vi.mock('@app/features/platform/utils/InputFocusManager', () => ({
canFocusTextarea: canFocusTextareaMock,
}));
const {useChannelComposerDraftFocusRestore} = await import(
'@app/features/channel/components/useChannelComposerDraftFocusRestore'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
let focusSpy: ReturnType<typeof vi.fn>;
interface HarnessProps {
initialDraft: string;
textareaInputDisabled?: boolean;
inlineEditActive?: boolean;
}
function Harness({initialDraft, textareaInputDisabled = false, inlineEditActive = false}: HarnessProps) {
const handleRef = useRef<ComposerHandle | null>(null);
handleRef.current = {focus: focusSpy} as unknown as ComposerHandle;
const editableRef = useRef<HTMLDivElement | null>(null);
useChannelComposerDraftFocusRestore({
handleRef,
editableRef,
initialDraft,
textareaInputDisabled,
inlineEditActive,
});
return (
<div
ref={editableRef}
contentEditable
suppressContentEditableWarning
data-flx="channel.use-channel-composer-draft-focus-restore-test.harness.div"
/>
);
}
function render(props: HarnessProps): void {
act(() => {
root.render(<Harness data-flx="channel.use-channel-composer-draft-focus-restore-test.harness" {...props} />);
});
}
beforeEach(() => {
focusSpy = vi.fn();
canFocusTextareaMock.mockReturnValue(true);
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
canFocusTextareaMock.mockReset();
});
describe('useChannelComposerDraftFocusRestore', () => {
test('focuses the composer when the channel is entered with a pending draft', () => {
render({initialDraft: 'half written'});
expect(focusSpy).toHaveBeenCalledTimes(1);
});
test('leaves focus alone when there is no pending draft', () => {
render({initialDraft: ''});
expect(focusSpy).not.toHaveBeenCalled();
});
test('yields to an active inline message edit', () => {
render({initialDraft: 'half written', inlineEditActive: true});
expect(focusSpy).not.toHaveBeenCalled();
});
test('does nothing when composer input is disabled', () => {
render({initialDraft: 'half written', textareaInputDisabled: true});
expect(focusSpy).not.toHaveBeenCalled();
});
test('respects the shared focus guard that blocks mobile, modals and popouts', () => {
canFocusTextareaMock.mockReturnValue(false);
render({initialDraft: 'half written'});
expect(focusSpy).not.toHaveBeenCalled();
});
test('restores focus only on the channel mount, not on every re-render', () => {
render({initialDraft: 'half written'});
render({initialDraft: 'half written'});
render({initialDraft: 'half written more'});
expect(focusSpy).toHaveBeenCalledTimes(1);
});
});
@@ -1,129 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type TrackedRecipientSnapshot,
type TrackedRecipientUser,
trackedRecipientNameKey,
} from '@app/features/channel/state/ChannelDisplayNameTracking';
import {autorun, observable, runInAction} from 'mobx';
import {describe, expect, it} from 'vitest';
const snapshots = (entries: Record<string, ReadonlyArray<string>>): ReadonlyMap<string, TrackedRecipientSnapshot> =>
new Map(Object.entries(entries).map(([id, recipientIds]) => [id, {recipientIds}]));
const userTable = (count: number): Record<string, TrackedRecipientUser> => {
const users: Record<string, TrackedRecipientUser> = {};
for (let i = 0; i < count; i++) {
users[`u${i}`] = {username: `user${i}`, globalName: null};
}
return users;
};
/** Wraps a user table so we can count exactly which keys get read. */
const countingUsers = (
users: Record<string, TrackedRecipientUser | undefined>,
): {proxy: Record<string, TrackedRecipientUser | undefined>; reads: Array<string>} => {
const reads: Array<string> = [];
const proxy = new Proxy(users, {
get(target, key, receiver) {
if (typeof key === 'string') reads.push(key);
return Reflect.get(target, key, receiver);
},
});
return {proxy, reads};
};
describe('trackedRecipientNameKey', () => {
it('reads only the recipients of tracked group DMs, not the whole user cache', () => {
const {proxy, reads} = countingUsers(userTable(10_000));
trackedRecipientNameKey(snapshots({c1: ['u1', 'u2'], c2: ['u3']}), proxy);
// The cost must be a function of tracked recipients, never of cache size.
expect(reads).toEqual(['u1', 'u2', 'u3']);
});
it('cost is independent of how large the user cache grows', () => {
const small = countingUsers(userTable(10));
const huge = countingUsers(userTable(100_000));
const tracked = snapshots({c1: ['u1', 'u2']});
trackedRecipientNameKey(tracked, small.proxy);
trackedRecipientNameKey(tracked, huge.proxy);
expect(small.reads.length).toBe(2);
expect(huge.reads.length).toBe(2);
});
it('changes when a tracked recipient is renamed', () => {
const users = userTable(3);
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u1 = {username: 'renamed', globalName: null};
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
});
it('does not change when an untracked user is renamed', () => {
const users = userTable(3);
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u2 = {username: 'unrelated-change', globalName: 'also changed'};
expect(trackedRecipientNameKey(tracked, users)).toBe(before);
});
it('distinguishes a missing recipient from one that arrives later', () => {
const users: Record<string, TrackedRecipientUser | undefined> = {};
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u1 = {username: 'arrived', globalName: null};
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
});
it('is not confused by usernames containing the separator characters', () => {
const tracked = snapshots({c1: ['a', 'b']});
const collidingLeft: Record<string, TrackedRecipientUser> = {
a: {username: 'x", "y', globalName: null},
b: {username: '', globalName: null},
};
const collidingRight: Record<string, TrackedRecipientUser> = {
a: {username: '', globalName: null},
b: {username: 'x", "y', globalName: null},
};
expect(trackedRecipientNameKey(tracked, collidingLeft)).not.toBe(trackedRecipientNameKey(tracked, collidingRight));
});
it('drives a MobX reaction on tracked renames but stays quiet on untracked ones', () => {
const users = observable.object<Record<string, TrackedRecipientUser | undefined>>({
u1: {username: 'one', globalName: null},
u2: {username: 'two', globalName: null},
});
const tracked = snapshots({c1: ['u1']});
let runs = 0;
const dispose = autorun(() => {
trackedRecipientNameKey(tracked, users);
runs++;
});
expect(runs).toBe(1);
// An untracked user changing must not wake the reaction.
runInAction(() => {
users.u2 = {username: 'two-renamed', globalName: null};
});
expect(runs).toBe(1);
// A tracked user changing must.
runInAction(() => {
users.u1 = {username: 'one-renamed', globalName: null};
});
expect(runs).toBe(2);
dispose();
});
});
@@ -1,272 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {autorun, runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
interface FakeChannel {
readonly id: string;
readonly type: number;
readonly recipientIds: ReadonlyArray<string>;
}
type ChannelFrecencyStore = typeof import('@app/features/channel/state/ChannelFrecency')['default'];
interface SyncedFieldFake {
seed: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}> | null;
toMessage: (() => {usage: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}>}) | null;
}
const fakes = await vi.hoisted(async () => {
const {observable} = await import('mobx');
return {
navigation: observable({guildId: null as string | null, channelId: null as string | null}),
voice: observable({
guildId: null as string | null,
channelId: null as string | null,
localDisconnectReason: null as string | null,
}),
guildIds: new Set<string>(),
channels: new Map<string, FakeChannel>(),
synced: {seed: null, toMessage: null} as SyncedFieldFake,
};
});
vi.mock('@app/features/user/state/SyncedField', () => ({
makeSyncedField: async (
store: object,
config: {
toMessage: (store: never) => {usage: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}>};
applyMessage: (store: never, message: {usage: Record<string, unknown>}) => void;
},
) => {
fakes.synced.toMessage = () => config.toMessage(store as never);
if (fakes.synced.seed !== null) {
config.applyMessage(store as never, {usage: fakes.synced.seed});
}
},
}));
vi.mock('@app/features/navigation/state/Navigation', () => ({default: fakes.navigation}));
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: fakes.voice}));
vi.mock('@app/features/guild/state/Guilds', () => ({
default: {getGuild: (guildId: string) => (fakes.guildIds.has(guildId) ? {id: guildId} : undefined)},
}));
vi.mock('@app/features/channel/state/Channels', () => ({
default: {
getChannel: (channelId: string) => fakes.channels.get(channelId),
getPrivateChannels: () => Array.from(fakes.channels.values()),
},
}));
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;
const T0 = Date.UTC(2026, 6, 15, 12);
const GUILD_ID = '1400000000000000100';
const OTHER_GUILD_ID = '1400000000000000200';
const TEXT_CHANNEL_ID = '1400000000000000101';
const OTHER_TEXT_CHANNEL_ID = '1400000000000000102';
const VOICE_CHANNEL_ID = '1400000000000000103';
const OTHER_VOICE_CHANNEL_ID = '1400000000000000104';
const OTHER_GUILD_VOICE_CHANNEL_ID = '1400000000000000201';
const DM_CHANNEL_ID = '1400000000000000301';
const FRIEND_ID = '1400000000000000401';
async function startChannelFrecency(
synced?: ReadonlyArray<readonly [string, {totalUses: number; recentUses: ReadonlyArray<number>}]>,
): Promise<ChannelFrecencyStore> {
vi.resetModules();
fakes.synced.seed =
synced === undefined
? null
: Object.fromEntries(
synced.map(([key, entry]) => [
key,
{totalUses: entry.totalUses, recentUsesMs: entry.recentUses.map((use) => BigInt(use))},
]),
);
const {default: ChannelFrecency} = await import('@app/features/channel/state/ChannelFrecency');
await vi.advanceTimersByTimeAsync(0);
return ChannelFrecency;
}
function navigate(guildId: string | null, channelId: string | null): void {
runInAction(() => {
fakes.navigation.guildId = guildId;
fakes.navigation.channelId = channelId;
});
}
function setVoiceChannel(guildId: string | null, channelId: string | null): void {
runInAction(() => {
fakes.voice.localDisconnectReason = channelId == null ? 'user' : null;
fakes.voice.guildId = guildId;
fakes.voice.channelId = channelId;
});
}
function moveVoiceChannel(guildId: string | null, channelId: string): void {
runInAction(() => {
fakes.voice.localDisconnectReason = 'channelMove';
fakes.voice.guildId = null;
fakes.voice.channelId = null;
});
setVoiceChannel(guildId, channelId);
}
function totalUses(store: ChannelFrecencyStore, key: string): number {
return store.usageHistory.get(key)?.totalUses ?? 0;
}
function keyFor(index: number): string {
return `1400000000000000${String(index).padStart(3, '0')}`;
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(T0);
navigate(null, null);
setVoiceChannel(null, null);
runInAction(() => {
fakes.voice.localDisconnectReason = null;
});
fakes.guildIds.clear();
fakes.channels.clear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('ChannelFrecency tracking', () => {
it('counts the landing channel and its guild once when tracking starts', async () => {
navigate(GUILD_ID, TEXT_CHANNEL_ID);
const store = await startChannelFrecency();
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(1);
expect(totalUses(store, GUILD_ID)).toBe(1);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(100);
});
it('counts a channel again after a page without a channel but not a guild the user stays in', async () => {
const store = await startChannelFrecency();
navigate(GUILD_ID, TEXT_CHANNEL_ID);
navigate(GUILD_ID, OTHER_TEXT_CHANNEL_ID);
navigate(GUILD_ID, TEXT_CHANNEL_ID);
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(2);
expect(totalUses(store, GUILD_ID)).toBe(1);
navigate(null, null);
navigate(GUILD_ID, TEXT_CHANNEL_ID);
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(3);
expect(totalUses(store, GUILD_ID)).toBe(2);
});
it('treats direct messages as guildless and never stores ids that are not snowflakes', async () => {
const store = await startChannelFrecency();
navigate(GUILD_ID, TEXT_CHANNEL_ID);
navigate('@me', DM_CHANNEL_ID);
navigate(GUILD_ID, 'settings');
navigate(GUILD_ID, TEXT_CHANNEL_ID);
expect([...store.usageHistory.keys()]).toEqual([TEXT_CHANNEL_ID, GUILD_ID, DM_CHANNEL_ID]);
expect(totalUses(store, TEXT_CHANNEL_ID)).toBe(2);
expect(totalUses(store, GUILD_ID)).toBe(2);
expect(totalUses(store, DM_CHANNEL_ID)).toBe(1);
});
it('counts voice joins through the same last selection as navigation', async () => {
const store = await startChannelFrecency();
navigate(GUILD_ID, VOICE_CHANNEL_ID);
setVoiceChannel(GUILD_ID, VOICE_CHANNEL_ID);
expect(totalUses(store, VOICE_CHANNEL_ID)).toBe(1);
navigate(GUILD_ID, TEXT_CHANNEL_ID);
setVoiceChannel(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
navigate(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
expect(totalUses(store, OTHER_VOICE_CHANNEL_ID)).toBe(1);
setVoiceChannel(null, null);
setVoiceChannel(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
expect(totalUses(store, OTHER_VOICE_CHANNEL_ID)).toBe(2);
setVoiceChannel(OTHER_GUILD_ID, OTHER_GUILD_VOICE_CHANNEL_ID);
expect(totalUses(store, OTHER_GUILD_VOICE_CHANNEL_ID)).toBe(1);
expect(totalUses(store, OTHER_GUILD_ID)).toBe(1);
expect(totalUses(store, GUILD_ID)).toBe(2);
});
it('counts a move between voice channels in one guild as a single guild use', async () => {
const store = await startChannelFrecency();
setVoiceChannel(GUILD_ID, VOICE_CHANNEL_ID);
moveVoiceChannel(GUILD_ID, OTHER_VOICE_CHANNEL_ID);
expect(totalUses(store, VOICE_CHANNEL_ID)).toBe(1);
expect(totalUses(store, OTHER_VOICE_CHANNEL_ID)).toBe(1);
expect(totalUses(store, GUILD_ID)).toBe(1);
});
it('keeps at most 100 keys and drops the one used longest ago', async () => {
const store = await startChannelFrecency();
for (let index = 0; index <= 100; index++) {
store.track(keyFor(index), T0 + index);
}
expect(store.usageHistory.size).toBe(100);
expect(store.usageHistory.has(keyFor(0))).toBe(false);
expect(store.usageHistory.has(keyFor(100))).toBe(true);
});
});
describe('ChannelFrecency account sync', () => {
it('re-derives persisted frecency against the current time on start', async () => {
const store = await startChannelFrecency([
[TEXT_CHANNEL_ID, {totalUses: 5, recentUses: [T0 - 8 * DAY_MS - HOUR_MS, T0 - 8 * DAY_MS]}],
[GUILD_ID, {totalUses: 1, recentUses: []}],
]);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(50);
expect(store.usageHistory.has(GUILD_ID)).toBe(false);
});
it('re-derives frecency again as a long session ages', async () => {
const store = await startChannelFrecency([[TEXT_CHANNEL_ID, {totalUses: 3, recentUses: [T0 - DAY_MS]}]]);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(210);
await vi.advanceTimersByTimeAsync(6 * DAY_MS);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(30);
});
it('hands the account sync only the uses, not the derived scores', async () => {
await startChannelFrecency();
navigate(GUILD_ID, TEXT_CHANNEL_ID);
await vi.advanceTimersByTimeAsync(1_000);
expect(fakes.synced.toMessage?.()).toEqual({
usage: {
[TEXT_CHANNEL_ID]: {totalUses: 1, recentUsesMs: [BigInt(T0)]},
[GUILD_ID]: {totalUses: 1, recentUsesMs: [BigInt(T0)]},
},
});
});
});
describe('ChannelFrecency ranking', () => {
it('ranks resolvable guild, channel and direct message keys by frecency', async () => {
fakes.guildIds.add(GUILD_ID);
fakes.channels.set(TEXT_CHANNEL_ID, {id: TEXT_CHANNEL_ID, type: ChannelTypes.GUILD_TEXT, recipientIds: []});
fakes.channels.set(DM_CHANNEL_ID, {id: DM_CHANNEL_ID, type: ChannelTypes.DM, recipientIds: [FRIEND_ID]});
const store = await startChannelFrecency();
store.track(GUILD_ID);
store.track(FRIEND_ID);
store.track(OTHER_TEXT_CHANNEL_ID);
store.track(TEXT_CHANNEL_ID);
store.track(TEXT_CHANNEL_ID);
expect(store.frequentIds).toEqual([TEXT_CHANNEL_ID, GUILD_ID, DM_CHANNEL_ID]);
expect(store.getScore(TEXT_CHANNEL_ID)).toBe(200);
expect(store.getScore(DM_CHANNEL_ID)).toBe(0);
});
it('lets observers follow the ranking as the user moves around', async () => {
fakes.guildIds.add(GUILD_ID);
fakes.channels.set(TEXT_CHANNEL_ID, {id: TEXT_CHANNEL_ID, type: ChannelTypes.GUILD_TEXT, recipientIds: []});
const store = await startChannelFrecency();
const seen: Array<ReadonlyArray<string>> = [];
const dispose = autorun(() => {
seen.push(store.frequentIds);
});
navigate(GUILD_ID, TEXT_CHANNEL_ID);
dispose();
expect(seen[0]).toEqual([]);
expect(seen.at(-1)).toEqual([TEXT_CHANNEL_ID, GUILD_ID]);
});
});
@@ -1,305 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ChannelFrecencyEntry,
type ChannelFrecencyHistory,
capChannelFrecencyHistory,
channelFrecencyDayDiff,
channelFrecencyHistoryFromWire,
channelFrecencyHistoryToWire,
channelFrecencyWeight,
computeChannelFrecency,
mergeChannelFrecencyWireUsage,
rankFrequentChannelIds,
restoreChannelFrecencyHistory,
trackChannelUse,
} from '@app/features/channel/utils/ChannelFrecencyCalculator';
import {afterEach, describe, expect, it, vi} from 'vitest';
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;
const NOW = Date.UTC(2026, 6, 15, 12);
const CHANNEL_ID = '1400000000000000001';
const STOCKHOLM_SUMMER_TIME_START = Date.UTC(2026, 2, 29, 1);
const STOCKHOLM_SUMMER_TIME_END = Date.UTC(2026, 9, 25, 1);
function useStockholmClock(): void {
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(function (this: Date) {
const time = this.getTime();
return time >= STOCKHOLM_SUMMER_TIME_START && time < STOCKHOLM_SUMMER_TIME_END ? -120 : -60;
});
}
function computedEntry(frecency: number): ChannelFrecencyEntry {
return {totalUses: 1, recentUses: [NOW], frecency, score: frecency};
}
function keyFor(index: number): string {
return `1400000000000000${String(index).padStart(3, '0')}`;
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('channelFrecencyWeight', () => {
it.each([
[0, 100],
[1, 70],
[2, 50],
[3, 50],
[4, 30],
[6, 30],
[7, 10],
[365, 10],
[-1, 1],
[-3, 1],
])('weights an age of %i days as %i', (dayDiff, weight) => {
expect(channelFrecencyWeight(dayDiff)).toBe(weight);
});
});
describe('channelFrecencyDayDiff', () => {
it('counts whole elapsed days and truncates toward zero', () => {
expect(channelFrecencyDayDiff(NOW, NOW - 23 * HOUR_MS)).toBe(0);
expect(channelFrecencyDayDiff(NOW, NOW - 25 * HOUR_MS)).toBe(1);
expect(channelFrecencyDayDiff(NOW, NOW - 6 * DAY_MS - 23 * HOUR_MS)).toBe(6);
expect(channelFrecencyDayDiff(NOW, NOW + 12 * HOUR_MS)).toBe(0);
expect(channelFrecencyDayDiff(NOW, NOW + 36 * HOUR_MS)).toBe(-1);
});
it('counts noon to noon across the spring clock change as one day although only 23 hours pass', () => {
useStockholmClock();
const saturdayNoon = Date.UTC(2026, 2, 28, 11);
const sundayNoon = Date.UTC(2026, 2, 29, 10);
expect(sundayNoon - saturdayNoon).toBe(23 * HOUR_MS);
expect(channelFrecencyDayDiff(sundayNoon, saturdayNoon)).toBe(1);
expect(channelFrecencyWeight(channelFrecencyDayDiff(sundayNoon, saturdayNoon))).toBe(70);
});
it('keeps 24.5 hours across the autumn clock change inside the same day', () => {
useStockholmClock();
const saturdayNoon = Date.UTC(2026, 9, 24, 10);
const sundayHalfPastEleven = Date.UTC(2026, 9, 25, 10, 30);
expect(sundayHalfPastEleven - saturdayNoon).toBe(24.5 * HOUR_MS);
expect(channelFrecencyDayDiff(sundayHalfPastEleven, saturdayNoon)).toBe(0);
});
});
describe('trackChannelUse', () => {
it('starts a new key with one uncomputed use', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, CHANNEL_ID, NOW);
expect(history.get(CHANNEL_ID)).toEqual({totalUses: 1, recentUses: [NOW], frecency: -1, score: 0});
});
it('keeps arrival order for untimed uses and sorts when a timestamp is given', () => {
vi.useFakeTimers();
const history: ChannelFrecencyHistory = new Map();
vi.setSystemTime(NOW);
trackChannelUse(history, CHANNEL_ID);
vi.setSystemTime(NOW - HOUR_MS);
trackChannelUse(history, CHANNEL_ID);
expect(history.get(CHANNEL_ID)?.recentUses).toEqual([NOW, NOW - HOUR_MS]);
trackChannelUse(history, CHANNEL_ID, NOW - 2 * HOUR_MS);
expect(history.get(CHANNEL_ID)?.recentUses).toEqual([NOW - 2 * HOUR_MS, NOW - HOUR_MS, NOW]);
expect(history.get(CHANNEL_ID)?.totalUses).toBe(3);
});
});
describe('computeChannelFrecency', () => {
it('scores 12 uses whose last 10 are 4 today, 3 yesterday and 3 five days ago as 840', () => {
const history: ChannelFrecencyHistory = new Map();
const uses = [
NOW - 20 * DAY_MS,
NOW - 20 * DAY_MS,
NOW - 5 * DAY_MS - HOUR_MS,
NOW - 5 * DAY_MS - 2 * HOUR_MS,
NOW - 5 * DAY_MS - 3 * HOUR_MS,
NOW - DAY_MS - HOUR_MS,
NOW - DAY_MS - 2 * HOUR_MS,
NOW - DAY_MS - 3 * HOUR_MS,
NOW - HOUR_MS,
NOW - 2 * HOUR_MS,
NOW - 3 * HOUR_MS,
NOW - 4 * HOUR_MS,
];
for (const use of uses) {
trackChannelUse(history, CHANNEL_ID, use);
}
computeChannelFrecency(history, NOW);
expect(history.get(CHANNEL_ID)).toEqual({
totalUses: 12,
recentUses: [
NOW - 5 * DAY_MS - 3 * HOUR_MS,
NOW - 5 * DAY_MS - 2 * HOUR_MS,
NOW - 5 * DAY_MS - HOUR_MS,
NOW - DAY_MS - 3 * HOUR_MS,
NOW - DAY_MS - 2 * HOUR_MS,
NOW - DAY_MS - HOUR_MS,
NOW - 4 * HOUR_MS,
NOW - 3 * HOUR_MS,
NOW - 2 * HOUR_MS,
NOW - HOUR_MS,
],
frecency: 840,
score: 700,
});
});
it('leaves computed entries alone until the key is used again', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, CHANNEL_ID, NOW - 8 * DAY_MS);
computeChannelFrecency(history, NOW - 8 * DAY_MS);
const computed = history.get(CHANNEL_ID);
expect(computed?.frecency).toBe(100);
computeChannelFrecency(history, NOW);
expect(history.get(CHANNEL_ID)).toBe(computed);
trackChannelUse(history, CHANNEL_ID, NOW);
computeChannelFrecency(history, NOW);
expect(history.get(CHANNEL_ID)).toEqual({
totalUses: 2,
recentUses: [NOW - 8 * DAY_MS, NOW],
frecency: 110,
score: 110,
});
});
});
describe('capChannelFrecencyHistory', () => {
it('drops the key whose last use is oldest once there are more than 100 keys', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, keyFor(0), NOW - 10 * DAY_MS);
trackChannelUse(history, keyFor(0), NOW + 500);
for (let index = 1; index <= 100; index++) {
trackChannelUse(history, keyFor(index), NOW + index);
}
capChannelFrecencyHistory(history);
expect(history.size).toBe(100);
expect(history.has(keyFor(0))).toBe(true);
expect(history.has(keyFor(1))).toBe(false);
});
it('drops the earlier key when two share the oldest last use', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, keyFor(0), NOW);
trackChannelUse(history, keyFor(1), NOW);
for (let index = 2; index <= 100; index++) {
trackChannelUse(history, keyFor(index), NOW + index);
}
capChannelFrecencyHistory(history);
expect(history.has(keyFor(0))).toBe(false);
expect(history.has(keyFor(1))).toBe(true);
});
});
describe('rankFrequentChannelIds', () => {
it('sorts resolved ids by frecency, keeps key order on ties and drops unresolved keys', () => {
const history: ChannelFrecencyHistory = new Map([
['a', computedEntry(100)],
['b', computedEntry(300)],
['c', computedEntry(100)],
['user', computedEntry(200)],
['gone', computedEntry(999)],
]);
const resolved = new Map([
['a', 'a'],
['b', 'b'],
['c', 'c'],
['user', 'dm-channel'],
]);
expect(rankFrequentChannelIds(history, (key) => resolved.get(key) ?? null)).toEqual(['b', 'dm-channel', 'a', 'c']);
});
it('returns at most 100 ids', () => {
const history: ChannelFrecencyHistory = new Map();
for (let index = 0; index < 150; index++) {
history.set(keyFor(index), computedEntry(100));
}
const ranked = rankFrequentChannelIds(history, (key) => key);
expect(ranked).toHaveLength(100);
expect(ranked[99]).toBe(keyFor(99));
});
});
describe('restoreChannelFrecencyHistory', () => {
it('re-derives every persisted entry against now and drops unusable ones', () => {
const persisted: ReadonlyArray<readonly [unknown, unknown]> = [
['a', {totalUses: 5, recentUses: [NOW - 8 * DAY_MS - HOUR_MS, NOW - 8 * DAY_MS], frecency: 999, score: 999}],
['b', {totalUses: 2, recentUses: [], frecency: 50, score: 50}],
['c', {totalUses: 'many', recentUses: [NOW]}],
['d', {totalUses: 1, recentUses: [NOW - HOUR_MS, 'soon', -5, null], frecency: 1, score: 1}],
[42, {totalUses: 1, recentUses: [NOW]}],
['e', null],
];
const restored = restoreChannelFrecencyHistory(persisted, NOW);
expect([...restored.keys()]).toEqual(['a', 'd']);
expect(restored.get('a')).toEqual({
totalUses: 5,
recentUses: [NOW - 8 * DAY_MS - HOUR_MS, NOW - 8 * DAY_MS],
frecency: 50,
score: 20,
});
expect(restored.get('d')).toEqual({totalUses: 1, recentUses: [NOW - HOUR_MS], frecency: 100, score: 100});
});
});
describe('channel frecency account sync', () => {
it('carries only the uses over the wire and re-derives on the way back', () => {
const history: ChannelFrecencyHistory = new Map();
trackChannelUse(history, 'a', NOW - HOUR_MS);
trackChannelUse(history, 'a', NOW);
computeChannelFrecency(history, NOW);
const usage = channelFrecencyHistoryToWire(history);
expect(usage).toEqual({a: {totalUses: 2, recentUsesMs: [BigInt(NOW - HOUR_MS), BigInt(NOW)]}});
const restored = channelFrecencyHistoryFromWire(usage, NOW);
expect(restored.get('a')).toEqual({
totalUses: 2,
recentUses: [NOW - HOUR_MS, NOW],
frecency: 200,
score: 200,
});
});
it('drops wire entries whose uses have all aged out of scoring', () => {
const restored = channelFrecencyHistoryFromWire({a: {totalUses: 3, recentUsesMs: []}}, NOW);
expect(restored.size).toBe(0);
});
it('merges two devices by keeping the higher count and the newest ten uses', () => {
const merged = mergeChannelFrecencyWireUsage(
{
a: {totalUses: 9, recentUsesMs: [BigInt(NOW - 2 * DAY_MS), BigInt(NOW - HOUR_MS)]},
b: {totalUses: 1, recentUsesMs: [BigInt(NOW - HOUR_MS)]},
},
{
a: {totalUses: 4, recentUsesMs: [BigInt(NOW - HOUR_MS), BigInt(NOW)]},
c: {totalUses: 2, recentUsesMs: [BigInt(NOW)]},
},
NOW,
);
expect(merged.a).toEqual({
totalUses: 9,
recentUsesMs: [BigInt(NOW - 2 * DAY_MS), BigInt(NOW - HOUR_MS), BigInt(NOW)],
});
expect(Object.keys(merged).sort()).toEqual(['a', 'b', 'c']);
});
it('keeps at most ten uses and a hundred keys when merging', () => {
const uses = Array.from({length: 8}, (_value, index) => BigInt(NOW - index * HOUR_MS));
const local: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}> = {};
const incoming: Record<string, {totalUses: number; recentUsesMs: Array<bigint>}> = {};
for (let index = 0; index < 60; index++) {
local[keyFor(index)] = {totalUses: 1, recentUsesMs: [BigInt(NOW - index * HOUR_MS)]};
}
for (let index = 60; index < 130; index++) {
incoming[keyFor(index)] = {totalUses: 1, recentUsesMs: [BigInt(NOW - index * HOUR_MS)]};
}
local.a = {totalUses: 20, recentUsesMs: uses};
incoming.a = {totalUses: 20, recentUsesMs: [BigInt(NOW + HOUR_MS), BigInt(NOW + 2 * HOUR_MS), BigInt(NOW)]};
const merged = mergeChannelFrecencyWireUsage(local, incoming, NOW);
expect(merged.a.recentUsesMs).toHaveLength(10);
expect(Object.keys(merged)).toHaveLength(100);
});
});
@@ -1,21 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
CHANNEL_SEARCH_INDEXING_INITIAL_POLL_INTERVAL_MS,
CHANNEL_SEARCH_INDEXING_MAX_POLL_INTERVAL_MS,
getChannelSearchIndexingPollInterval,
} from '@app/features/channel/utils/ChannelSearchPolling';
import {describe, expect, it} from 'vitest';
describe('getChannelSearchIndexingPollInterval', () => {
it('starts with a short retry interval for newly indexing channels', () => {
expect(getChannelSearchIndexingPollInterval(0)).toBe(CHANNEL_SEARCH_INDEXING_INITIAL_POLL_INTERVAL_MS);
});
it('backs off without exceeding the maximum interval', () => {
expect(getChannelSearchIndexingPollInterval(1)).toBeGreaterThan(CHANNEL_SEARCH_INDEXING_INITIAL_POLL_INTERVAL_MS);
expect(getChannelSearchIndexingPollInterval(100)).toBe(CHANNEL_SEARCH_INDEXING_MAX_POLL_INTERVAL_MS);
});
it('treats negative poll counts as the first poll', () => {
expect(getChannelSearchIndexingPollInterval(-1)).toBe(CHANNEL_SEARCH_INDEXING_INITIAL_POLL_INTERVAL_MS);
});
});