mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
test(app): remove useless frontend tests (#2819)
This commit is contained in:
1 parent
ed9528834d
commit
deac653a9e
341 files changed
-62758
No files matched your search
@@ -1,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);
|
||||
});
|
||||
});
|
||||
-64
@@ -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')}]);
|
||||
});
|
||||
});
|
||||
-38
@@ -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);
|
||||
});
|
||||
});
|
||||
-102
@@ -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},
|
||||
});
|
||||
});
|
||||
});
|
||||
-107
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user