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

This commit is contained in:
Hampus authored and GitHub committed 2026-09-17 15:05:36 +02:00
1 parent ed9528834d
commit deac653a9e
341 files changed
-62758

No files matched your search

@@ -1,189 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildForwardDefaultDestinations,
type ForwardDestination,
type ForwardDestinationRow,
type ForwardRowIdentity,
filterForwardSearchRows,
resolveForwardOrigin,
} from '@app/features/app/components/dialogs/shared/ForwardDefaultDestinations';
import type {ForwardResultType} from '@app/features/app/components/dialogs/shared/ForwardDestinationQuery';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {describe, expect, it} from 'vitest';
const DM_RECIPIENTS: ReadonlyMap<string, string> = new Map([['dm-1', 'user-1']]);
const KNOWN_USERS: ReadonlySet<string> = new Set(['user-1', 'user-9']);
const INVALID_IDS: ReadonlySet<string> = new Set(['text-blocked']);
function channelType(channelId: string): ForwardResultType | null {
if (channelId.startsWith('text-')) return 'text_channel';
if (channelId.startsWith('voice-')) return 'voice_channel';
if (channelId.startsWith('gdm-')) return 'group_dm';
return null;
}
function resolveChannel(channelId: string): ForwardRowIdentity | null {
const recipientId = DM_RECIPIENTS.get(channelId);
if (recipientId !== undefined) return {id: recipientId, type: 'user'};
const type = channelType(channelId);
return type == null ? null : {id: channelId, type};
}
function resolveDestination(destination: ForwardDestination): ForwardRowIdentity | null {
if (destination.type === 'channel') return resolveChannel(destination.id);
return KNOWN_USERS.has(destination.id) ? {id: destination.id, type: 'user'} : null;
}
function isValid(row: ForwardRowIdentity): boolean {
return !INVALID_IDS.has(row.id);
}
interface DefaultsOverrides {
readonly frequentIds?: ReadonlyArray<string>;
readonly history?: ReadonlyArray<string>;
readonly mode?: ForwardResultType | null;
readonly origin?: ForwardDestination | null;
readonly pinned?: ReadonlyArray<ForwardDestination>;
readonly selected?: ReadonlyArray<ForwardDestination>;
}
function defaults(overrides: DefaultsOverrides): ReadonlyArray<string> {
const rows = buildForwardDefaultDestinations({
frequentIds: [],
history: [],
isValid,
mode: null,
origin: null,
pinned: [],
resolveChannel,
resolveDestination,
selected: [],
...overrides,
});
return rows.map((row) => `${row.type}:${row.id}`);
}
function channelIds(prefix: string, count: number): ReadonlyArray<string> {
return Array.from({length: count}, (_, index) => `${prefix}-${index}`);
}
describe('buildForwardDefaultDestinations', () => {
it('lists pinned destinations, then history, then frecency, keeping the first copy of each', () => {
expect(
defaults({
frequentIds: ['voice-1', 'guild-1', 'text-1', 'gdm-1'],
history: ['text-1', 'text-2', 'dm-1'],
pinned: [
{id: 'text-2', type: 'channel'},
{id: 'user-9', type: 'user'},
],
}),
).toEqual([
'text_channel:text-2',
'user:user-9',
'text_channel:text-1',
'user:user-1',
'voice_channel:voice-1',
'group_dm:gdm-1',
]);
});
it('drops unknown users, unresolvable ids and invalid rows', () => {
expect(
defaults({
frequentIds: ['guild-1', 'text-blocked', 'text-1'],
pinned: [{id: 'user-unknown', type: 'user'}],
}),
).toEqual(['text_channel:text-1']);
});
it('hides the origin channel unless it is selected', () => {
const origin: ForwardDestination = {id: 'text-1', type: 'channel'};
expect(defaults({history: ['text-1', 'text-2'], origin})).toEqual(['text_channel:text-2']);
expect(defaults({history: ['text-1', 'text-2'], origin, selected: [origin]})).toEqual([
'text_channel:text-1',
'text_channel:text-2',
]);
});
it('hides the recipient of a DM origin', () => {
const origin = resolveForwardOrigin('dm-1', {recipientIds: ['user-1'], type: ChannelTypes.DM});
expect(origin).toEqual({id: 'user-1', type: 'user'});
expect(defaults({history: ['dm-1', 'text-1'], origin})).toEqual(['text_channel:text-1']);
});
it('caps the list at 15 rows', () => {
expect(defaults({frequentIds: channelIds('text', 20)})).toEqual(
channelIds('text', 15).map((id) => `text_channel:${id}`),
);
});
it('filters to the mode without a cap', () => {
const frequentIds = [...channelIds('text', 20), 'dm-1', 'voice-1'];
expect(defaults({frequentIds, mode: 'text_channel'})).toEqual(
channelIds('text', 20).map((id) => `text_channel:${id}`),
);
expect(defaults({frequentIds, mode: 'user'})).toEqual(['user:user-1']);
});
it('reads at most 8 history entries and 100 frecency entries', () => {
const history = channelIds('text-h', 10);
const frequentIds = channelIds('text-f', 120);
const rows = defaults({frequentIds, history, mode: 'text_channel'});
expect(rows).toHaveLength(108);
expect(rows).not.toContain('text_channel:text-h-8');
expect(rows).not.toContain('text_channel:text-f-100');
});
it('maps each row to the destination the selection stores', () => {
const rows = buildForwardDefaultDestinations({
frequentIds: ['dm-1', 'gdm-1', 'voice-1'],
history: [],
isValid,
mode: null,
origin: null,
pinned: [],
resolveChannel,
resolveDestination,
selected: [],
});
expect(rows.map((row) => row.destination)).toEqual([
{id: 'user-1', type: 'user'},
{id: 'gdm-1', type: 'channel'},
{id: 'voice-1', type: 'channel'},
]);
});
});
describe('resolveForwardOrigin', () => {
it('uses the channel for anything but a DM', () => {
expect(resolveForwardOrigin('text-1', {recipientIds: [], type: ChannelTypes.GUILD_TEXT})).toEqual({
id: 'text-1',
type: 'channel',
});
expect(resolveForwardOrigin('gdm-1', {recipientIds: ['user-1', 'user-9'], type: ChannelTypes.GROUP_DM})).toEqual({
id: 'gdm-1',
type: 'channel',
});
expect(resolveForwardOrigin('text-1', null)).toEqual({id: 'text-1', type: 'channel'});
});
});
describe('filterForwardSearchRows', () => {
it('drops invalid rows and duplicate ids without a cap or origin exclusion', () => {
const results: ReadonlyArray<ForwardRowIdentity> = [
{id: 'user-1', type: 'user'},
{id: 'text-blocked', type: 'text_channel'},
{id: 'text-1', type: 'text_channel'},
{id: 'text-1', type: 'voice_channel'},
...channelIds('text-x', 30).map((id): ForwardRowIdentity => ({id, type: 'text_channel'})),
];
const rows: ReadonlyArray<ForwardDestinationRow> = filterForwardSearchRows(results, isValid);
expect(rows).toHaveLength(32);
expect(rows.slice(0, 2)).toEqual([
{destination: {id: 'user-1', type: 'user'}, id: 'user-1', type: 'user'},
{destination: {id: 'text-1', type: 'channel'}, id: 'text-1', type: 'text_channel'},
]);
});
});
@@ -1,47 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {parseForwardDestinationQuery} from '@app/features/app/components/dialogs/shared/ForwardDestinationQuery';
import {describe, expect, it} from 'vitest';
const ALL_TYPES = ['user', 'text_channel', 'voice_channel', 'group_dm'];
describe('parseForwardDestinationQuery', () => {
it('searches every destination type without a sigil', () => {
expect(parseForwardDestinationQuery('general')).toEqual({mode: null, query: 'general', resultTypes: ALL_TYPES});
expect(parseForwardDestinationQuery('')).toEqual({mode: null, query: '', resultTypes: ALL_TYPES});
});
it('narrows to one type for the @, # and ! sigils', () => {
expect(parseForwardDestinationQuery('@bob')).toEqual({mode: 'user', query: 'bob', resultTypes: ['user']});
expect(parseForwardDestinationQuery('#gen')).toEqual({
mode: 'text_channel',
query: 'gen',
resultTypes: ['text_channel'],
});
expect(parseForwardDestinationQuery('!lounge')).toEqual({
mode: 'voice_channel',
query: 'lounge',
resultTypes: ['voice_channel'],
});
});
it('keeps a bare sigil as a mode with an empty query', () => {
expect(parseForwardDestinationQuery('#')).toEqual({mode: 'text_channel', query: '', resultTypes: ['text_channel']});
});
it('strips the guild, game and global user sigils without narrowing', () => {
expect(parseForwardDestinationQuery('*guild')).toEqual({mode: null, query: 'guild', resultTypes: ALL_TYPES});
expect(parseForwardDestinationQuery('$game')).toEqual({mode: null, query: 'game', resultTypes: ALL_TYPES});
expect(parseForwardDestinationQuery('@@bob')).toEqual({mode: null, query: 'bob', resultTypes: ALL_TYPES});
});
it('strips only a leading @ but the first other sigil anywhere', () => {
expect(parseForwardDestinationQuery('a@b')).toEqual({mode: null, query: 'a@b', resultTypes: ALL_TYPES});
expect(parseForwardDestinationQuery('foo#bar#baz')).toEqual({
mode: null,
query: 'foobar#baz',
resultTypes: ALL_TYPES,
});
expect(parseForwardDestinationQuery('@#x')).toEqual({mode: 'user', query: '#x', resultTypes: ['user']});
});
});
@@ -1,326 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ForwardResultType} from '@app/features/app/components/dialogs/shared/ForwardDestinationQuery';
import {
buildForwardSearchBoosters,
type ForwardChannelCandidate,
type ForwardGroupDMCandidate,
type ForwardSearchBoosters,
type ForwardSearchResult,
type ForwardUserCandidate,
searchForwardDestinations,
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSearch';
import {loadUnicodeConfusables} from '@app/features/search/utils/SearchTextMatching';
import {beforeAll, describe, expect, it} from 'vitest';
const ALL_TYPES: ReadonlyArray<ForwardResultType> = ['user', 'text_channel', 'voice_channel', 'group_dm'];
const NO_BOOSTERS = buildForwardSearchBoosters({dmUserIds: [], frequent: [], friendIds: []});
let confusables: ReadonlyMap<string, string> = new Map();
beforeAll(async () => {
confusables = await loadUnicodeConfusables();
});
interface SearchOverrides {
readonly blacklist?: ReadonlySet<string>;
readonly boosters?: ForwardSearchBoosters;
readonly channels?: ReadonlyArray<ForwardChannelCandidate>;
readonly confusables?: ReadonlyMap<string, string>;
readonly groupDMs?: ReadonlyArray<ForwardGroupDMCandidate>;
readonly limit?: number;
readonly resultTypes?: ReadonlyArray<ForwardResultType>;
readonly users?: ReadonlyArray<ForwardUserCandidate>;
}
function search(query: string, overrides: SearchOverrides = {}): ReadonlyArray<ForwardSearchResult> {
return searchForwardDestinations({
blacklist: new Set(),
boosters: NO_BOOSTERS,
channels: [],
confusables,
groupDMs: [],
limit: 20,
resultTypes: ALL_TYPES,
users: [],
...overrides,
query,
});
}
function channel(id: string, name: string, overrides: Partial<ForwardChannelCandidate> = {}): ForwardChannelCandidate {
return {canAccess: true, guildName: null, hasFrecency: false, id, kind: 'text', name, parentName: null, ...overrides};
}
function user(id: string, username: string, overrides: Partial<ForwardUserCandidate> = {}): ForwardUserCandidate {
return {friendNickname: null, globalName: null, id, nicknames: [], username, ...overrides};
}
function groupDM(id: string, name: string, memberFields: ReadonlyArray<string> = []): ForwardGroupDMCandidate {
return {id, memberFields, name};
}
function tier(results: ReadonlyArray<ForwardSearchResult>): number | null {
return results.length === 0 ? null : results[0].score / 1000;
}
function channelTier(
query: string,
candidate: ForwardChannelCandidate,
resultTypes: ReadonlyArray<ForwardResultType> = ['text_channel'],
): number | null {
return tier(search(query, {channels: [candidate], resultTypes}));
}
function userTier(query: string, candidate: ForwardUserCandidate, overrides: SearchOverrides = {}): number | null {
return tier(search(query, {resultTypes: ['user'], users: [candidate], ...overrides}));
}
function summarize(results: ReadonlyArray<ForwardSearchResult>): ReadonlyArray<string> {
return results.map((result) => `${result.type}:${result.id}:${result.score}`);
}
describe('forward destination channel search', () => {
const general = channel('general', 'general');
it('scores exact, prefix, contains and fuzzy name matches', () => {
expect(channelTier('general', general)).toBe(10);
expect(channelTier('gen', general)).toBe(7);
expect(channelTier('eral', general)).toBe(5);
expect(channelTier('gnrl', general)).toBe(1);
});
it('adds the frecency bonus up to the tier cap', () => {
expect(channelTier('gen', channel('general', 'general', {hasFrecency: true}))).toBe(10);
expect(channelTier('eral', channel('general', 'general', {hasFrecency: true}))).toBe(7);
});
it('matches a full phrase across a dash', () => {
expect(channelTier('general chat', channel('general-chat', 'general-chat'))).toBe(7);
});
it('rejects words in the wrong order', () => {
expect(channelTier('chat general', channel('general-chat', 'general-chat'))).toBeNull();
});
it('lets leftover words match the guild name at half weight, capped at 6', () => {
expect(channelTier('general foo', channel('general', 'general', {guildName: 'Foo Server'}))).toBe(6);
});
it('rejects a query that leaves more than one word unmatched', () => {
expect(channelTier('gen my server', channel('general', 'general', {guildName: 'My Server'}))).toBeNull();
});
it('needs the channel name itself to match', () => {
expect(channelTier('foo', channel('general', 'general', {guildName: 'foo'}))).toBeNull();
});
it('caps a query with a stray space at 6', () => {
const inGuild = channel('general', 'general', {guildName: 'Foo Server'});
expect(channelTier('general ', inGuild)).toBe(6);
expect(channelTier(' general', inGuild)).toBe(6);
});
it('penalises voice channels found by the text search only', () => {
const lounge = channel('lounge', 'lounge', {kind: 'voice'});
expect(search('lounge', {channels: [lounge], resultTypes: ['text_channel']})).toEqual([
{comparator: 'lounge', id: 'lounge', score: 9000, type: 'voice_channel'},
]);
expect(channelTier('lounge', lounge, ['voice_channel'])).toBe(10);
});
it('keeps the penalised text-search copy of a voice channel when both searches find it', () => {
const lounge = channel('lounge', 'lounge', {kind: 'voice'});
const boosters = buildForwardSearchBoosters({
dmUserIds: [],
frequent: [{id: 'lounge', kind: 'voice', score: 10}],
friendIds: [],
});
expect(summarize(search('lounge', {boosters, channels: [lounge], resultTypes: ['voice_channel']}))).toEqual([
'voice_channel:lounge:20000',
]);
expect(summarize(search('lounge', {boosters, channels: [lounge]}))).toEqual(['voice_channel:lounge:9000']);
});
it('does not fold diacritics in channel names', () => {
expect(channelTier('cafe', channel('cafe', 'café'))).toBeNull();
});
it('skips channels the reader cannot access', () => {
expect(channelTier('general', channel('general', 'general', {canAccess: false}))).toBeNull();
});
it('keeps candidate order between channels with equal scores', () => {
const channels = [channel('z', 'general-z'), channel('a', 'general-a'), channel('m', 'general')];
expect(search('general', {channels, resultTypes: ['text_channel']}).map((result) => result.id)).toEqual([
'm',
'z',
'a',
]);
});
it('truncates each category to the limit before merging', () => {
const channels = Array.from({length: 25}, (_, index) => channel(`c${index}`, `general-${index}`));
const results = search('general', {channels, limit: 20});
expect(results.map((result) => result.id)).toEqual(channels.slice(0, 20).map((candidate) => candidate.id));
});
});
describe('forward destination user search', () => {
it('ranks a case-insensitive prefix at 10 and a substring at 5', () => {
const bobby = user('1', 'bobby');
expect(userTier('Bob', bobby)).toBe(10);
expect(userTier('ob', bobby)).toBe(5);
});
it('folds diacritics in the field but not in the query', () => {
expect(userTier('jose', user('1', 'José'))).toBe(1);
expect(userTier('josé', user('1', 'jose99'))).toBeNull();
});
it('matches look-alike characters once the confusables table is loaded', () => {
expect(userTier('b0b', user('1', 'bob'))).toBe(1);
expect(userTier('b0b', user('1', 'bob'), {confusables: new Map()})).toBeNull();
});
it('does not trim the query', () => {
expect(userTier('john ', user('1', 'john'))).toBeNull();
});
it('matches the exact user id', () => {
expect(userTier('123456789012345678', user('123456789012345678', 'ada'))).toBe(10);
});
it('matches community nicknames, friend nicknames and global names', () => {
expect(userTier('mods', user('1', 'ada', {nicknames: ['Mods Team']}))).toBe(10);
expect(userTier('count', user('1', 'ada', {friendNickname: 'Countess'}))).toBe(10);
expect(userTier('love', user('1', 'ada', {globalName: 'Ada Lovelace'}))).toBe(5);
});
it('reports the first field that produced the best score', () => {
const [strongest] = search('love', {
users: [user('1', 'ada', {globalName: 'Lovelace', nicknames: ['Ada Lovelace']})],
});
expect(strongest.comparator).toBe('Lovelace');
const [first] = search('ada', {users: [user('1', 'ada_x', {globalName: 'Ada'})]});
expect(first.comparator).toBe('ada_x');
});
it('skips blacklisted users', () => {
expect(userTier('ada', user('me', 'ada'), {blacklist: new Set(['me'])})).toBeNull();
});
it('multiplies by the user booster', () => {
const boosters = buildForwardSearchBoosters({dmUserIds: [], frequent: [], friendIds: ['1']});
expect(userTier('bob', user('1', 'bob'), {boosters})).toBeCloseTo(12);
});
it('breaks ties alphabetically by the matched name', () => {
const users = [user('1', 'Bob_b'), user('2', 'bob_a'), user('3', 'bob_c')];
expect(search('bob', {users}).map((result) => result.id)).toEqual(['2', '1', '3']);
});
});
describe('forward destination group DM search', () => {
it('folds diacritics and look-alikes in the name', () => {
expect(tier(search('cafe', {groupDMs: [groupDM('g', 'Café Crew')]}))).toBe(7);
expect(tier(search('rnovie', {groupDMs: [groupDM('g', 'movie night')]}))).toBe(7);
});
it('caps member matches at 5', () => {
expect(tier(search('alice', {groupDMs: [groupDM('g', 'Weekend', ['alice', 'Alice A.'])]}))).toBe(5);
});
it('prefers a stronger name match over a member match', () => {
expect(tier(search('weekend', {groupDMs: [groupDM('g', 'Weekend', ['weekend_fan'])]}))).toBe(10);
});
});
describe('forward destination search merge', () => {
it('returns nothing for a blank query', () => {
expect(search(' ', {channels: [channel('general', 'general')]})).toEqual([]);
});
it('only searches the requested result types', () => {
const results = search('general', {
channels: [channel('general', 'general')],
groupDMs: [groupDM('g', 'general')],
resultTypes: ['user'],
users: [user('1', 'general')],
});
expect(summarize(results)).toEqual(['user:1:10000']);
});
it('orders equal scores as users, group DMs, text channels, then voice channels', () => {
const results = search('general', {
channels: [channel('v', 'general', {kind: 'voice'}), channel('t', 'general')],
groupDMs: [groupDM('g', 'general')],
users: [user('1', 'general')],
});
expect(summarize(results)).toEqual([
'user:1:10000',
'group_dm:g:10000',
'text_channel:t:10000',
'voice_channel:v:9000',
]);
});
it('sorts higher scores first across types', () => {
const results = search('gen', {
channels: [channel('t', 'general', {hasFrecency: true})],
users: [user('1', 'xgen')],
});
expect(summarize(results)).toEqual(['text_channel:t:10000', 'user:1:5000']);
});
});
describe('buildForwardSearchBoosters', () => {
it('scales frecency against the highest score and adds friend and open DM boosts', () => {
const boosters = buildForwardSearchBoosters({
dmUserIds: ['user-1', 'user-3'],
frequent: [
{id: 'guild-1', kind: 'other', score: 200},
{id: 'dm-1', kind: 'dm', recipientId: 'user-1', score: 100},
{id: 'text-1', kind: 'text', score: 50},
{id: 'voice-1', kind: 'voice', score: 150},
{id: 'gdm-1', kind: 'group_dm', score: 20},
],
friendIds: ['user-1', 'user-2'],
});
expect(boosters.users.get('user-1')).toBeCloseTo(1.8);
expect(boosters.users.get('user-2')).toBeCloseTo(1.2);
expect(boosters.users.get('user-3')).toBeCloseTo(1.1);
expect(boosters.users.has('dm-1')).toBe(false);
expect(boosters.textChannels.get('text-1')).toBe(1.25);
expect(boosters.voiceChannels.get('voice-1')).toBe(1.75);
expect(boosters.groupDMs.get('gdm-1')).toBeCloseTo(1.1);
expect(boosters.textChannels.has('guild-1')).toBe(false);
});
it('falls back to a boost of 1 when every frecency score is zero', () => {
const boosters = buildForwardSearchBoosters({
dmUserIds: [],
frequent: [{id: 'text-1', kind: 'text', score: 0}],
friendIds: [],
});
expect(boosters.textChannels.get('text-1')).toBe(1);
});
it('boosts text channels only in the text search and voice channels only in the voice search', () => {
const boosters = buildForwardSearchBoosters({
dmUserIds: [],
frequent: [
{id: 't', kind: 'text', score: 10},
{id: 'v', kind: 'voice', score: 10},
],
friendIds: [],
});
const channels = [channel('t', 'general'), channel('v', 'general', {kind: 'voice'})];
expect(summarize(search('general', {boosters, channels, resultTypes: ['text_channel']}))).toEqual([
'text_channel:t:20000',
'voice_channel:v:9000',
]);
expect(summarize(search('general', {boosters, channels, resultTypes: ['voice_channel']}))).toEqual([
'voice_channel:v:20000',
]);
});
});
@@ -1,134 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildForwardDefaultDestinations,
type ForwardDestination,
type ForwardRowIdentity,
} from '@app/features/app/components/dialogs/shared/ForwardDefaultDestinations';
import {
type ForwardPinnedDestinations,
forwardDestinationKey,
MAX_FORWARD_DESTINATIONS,
pinForwardDestinations,
toggleForwardDestination,
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSelection';
import {describe, expect, it} from 'vitest';
const UNPINNED: ForwardPinnedDestinations = {engineQuery: null, pinned: []};
const HISTORY: ReadonlyArray<string> = ['text-1', 'text-2', 'text-3'];
function channel(id: string): ForwardDestination {
return {id, type: 'channel'};
}
function user(id: string): ForwardDestination {
return {id, type: 'user'};
}
function resolveChannel(channelId: string): ForwardRowIdentity {
return {id: channelId, type: 'text_channel'};
}
function resolveDestination(destination: ForwardDestination): ForwardRowIdentity {
if (destination.type === 'user') return {id: destination.id, type: 'user'};
return resolveChannel(destination.id);
}
interface PickerState {
readonly engineQuery: string;
readonly pinned: ForwardPinnedDestinations;
readonly selected: ReadonlyArray<ForwardDestination>;
}
interface PickerRender {
readonly rows: ReadonlyArray<string>;
readonly state: PickerState;
}
function renderPicker(state: PickerState): PickerRender {
const pinned = pinForwardDestinations(state.pinned, state.engineQuery, state.selected);
const rows = buildForwardDefaultDestinations({
frequentIds: [],
history: HISTORY,
isValid: () => true,
mode: null,
origin: null,
pinned: pinned.pinned,
resolveChannel,
resolveDestination,
selected: state.selected,
});
return {rows: rows.map((row) => row.id), state: {...state, pinned}};
}
function togglePicker(state: PickerState, destination: ForwardDestination): PickerState {
const selected = toggleForwardDestination(state.selected, destination);
const engineQuery = selected.length > state.selected.length ? '' : state.engineQuery;
return {...state, engineQuery, selected};
}
describe('toggleForwardDestination', () => {
it('puts the newest pick first and removes a pick in place', () => {
const picked = toggleForwardDestination(toggleForwardDestination([], channel('text-1')), user('user-1'));
expect(picked).toEqual([user('user-1'), channel('text-1')]);
expect(toggleForwardDestination(picked, channel('text-1'))).toEqual([user('user-1')]);
});
it('tells a user apart from a channel with the same id', () => {
expect(forwardDestinationKey(user('1'))).toBe('user:1');
expect(forwardDestinationKey(channel('1'))).toBe('channel:1');
expect(toggleForwardDestination([channel('1')], user('1'))).toEqual([user('1'), channel('1')]);
});
it('refuses a new pick at the cap but still removes one', () => {
const full = Array.from({length: MAX_FORWARD_DESTINATIONS}, (_, index) => channel(`text-${index}`));
expect(toggleForwardDestination(full, channel('text-extra'))).toBe(full);
expect(toggleForwardDestination(full, channel('text-0'))).toHaveLength(MAX_FORWARD_DESTINATIONS - 1);
});
});
describe('pinForwardDestinations', () => {
it('pins the live selection on the first render', () => {
const selected = [channel('text-1')];
expect(pinForwardDestinations(UNPINNED, '', selected)).toEqual({engineQuery: '', pinned: selected});
});
it('keeps the snapshot while the engine query stays the same', () => {
const snapshot = pinForwardDestinations(UNPINNED, '', []);
expect(pinForwardDestinations(snapshot, '', [channel('text-1')])).toBe(snapshot);
});
it('pins the live selection again when the engine query changes', () => {
const snapshot = pinForwardDestinations(UNPINNED, '', []);
const selected = [user('user-1'), channel('text-1')];
expect(pinForwardDestinations(snapshot, 'abc', selected)).toEqual({engineQuery: 'abc', pinned: selected});
});
});
describe('forward default list while selecting', () => {
it('keeps rows in place until a search clears, then pins the selection newest first', () => {
const opened = renderPicker({engineQuery: '', pinned: UNPINNED, selected: []});
expect(opened.rows).toEqual(['text-1', 'text-2', 'text-3']);
const pickedWithoutQuery = renderPicker(togglePicker(opened.state, channel('text-3')));
expect(pickedWithoutQuery.state.selected).toEqual([channel('text-3')]);
expect(pickedWithoutQuery.rows).toEqual(['text-1', 'text-2', 'text-3']);
const searching = renderPicker({...pickedWithoutQuery.state, engineQuery: 'ada'});
const pickedFromSearch = renderPicker(togglePicker(searching.state, user('user-9')));
expect(pickedFromSearch.state.engineQuery).toBe('');
expect(pickedFromSearch.rows).toEqual(['user-9', 'text-3', 'text-1', 'text-2']);
const removed = renderPicker(togglePicker(pickedFromSearch.state, channel('text-3')));
expect(removed.state.selected).toEqual([user('user-9')]);
expect(removed.rows).toEqual(['user-9', 'text-3', 'text-1', 'text-2']);
});
it('re-pins the live selection when a typed query is cleared by hand', () => {
const opened = renderPicker({engineQuery: '', pinned: UNPINNED, selected: []});
const picked = renderPicker(togglePicker(opened.state, channel('text-2')));
const searching = renderPicker({...picked.state, engineQuery: 'x'});
const cleared = renderPicker({...searching.state, engineQuery: ''});
expect(cleared.rows).toEqual(['text-2', 'text-1', 'text-3']);
});
});
@@ -1,47 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {selectFrozenUnreadChannels} from '@app/features/app/components/floating/UnreadChannelOrder';
import {describe, expect, it} from 'vitest';
function ids(channels: ReadonlyArray<{id: string}>): Array<string> {
return channels.map((channel) => channel.id);
}
describe('selectFrozenUnreadChannels', () => {
it('keeps channels in their frozen position while the list is open', () => {
const order = new Map([
['a', 0],
['b', 1],
['c', 2],
]);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]))).toEqual(['a', 'b', 'c']);
});
it('hides a channel that becomes unread after the order was frozen', () => {
const order = new Map([['a', 0]]);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]))).toEqual(['a']);
});
it('never grows the list when every live channel is new to the order', () => {
const order = new Map([['a', 0]]);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]))).toEqual([]);
});
it('yields nothing for an empty order', () => {
expect(ids(selectFrozenUnreadChannels(new Map<string, number>(), [{id: 'x'}]))).toEqual([]);
});
it('does not resurrect channels that are no longer unread', () => {
const order = new Map([
['a', 0],
['gone', 1],
]);
expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}]))).toEqual(['a']);
});
it('does not mutate the order it was given', () => {
const order = new Map([['a', 0]]);
selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]);
expect([...order.keys()]).toEqual(['a']);
});
});
@@ -1,31 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
UNREAD_PREVIEW_MESSAGE_LIMIT,
} from '@app/features/app/components/floating/UnreadPreviewBudget';
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {describe, expect, it} from 'vitest';
const buildBatch = (limit: number) => ({
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
channel_id: String(1000000000000000000n + BigInt(index)),
limit,
})),
});
describe('unread preview fetch budget', () => {
it('keeps the anchored window inside the bulk fetch schema', () => {
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
});
it('keeps the unanchored window inside the bulk fetch schema', () => {
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
});
it('rejects a batch one channel wider than the client sends', () => {
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
});
});
@@ -1,98 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
GUILD_BANNER_HEADER_GLASS_DISTANCE,
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
isGuildBannerHeaderFrosted,
resolveGuildBannerCollapseProgress,
resolveGuildBannerGeometry,
resolveGuildBannerHeaderGlassOpacity,
} from '@app/features/app/components/layout/GuildBannerCollapse';
import {describe, expect, it} from 'vitest';
describe('resolveGuildBannerGeometry', () => {
it('sizes the banner from the sidebar width and the banner aspect ratio', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 320,
viewportHeight: 1000,
headerHeight: 56,
aspectRatio: 16 / 9,
});
expect(geometry.bannerHeight).toBeCloseTo(180);
expect(geometry.collapseDistance).toBeCloseTo(124);
expect(geometry.heightCapped).toBe(false);
});
it('caps the banner at a fraction of the viewport height', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 480,
viewportHeight: 500,
headerHeight: 56,
aspectRatio: 1,
});
expect(geometry.bannerHeight).toBeCloseTo(150);
expect(geometry.collapseDistance).toBeCloseTo(94);
expect(geometry.heightCapped).toBe(true);
});
it('never collapses below the header height', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 200,
viewportHeight: 120,
headerHeight: 56,
aspectRatio: 21 / 9,
});
expect(geometry.bannerHeight).toBe(56);
expect(geometry.collapseDistance).toBe(0);
});
it('reports no banner height before the header is measured', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 0,
viewportHeight: 0,
headerHeight: 56,
aspectRatio: 16 / 9,
});
expect(geometry.bannerHeight).toBe(56);
expect(geometry.collapseDistance).toBe(0);
});
});
describe('resolveGuildBannerCollapseProgress', () => {
it('tracks scroll position across the collapse distance', () => {
expect(resolveGuildBannerCollapseProgress(0, 80)).toBe(0);
expect(resolveGuildBannerCollapseProgress(40, 80)).toBe(0.5);
expect(resolveGuildBannerCollapseProgress(80, 80)).toBe(1);
});
it('clamps overscroll and bounce past both ends', () => {
expect(resolveGuildBannerCollapseProgress(-30, 80)).toBe(0);
expect(resolveGuildBannerCollapseProgress(4000, 80)).toBe(1);
});
it('stays expanded when there is nothing to collapse', () => {
expect(resolveGuildBannerCollapseProgress(200, 0)).toBe(0);
});
});
describe('resolveGuildBannerHeaderGlassOpacity', () => {
it('ramps in over a fixed scroll distance regardless of banner height', () => {
expect(resolveGuildBannerHeaderGlassOpacity(0)).toBe(0);
expect(resolveGuildBannerHeaderGlassOpacity(GUILD_BANNER_HEADER_GLASS_DISTANCE / 2)).toBeCloseTo(
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY / 2,
);
expect(resolveGuildBannerHeaderGlassOpacity(GUILD_BANNER_HEADER_GLASS_DISTANCE)).toBeCloseTo(
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
);
});
it('never exceeds the opaque header background', () => {
expect(resolveGuildBannerHeaderGlassOpacity(5000)).toBe(GUILD_BANNER_HEADER_GLASS_MAX_OPACITY);
});
it('frosts the header once the glass is fully ramped in', () => {
expect(isGuildBannerHeaderFrosted(0)).toBe(false);
expect(isGuildBannerHeaderFrosted(GUILD_BANNER_HEADER_GLASS_DISTANCE - 1)).toBe(false);
expect(isGuildBannerHeaderFrosted(GUILD_BANNER_HEADER_GLASS_DISTANCE)).toBe(true);
});
});
@@ -1,224 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildHeader} from '@app/features/app/components/layout/GuildHeader';
import styles from '@app/features/app/components/layout/GuildHeader.module.css';
import {useGuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
import {Guild} from '@app/features/guild/models/Guild';
import {WINDOW_FOCUSED_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import type {Guild as WireGuild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {setupI18n} from '@lingui/core';
import {useMotionValue} from 'framer-motion';
import {runInAction} from 'mobx';
import {observer} from 'mobx-react-lite';
import {act, useLayoutEffect} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {get: vi.fn(), post: vi.fn()},
}));
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
vi.mock('@app/features/app/config/Config', () => ({
default: {
PUBLIC_BUILD_VERSION: 'test',
PUBLIC_RELEASE_CHANNEL: 'canary',
PUBLIC_BOOTSTRAP_API_ENDPOINT: 'https://example.invalid',
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: 'https://example.invalid',
},
}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown =>
typeof value === 'string'
? {id: value, message: value}
: {...(value as object), id: (value as {message: string}).message};
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
useLingui: () => ({i18n}),
}));
vi.mock('@app/features/guild/components/popouts/GuildHeaderPopout', () => ({GuildHeaderPopout: () => null}));
vi.mock('@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet', () => ({
GuildHeaderBottomSheet: () => null,
}));
vi.mock('@app/features/ui/action_menu/GuildContextMenu', () => ({GuildContextMenu: () => null}));
vi.mock('@app/features/app/hooks/useSkeletonLayoutMemoryCapture', () => ({
useSkeletonLayoutReport: () => {},
measureSkeletonTextWidthPx: () => 0,
}));
vi.mock('@app/features/messaging/utils/ImageCacheUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@app/features/messaging/utils/ImageCacheUtils')>()),
hasImage: () => true,
}));
vi.mock('@app/features/user/utils/AvatarUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@app/features/user/utils/AvatarUtils')>()),
getGuildBannerURL: ({id, banner}: {id: string; banner: string | null}, animated: boolean) =>
banner == null ? null : `https://media.test/banners/${id}/${banner}.${animated ? 'gif' : 'webp'}`,
}));
const i18n = setupI18n({locale: 'en-US', messages: {'en-US': {}}});
const DEFAULT_GIF_AUTOPLAY = UserSettings.gifAutoPlay;
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const HEADER_ROW_HEIGHT = 56;
const SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT = 135;
interface Frame {
readonly headerHeight: number;
readonly bannerHeight: number;
readonly listTop: number;
}
const layout = {sidebarWidth: 240};
const frames: Array<Frame> = [];
const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth');
const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
let container: HTMLDivElement;
let root: Root;
function createBannerGuild(): Guild {
return new Guild({
id: '1400000000000000000',
name: 'Banner Community',
icon: null,
banner: 'b4nn3r',
banner_width: 1600,
banner_height: 900,
features: [],
roles: [],
owner_id: '1400000000000000001',
} as unknown as WireGuild);
}
function requireElement(selector: string): HTMLElement {
const element = container.querySelector<HTMLElement>(selector);
if (element == null) {
throw new Error(`missing ${selector}`);
}
return element;
}
const Harness = observer(({guild}: {guild: Guild}) => {
const scrollY = useMotionValue(0);
const banner = useGuildBannerPresentation({guild, scrollY});
useLayoutEffect(() => {
const header = requireElement(`.${styles.headerContainer}`);
const clip = container.querySelector<HTMLElement>('[data-flx="app.guild-header.banner-clip"]');
frames.push({
headerHeight: header.offsetHeight,
bannerHeight: clip?.offsetHeight ?? 0,
listTop: header.offsetHeight + banner.collapseDistance,
});
});
return <GuildHeader guild={guild} banner={banner} />;
});
function mount(guild: Guild): void {
act(() => {
root.render(<Harness guild={guild} />);
});
}
function lastFrame(): Frame {
const frame = frames.at(-1);
if (frame === undefined) {
throw new Error('nothing rendered');
}
return frame;
}
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get() {
return layout.sidebarWidth;
},
});
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get(this: HTMLElement) {
const inlineHeight = Number.parseFloat(this.style.height);
return Number.isFinite(inlineHeight) ? inlineHeight : HEADER_ROW_HEIGHT;
},
});
});
afterAll(() => {
if (originalOffsetWidth) Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth);
if (originalOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight);
});
beforeEach(() => {
layout.sidebarWidth = 240;
frames.length = 0;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
document.documentElement.classList.remove(WINDOW_FOCUSED_CLASS);
vi.restoreAllMocks();
runInAction(() => {
UserSettings.gifAutoPlay = DEFAULT_GIF_AUTOPLAY;
});
});
describe('GuildHeader banner geometry', () => {
it('keeps the header at the row height with the list below the banner', () => {
mount(createBannerGuild());
expect(lastFrame()).toEqual({
headerHeight: HEADER_ROW_HEIGHT,
bannerHeight: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
listTop: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
});
});
it('shrinks the collapsing banner again when the sidebar narrows', () => {
mount(createBannerGuild());
layout.sidebarWidth = 160;
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(lastFrame()).toEqual({headerHeight: HEADER_ROW_HEIGHT, bannerHeight: 90, listTop: 90});
});
it('plays the animated banner on header hover when the banner is too short to collapse', async () => {
layout.sidebarWidth = 80;
runInAction(() => {
UserSettings.gifAutoPlay = false;
});
document.documentElement.classList.add(WINDOW_FOCUSED_CLASS);
mount(createBannerGuild());
expect(lastFrame()).toEqual({
headerHeight: HEADER_ROW_HEIGHT,
bannerHeight: HEADER_ROW_HEIGHT,
listTop: HEADER_ROW_HEIGHT,
});
const bannerImage = requireElement('[data-flx="app.guild-header.banner-image"]');
expect(bannerImage.style.backgroundImage).toContain('b4nn3r.webp');
const header = requireElement(`.${styles.headerContainer}`);
const matches = header.matches.bind(header);
vi.spyOn(header, 'matches').mockImplementation((selector: string) => selector === ':hover' || matches(selector));
await act(async () => {
header.dispatchEvent(new MouseEvent('mouseenter'));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(bannerImage.style.backgroundImage).toContain('b4nn3r.gif');
});
});
@@ -1,142 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createScrollIndicatorSnapshot,
getScrollIndicatorStateValue,
resolveActiveScrollIndicator,
resolveScrollIndicatorEdgeCandidates,
type ScrollIndicatorMeasurement,
type ScrollIndicatorTargetMeasurement,
selectActiveScrollIndicator,
transitionScrollIndicatorSnapshot,
} from '@app/features/app/components/layout/ScrollIndicatorStateMachine';
import {describe, expect, it} from 'vitest';
function target(
id: string,
top: number,
bottom: number,
severity: ScrollIndicatorTargetMeasurement['severity'] = 'unread',
order = 0,
): ScrollIndicatorTargetMeasurement {
return {id, top, bottom, severity, order};
}
function measurement(targets: Array<ScrollIndicatorTargetMeasurement>, scrollTop = 100): ScrollIndicatorMeasurement {
return {
scrollTop,
viewportHeight: 200,
targets,
};
}
describe('ScrollIndicatorStateMachine', () => {
it('hides when there are no offscreen unread targets', () => {
const snapshot = createScrollIndicatorSnapshot();
const next = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('visible', 120, 160)]),
});
expect(getScrollIndicatorStateValue(next)).toBe('hidden');
expect(selectActiveScrollIndicator(next)).toBeNull();
});
it('treats partially visible targets as visible, not floating-pill targets', () => {
const candidates = resolveScrollIndicatorEdgeCandidates(
measurement([target('partly-above', 90, 120), target('partly-below', 290, 320)]),
);
expect(candidates.topIndicator).toBeNull();
expect(candidates.bottomIndicator).toBeNull();
});
it('selects the nearest offscreen target at the same severity', () => {
const active = resolveActiveScrollIndicator(
measurement([
target('far-above', 0, 40, 'unread', 0),
target('near-above', 80, 90, 'unread', 1),
target('far-below', 500, 540, 'unread', 2),
]),
);
expect(active?.direction).toBe('top');
expect(active?.indicator.id).toBe('near-above');
expect(active?.indicator.distance).toBe(10);
});
it('prioritizes mentions over plain unread targets regardless of distance', () => {
const active = resolveActiveScrollIndicator(
measurement([target('near-unread', 80, 90, 'unread', 0), target('far-mention', 600, 640, 'mention', 1)]),
);
expect(active?.direction).toBe('bottom');
expect(active?.indicator.id).toBe('far-mention');
});
it('uses the latest scroll direction to break exact ties', () => {
const active = resolveActiveScrollIndicator({
...measurement([target('above', 80, 90, 'unread', 0), target('below', 310, 320, 'unread', 1)]),
preferredDirection: 'bottom',
});
expect(active?.direction).toBe('bottom');
expect(active?.indicator.id).toBe('below');
});
it('does not keep a stale active target after collapse or read-state changes remove it', () => {
let snapshot = createScrollIndicatorSnapshot();
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('unread-above', 80, 90)]),
});
expect(getScrollIndicatorStateValue(snapshot)).toBe('visible');
expect(selectActiveScrollIndicator(snapshot)?.indicator.id).toBe('unread-above');
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([]),
});
expect(getScrollIndicatorStateValue(snapshot)).toBe('hidden');
expect(selectActiveScrollIndicator(snapshot)).toBeNull();
});
it('switches from an expanded folder child marker to the collapsed folder marker', () => {
let snapshot = createScrollIndicatorSnapshot();
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('guild-inside-folder', 340, 380, 'mention')]),
});
expect(getScrollIndicatorStateValue(snapshot)).toBe('visible');
expect(selectActiveScrollIndicator(snapshot)?.indicator.id).toBe('guild-inside-folder');
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('folder-1', 340, 380, 'mention')]),
});
expect(getScrollIndicatorStateValue(snapshot)).toBe('visible');
expect(selectActiveScrollIndicator(snapshot)?.indicator.id).toBe('folder-1');
});
it('hides immediately when the previously offscreen target moves into the viewport', () => {
let snapshot = createScrollIndicatorSnapshot();
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('unread-above', 80, 90)]),
});
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('unread-above', 120, 160)]),
});
expect(getScrollIndicatorStateValue(snapshot)).toBe('hidden');
expect(selectActiveScrollIndicator(snapshot)).toBeNull();
});
it('resets when the scroll container is unavailable', () => {
let snapshot = createScrollIndicatorSnapshot();
snapshot = transitionScrollIndicatorSnapshot(snapshot, {
type: 'scrollIndicator.measured',
measurement: measurement([target('unread-below', 320, 340)]),
});
expect(getScrollIndicatorStateValue(snapshot)).toBe('visible');
snapshot = transitionScrollIndicatorSnapshot(snapshot, {type: 'scrollIndicator.reset'});
expect(getScrollIndicatorStateValue(snapshot)).toBe('hidden');
expect(selectActiveScrollIndicator(snapshot)).toBeNull();
});
});
@@ -1,88 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
selectUserAreaMicrophoneState,
type UserAreaMicrophoneSignals,
} from '@app/features/app/components/layout/UserAreaState';
import {describe, expect, it} from 'vitest';
const baseSignals: UserAreaMicrophoneSignals = {
effectiveAudioMuted: false,
effectiveAudioDeafened: false,
isGuildMuted: false,
isGuildDeafened: false,
isPermissionMuted: false,
muteReason: null,
isPushToTalkEffective: false,
isPushToTalkHeld: false,
isPushToMuteEffective: false,
isPushToMuteHeld: false,
};
describe('selectUserAreaMicrophoneState', () => {
it('follows normal effective mute state without transmit hold modes', () => {
expect(selectUserAreaMicrophoneState(baseSignals)).toEqual({
effectiveMuted: false,
muteToggleLocked: false,
});
expect(selectUserAreaMicrophoneState({...baseSignals, effectiveAudioMuted: true})).toMatchObject({
effectiveMuted: true,
});
});
it('shows push-to-talk released as muted', () => {
expect(selectUserAreaMicrophoneState({...baseSignals, isPushToTalkEffective: true})).toEqual({
effectiveMuted: true,
muteToggleLocked: true,
});
});
it('treats push-to-talk without an effective binding as disabled', () => {
expect(selectUserAreaMicrophoneState({...baseSignals, isPushToTalkEffective: false})).toEqual({
effectiveMuted: false,
muteToggleLocked: false,
});
});
it('shows push-to-talk held as unmuted even with persisted self mute state', () => {
expect(
selectUserAreaMicrophoneState({
...baseSignals,
effectiveAudioMuted: true,
muteReason: 'self',
isPushToTalkEffective: true,
isPushToTalkHeld: true,
}),
).toEqual({
effectiveMuted: false,
muteToggleLocked: true,
});
});
it('does not show push-to-talk held as unmuted through hard mute states', () => {
expect(
selectUserAreaMicrophoneState({
...baseSignals,
isPermissionMuted: true,
isPushToTalkEffective: true,
isPushToTalkHeld: true,
}),
).toEqual({
effectiveMuted: true,
muteToggleLocked: true,
});
});
it('shows push-to-mute held as muted while leaving the mute toggle available', () => {
expect(
selectUserAreaMicrophoneState({
...baseSignals,
isPushToMuteEffective: true,
isPushToMuteHeld: true,
}),
).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
});
});
@@ -1,409 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ChannelReorderRect,
type ChannelReorderTarget,
selectChannelReorderIntent,
} from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine';
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {
type ChannelMoveOperation,
createChannelMoveOperation,
} from '@app/features/app/components/layout/utils/ChannelMoveOperation';
import type {Channel} from '@app/features/channel/models/Channel';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {computeGuildChannelReorderPlan} from '@fluxer/schema/src/domains/channel/GuildChannelOrdering';
import {describe, expect, it} from 'vitest';
const rect: ChannelReorderRect = {
top: 100,
bottom: 132,
left: 0,
right: 240,
};
function point(y: number) {
return {
x: 120,
y,
};
}
function channel(id: string, type: number, position: number, parentId: string | null = null): Channel {
return {
id,
type,
position,
parentId,
} as Channel;
}
function channelsFixture(): Array<Channel> {
return [
channel('root-text', ChannelTypes.GUILD_TEXT, 10),
channel('category-a', ChannelTypes.GUILD_CATEGORY, 20),
channel('a-text-1', ChannelTypes.GUILD_TEXT, 30, 'category-a'),
channel('a-text-2', ChannelTypes.GUILD_TEXT, 40, 'category-a'),
channel('a-voice-1', ChannelTypes.GUILD_VOICE, 50, 'category-a'),
channel('a-voice-2', ChannelTypes.GUILD_VOICE, 60, 'category-a'),
channel('category-b', ChannelTypes.GUILD_CATEGORY, 70),
channel('b-text-1', ChannelTypes.GUILD_TEXT, 80, 'category-b'),
channel('b-link-1', ChannelTypes.GUILD_LINK, 90, 'category-b'),
channel('b-voice-1', ChannelTypes.GUILD_VOICE, 100, 'category-b'),
channel('root-voice', ChannelTypes.GUILD_VOICE, 110),
];
}
function requireChannel(channels: ReadonlyArray<Channel>, id: string): Channel {
const found = channels.find((candidate) => candidate.id === id);
if (!found) throw new Error(`Missing test channel ${id}`);
return found;
}
function dragItemFrom(channel: Channel): DragItem {
return {
type: channel.type === ChannelTypes.GUILD_CATEGORY ? DragItemType.CATEGORY : DragItemType.CHANNEL,
id: channel.id,
channelType: channel.type,
parentId: channel.parentId,
guildId: 'guild-1',
};
}
function targetFrom(channel: Channel): ChannelReorderTarget {
return {
id: channel.id,
channelType: channel.type,
parentId: channel.parentId,
guildId: 'guild-1',
};
}
function requireMachineDropResult(
channels: ReadonlyArray<Channel>,
sourceId: string,
targetId: string,
y: number,
): DropResult {
const source = requireChannel(channels, sourceId);
const target = requireChannel(channels, targetId);
const intent = selectChannelReorderIntent(dragItemFrom(source), targetFrom(target), point(y), rect);
if (!intent) throw new Error(`Expected test drop intent from ${sourceId} to ${targetId} at y=${y}`);
return intent.result;
}
function createOperation(
channels: ReadonlyArray<Channel>,
sourceId: string,
dropResult: DropResult,
): ChannelMoveOperation | null {
return createChannelMoveOperation({
channels,
dragItem: dragItemFrom(requireChannel(channels, sourceId)),
dropResult,
});
}
function applyOperation(channels: ReadonlyArray<Channel>, operation: ChannelMoveOperation) {
const plan = computeGuildChannelReorderPlan({
channels,
operation: {
channelId: operation.channelId,
parentId: operation.newParentId,
precedingSiblingId: operation.precedingSiblingId,
},
});
if (!plan.ok) throw new Error(`Unexpected invalid channel reorder plan: ${plan.code}`);
return plan.plan;
}
function finalIds(channels: ReadonlyArray<Channel>, operation: ChannelMoveOperation): Array<string> {
return applyOperation(channels, operation).finalChannels.map((ch) => ch.id);
}
function finalParent(
channels: ReadonlyArray<Channel>,
operation: ChannelMoveOperation,
channelId: string,
): string | null {
return applyOperation(channels, operation).desiredParentById.get(channelId) ?? null;
}
describe('channel reorder decision integration', () => {
it('turns a bottom-half category drop into a text channel move inside the category before voice siblings', () => {
const channels = channelsFixture();
const dropResult = requireMachineDropResult(channels, 'root-text', 'category-a', rect.bottom - 1);
expect(dropResult).toEqual({
targetId: 'category-a',
position: 'inside',
targetParentId: 'category-a',
});
const operation = createOperation(channels, 'root-text', dropResult);
expect(operation).toEqual({
channelId: 'root-text',
newParentId: 'category-a',
precedingSiblingId: 'a-text-2',
position: 2,
});
expect(finalParent(channels, operation!, 'root-text')).toBe('category-a');
expect(finalIds(channels, operation!)).toEqual([
'category-a',
'a-text-1',
'a-text-2',
'root-text',
'a-voice-1',
'a-voice-2',
'category-b',
'b-text-1',
'b-link-1',
'b-voice-1',
'root-voice',
]);
});
it('turns a bottom-half category drop into a voice channel move after existing voice siblings', () => {
const channels = channelsFixture();
const dropResult = requireMachineDropResult(channels, 'root-voice', 'category-a', rect.bottom - 1);
const operation = createOperation(channels, 'root-voice', dropResult);
expect(operation).toEqual({
channelId: 'root-voice',
newParentId: 'category-a',
precedingSiblingId: 'a-voice-2',
position: 4,
});
expect(finalParent(channels, operation!, 'root-voice')).toBe('category-a');
expect(finalIds(channels, operation!)).toEqual([
'root-text',
'category-a',
'a-text-1',
'a-text-2',
'a-voice-1',
'a-voice-2',
'root-voice',
'category-b',
'b-text-1',
'b-link-1',
'b-voice-1',
]);
});
it('preserves text-before-voice ordering when a voice channel is dropped before text siblings', () => {
const channels = channelsFixture();
const dropResult = requireMachineDropResult(channels, 'a-voice-1', 'a-text-1', rect.top + 1);
expect(dropResult).toEqual({
targetId: 'a-text-1',
position: 'before',
targetParentId: 'category-a',
});
expect(createOperation(channels, 'a-voice-1', dropResult)).toBeNull();
});
it('moves a voice channel after another voice sibling inside the same category', () => {
const channels = channelsFixture();
const dropResult = requireMachineDropResult(channels, 'a-voice-1', 'a-voice-2', rect.bottom - 1);
const operation = createOperation(channels, 'a-voice-1', dropResult);
expect(operation).toEqual({
channelId: 'a-voice-1',
newParentId: 'category-a',
precedingSiblingId: 'a-voice-2',
position: 3,
});
expect(finalIds(channels, operation!)).toEqual([
'root-text',
'category-a',
'a-text-1',
'a-text-2',
'a-voice-2',
'a-voice-1',
'category-b',
'b-text-1',
'b-link-1',
'b-voice-1',
'root-voice',
]);
});
it('blocks text and link channel drops onto voice rows before they can produce move operations', () => {
const channels = channelsFixture();
const textIntent = selectChannelReorderIntent(
dragItemFrom(requireChannel(channels, 'a-text-1')),
targetFrom(requireChannel(channels, 'a-voice-1')),
point(rect.top + 1),
rect,
);
const linkIntent = selectChannelReorderIntent(
dragItemFrom(requireChannel(channels, 'b-link-1')),
targetFrom(requireChannel(channels, 'b-voice-1')),
point(rect.bottom - 1),
rect,
);
expect(textIntent).toBeNull();
expect(linkIntent).toBeNull();
});
it('moves an entire category block before another category', () => {
const channels = channelsFixture();
const dropResult = requireMachineDropResult(channels, 'category-b', 'category-a', rect.top + 1);
const operation = createOperation(channels, 'category-b', dropResult);
expect(operation).toEqual({
channelId: 'category-b',
newParentId: null,
precedingSiblingId: 'root-text',
position: 1,
});
expect(finalIds(channels, operation!)).toEqual([
'root-text',
'category-b',
'b-text-1',
'b-link-1',
'b-voice-1',
'category-a',
'a-text-1',
'a-text-2',
'a-voice-1',
'a-voice-2',
'root-voice',
]);
});
it('moves an entire category block after another category block from the bottom half', () => {
const channels = channelsFixture();
const dropResult = requireMachineDropResult(channels, 'category-a', 'category-b', rect.bottom - 1);
expect(dropResult).toEqual({
targetId: 'category-b',
position: 'after',
targetParentId: null,
});
const operation = createOperation(channels, 'category-a', dropResult);
expect(operation).toEqual({
channelId: 'category-a',
newParentId: null,
precedingSiblingId: 'category-b',
position: 2,
});
expect(finalIds(channels, operation!)).toEqual([
'root-text',
'category-b',
'b-text-1',
'b-link-1',
'b-voice-1',
'category-a',
'a-text-1',
'a-text-2',
'a-voice-1',
'a-voice-2',
'root-voice',
]);
});
it('blocks category drops onto child channels before they can produce move operations', () => {
const channels = channelsFixture();
const intent = selectChannelReorderIntent(
dragItemFrom(requireChannel(channels, 'category-b')),
targetFrom(requireChannel(channels, 'a-text-1')),
point(rect.top + 1),
rect,
);
expect(intent).toBeNull();
});
it('moves nested channels to the top null-space as root channels', () => {
const channels = channelsFixture();
const operation = createOperation(channels, 'b-text-1', {
targetId: 'null-space',
position: 'before',
targetParentId: null,
});
expect(operation).toEqual({
channelId: 'b-text-1',
newParentId: null,
precedingSiblingId: null,
position: 0,
});
expect(finalParent(channels, operation!, 'b-text-1')).toBeNull();
expect(finalIds(channels, operation!)).toEqual([
'b-text-1',
'root-text',
'category-a',
'a-text-1',
'a-text-2',
'a-voice-1',
'a-voice-2',
'category-b',
'b-link-1',
'b-voice-1',
'root-voice',
]);
});
it('moves nested channels to trailing space as root channels after the final root sibling', () => {
const channels = channelsFixture();
const operation = createOperation(channels, 'a-text-1', {
targetId: 'trailing-space',
position: 'after',
targetParentId: null,
});
expect(operation).toEqual({
channelId: 'a-text-1',
newParentId: null,
precedingSiblingId: 'root-voice',
position: 4,
});
expect(finalParent(channels, operation!, 'a-text-1')).toBeNull();
expect(finalIds(channels, operation!)).toEqual([
'root-text',
'category-a',
'a-text-2',
'a-voice-1',
'a-voice-2',
'category-b',
'b-text-1',
'b-link-1',
'b-voice-1',
'root-voice',
'a-text-1',
]);
});
it('does not produce operations for current-position drops', () => {
const channels = channelsFixture();
expect(
createOperation(
channels,
'a-text-2',
requireMachineDropResult(channels, 'a-text-2', 'a-text-1', rect.bottom - 1),
),
).toBeNull();
expect(
createOperation(
channels,
'category-b',
requireMachineDropResult(channels, 'category-b', 'category-a', rect.bottom - 1),
),
).toBeNull();
});
it('every machine-produced reorder result is accepted by the move-operation planner or resolves to a no-op', () => {
const channels = channelsFixture();
const sources = channels;
for (const source of sources) {
for (const target of channels) {
for (const y of [rect.top + 1, rect.bottom - 1]) {
const intent = selectChannelReorderIntent(dragItemFrom(source), targetFrom(target), point(y), rect);
if (!intent) continue;
const operation = createOperation(channels, source.id, intent.result);
if (!operation) continue;
const plan = computeGuildChannelReorderPlan({
channels,
operation: {
channelId: operation.channelId,
parentId: operation.newParentId,
precedingSiblingId: operation.precedingSiblingId,
},
});
expect(plan, `${source.id} -> ${target.id} @ ${y}`).toMatchObject({ok: true});
}
}
}
});
});
@@ -1,296 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ChannelReorderRect,
type ChannelReorderTarget,
canChannelDropOnTarget,
createChannelReorderSnapshot,
getChannelReorderStateValue,
resolveChannelReorderHover,
selectChannelReorderIntent,
selectChannelReorderResolution,
transitionChannelReorderSnapshot,
} from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine';
import {type DragItem, DragItemType} from '@app/features/app/components/layout/types/DndTypes';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {describe, expect, it} from 'vitest';
const rect: ChannelReorderRect = {
top: 40,
bottom: 72,
left: 0,
right: 240,
};
function point(y: number) {
return {
x: 80,
y,
};
}
function channelItem(
id: string,
channelType: number = ChannelTypes.GUILD_TEXT,
parentId: string | null = null,
): DragItem {
return {
type: DragItemType.CHANNEL,
id,
channelType,
parentId,
guildId: 'guild-1',
};
}
function categoryItem(id = 'category-source'): DragItem {
return {
type: DragItemType.CATEGORY,
id,
channelType: ChannelTypes.GUILD_CATEGORY,
parentId: null,
guildId: 'guild-1',
};
}
function voiceParticipantItem(id = 'user-1', currentChannelId = 'voice-source'): DragItem {
return {
type: DragItemType.VOICE_PARTICIPANT,
id,
channelType: ChannelTypes.GUILD_VOICE,
parentId: null,
guildId: 'guild-1',
userId: id,
currentChannelId,
};
}
function target(
id: string,
channelType: number = ChannelTypes.GUILD_TEXT,
parentId: string | null = null,
): ChannelReorderTarget {
return {
id,
channelType,
parentId,
guildId: 'guild-1',
};
}
describe('ChannelReorderStateMachine', () => {
it('starts idle and targets a valid channel hover', () => {
let snapshot = createChannelReorderSnapshot();
expect(getChannelReorderStateValue(snapshot)).toBe('idle');
snapshot = transitionChannelReorderSnapshot(snapshot, {
type: 'drag.hover',
item: channelItem('source'),
target: target('target'),
clientOffset: point(42),
targetRect: rect,
});
expect(getChannelReorderStateValue(snapshot)).toBe('targeting');
expect(snapshot.context.intent?.indicator).toEqual({position: 'top', isValid: true});
expect(snapshot.context.intent?.result).toEqual({
targetId: 'target',
position: 'before',
targetParentId: null,
});
});
it('clears targeting state on drop', () => {
let snapshot = createChannelReorderSnapshot();
snapshot = transitionChannelReorderSnapshot(snapshot, {
type: 'drag.hover',
item: channelItem('source'),
target: target('target'),
clientOffset: point(42),
targetRect: rect,
});
snapshot = transitionChannelReorderSnapshot(snapshot, {type: 'drag.drop'});
expect(getChannelReorderStateValue(snapshot)).toBe('idle');
expect(snapshot.context.intent).toBeNull();
expect(snapshot.context.indicator).toBeNull();
});
it('blocks dragging an item onto itself and clears on leave', () => {
let snapshot = createChannelReorderSnapshot();
snapshot = transitionChannelReorderSnapshot(snapshot, {
type: 'drag.hover',
item: channelItem('same'),
target: target('same'),
clientOffset: point(42),
targetRect: rect,
});
expect(getChannelReorderStateValue(snapshot)).toBe('blocked');
expect(snapshot.context.blockedReason).toBe('same-source-and-target');
expect(snapshot.context.indicator).toEqual({position: 'top', isValid: false});
snapshot = transitionChannelReorderSnapshot(snapshot, {type: 'drag.leave'});
expect(getChannelReorderStateValue(snapshot)).toBe('idle');
expect(snapshot.context.blockedReason).toBeNull();
});
it('covers every pixel of a valid regular channel target with a concrete reorder result', () => {
const source = channelItem('source');
const destination = target('target', ChannelTypes.GUILD_TEXT, 'category-1');
for (let y = rect.top; y <= rect.bottom; y++) {
const intent = selectChannelReorderIntent(source, destination, point(y), rect);
expect(intent, `expected intent for y=${y}`).not.toBeNull();
expect(['before', 'after']).toContain(intent?.result.position);
expect(intent?.result.targetParentId).toBe('category-1');
}
});
it('maps regular channel top and bottom halves to before and after', () => {
const source = channelItem('source');
const destination = target('target', ChannelTypes.GUILD_TEXT, 'category-1');
expect(selectChannelReorderIntent(source, destination, point(41), rect)).toMatchObject({
indicator: {position: 'top', isValid: true},
result: {
targetId: 'target',
position: 'before',
targetParentId: 'category-1',
},
});
expect(selectChannelReorderIntent(source, destination, point(71), rect)).toMatchObject({
indicator: {position: 'bottom', isValid: true},
result: {
targetId: 'target',
position: 'after',
targetParentId: 'category-1',
},
});
});
it('maps category top half to before-category and bottom half to inside-category', () => {
const source = channelItem('source');
const categoryTarget = target('category-1', ChannelTypes.GUILD_CATEGORY, null);
expect(selectChannelReorderIntent(source, categoryTarget, point(41), rect)).toMatchObject({
indicator: {position: 'top', isValid: true},
result: {
targetId: 'category-1',
position: 'before',
targetParentId: null,
},
});
expect(selectChannelReorderIntent(source, categoryTarget, point(71), rect)).toMatchObject({
indicator: {position: 'bottom', isValid: true},
result: {
targetId: 'category-1',
position: 'inside',
targetParentId: 'category-1',
},
});
});
it('allows voice channels to target text, voice, and category rows like the current move operation expects', () => {
const source = channelItem('voice-source', ChannelTypes.GUILD_VOICE, 'category-1');
expect(canChannelDropOnTarget(source, target('text-target', ChannelTypes.GUILD_TEXT, 'category-1'))).toBe(true);
expect(canChannelDropOnTarget(source, target('voice-target', ChannelTypes.GUILD_VOICE, 'category-1'))).toBe(true);
expect(canChannelDropOnTarget(source, target('category-1', ChannelTypes.GUILD_CATEGORY, null))).toBe(true);
});
it('blocks text channels from targeting voice rows and returns an invalid indicator', () => {
const source = channelItem('text-source', ChannelTypes.GUILD_TEXT, 'category-1');
const destination = target('voice-target', ChannelTypes.GUILD_VOICE, 'category-1');
expect(canChannelDropOnTarget(source, destination)).toBe(false);
expect(resolveChannelReorderHover(source, destination, point(71), rect)).toMatchObject({
intent: null,
indicator: {position: 'bottom', isValid: false},
blockedReason: 'incompatible-channel-kind',
isVoiceParticipantTransfer: false,
});
});
it('blocks dragging a category onto a child channel', () => {
const source = categoryItem();
const childTarget = target('child', ChannelTypes.GUILD_TEXT, 'category-1');
expect(canChannelDropOnTarget(source, childTarget)).toBe(false);
expect(resolveChannelReorderHover(source, childTarget, point(41), rect)).toMatchObject({
intent: null,
indicator: {position: 'top', isValid: false},
blockedReason: 'category-into-child-channel',
});
});
it('allows dragging a category before or after a top-level channel target', () => {
const source = categoryItem();
const topLevelTarget = target('root-channel', ChannelTypes.GUILD_TEXT, null);
expect(selectChannelReorderIntent(source, topLevelTarget, point(41), rect)).toMatchObject({
result: {
targetId: 'root-channel',
position: 'before',
targetParentId: null,
},
});
expect(selectChannelReorderIntent(source, topLevelTarget, point(71), rect)).toMatchObject({
result: {
targetId: 'root-channel',
position: 'after',
targetParentId: null,
},
});
});
it('maps category-to-category bottom drops to after instead of inside', () => {
const source = categoryItem('category-source');
const categoryTarget = target('category-target', ChannelTypes.GUILD_CATEGORY, null);
expect(selectChannelReorderIntent(source, categoryTarget, point(71), rect)).toMatchObject({
indicator: {position: 'bottom', isValid: true},
result: {
targetId: 'category-target',
position: 'after',
targetParentId: null,
},
});
});
it('treats voice participant drops onto voice channels as transfers, not reorder intents', () => {
const resolution = selectChannelReorderResolution(
voiceParticipantItem(),
target('voice-target', ChannelTypes.GUILD_VOICE, 'category-1'),
point(41),
rect,
);
expect(resolution).toEqual({
intent: null,
indicator: null,
blockedReason: null,
isVoiceParticipantTransfer: true,
});
let snapshot = createChannelReorderSnapshot();
snapshot = transitionChannelReorderSnapshot(snapshot, {
type: 'drag.hover',
item: voiceParticipantItem(),
target: target('voice-target', ChannelTypes.GUILD_VOICE, 'category-1'),
clientOffset: point(41),
targetRect: rect,
});
expect(getChannelReorderStateValue(snapshot)).toBe('voiceParticipantTransfer');
});
it('blocks voice participant drops onto non-voice channels with an invalid indicator', () => {
const resolution = selectChannelReorderResolution(
voiceParticipantItem(),
target('text-target', ChannelTypes.GUILD_TEXT, 'category-1'),
point(71),
rect,
);
expect(resolution).toMatchObject({
intent: null,
indicator: {position: 'bottom', isValid: false},
blockedReason: 'incompatible-channel-kind',
isVoiceParticipantTransfer: false,
});
});
it('blocks zero-height target rects without manufacturing a drop result', () => {
const emptyRect = {...rect, bottom: rect.top};
expect(resolveChannelReorderHover(channelItem('source'), target('target'), point(rect.top), emptyRect)).toEqual({
intent: null,
indicator: null,
blockedReason: 'empty-target-rect',
isVoiceParticipantTransfer: false,
});
});
});
@@ -1,334 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createGuildReorderSnapshot,
GuildReorderBlockedReason,
GuildReorderStateMachine,
type GuildReorderTarget,
GuildReorderTargetKind,
resolveGuildReorderHover,
transitionGuildReorderSnapshot,
} from '@app/features/app/components/layout/dnd/GuildReorderStateMachine';
import {
DNDReorderState,
DragItemType,
DropPlacement,
type GuildDragItem,
} from '@app/features/app/components/layout/types/DndTypes';
import {Edge} from '@app/features/ui/AxisOrientation';
import type {VerticalDropRect} from '@app/features/ui/dnd/DropGeometry';
import {RelativePosition} from '@app/features/ui/RelativePosition';
import {describe, expect, it} from 'vitest';
const rect: VerticalDropRect = {
top: 100,
bottom: 154,
};
function guildItem(id: string, folderId: number | null = null): GuildDragItem {
return {
type: DragItemType.GUILD_ITEM,
id,
isFolder: false,
folderId,
};
}
function folderItem(id = 'folder-1', folderId = 1): GuildDragItem {
return {
type: DragItemType.GUILD_FOLDER,
id,
isFolder: true,
folderId,
};
}
function rootGuildTarget(id = 'guild-target'): GuildReorderTarget {
return {
id,
kind: GuildReorderTargetKind.TOP_LEVEL_GUILD,
};
}
function folderGuildTarget(id = 'guild-target', isTerminal = false): GuildReorderTarget {
return {
id,
kind: GuildReorderTargetKind.FOLDER_GUILD,
folderId: 1,
isTerminal,
};
}
function folderTarget(id = 'folder-1'): GuildReorderTarget {
return {
id,
kind: GuildReorderTargetKind.FOLDER,
};
}
function point(y: number) {
return {
x: 36,
y,
};
}
describe('GuildReorderStateMachine', () => {
it('starts idle and targets a valid top-level reorder hover', () => {
let snapshot = createGuildReorderSnapshot();
expect(snapshot.value).toBe(DNDReorderState.IDLE);
snapshot = transitionGuildReorderSnapshot({
snapshot,
event: {
type: 'drag.hover',
item: guildItem('guild-source'),
target: rootGuildTarget(),
clientOffset: point(104),
targetRect: rect,
},
});
expect(snapshot.value).toBe(DNDReorderState.TARGETING);
expect(snapshot.context.intent?.result).toMatchObject({
targetId: 'guild-target',
position: RelativePosition.BEFORE,
targetIsFolder: false,
});
expect(snapshot.context.intent?.indicator).toBe(Edge.TOP);
});
it('moves to blocked state for invalid hovers and clears on leave', () => {
let snapshot = createGuildReorderSnapshot();
snapshot = transitionGuildReorderSnapshot({
snapshot,
event: {
type: 'drag.hover',
item: guildItem('guild-target'),
target: rootGuildTarget('guild-target'),
clientOffset: point(104),
targetRect: rect,
},
});
expect(snapshot.value).toBe(DNDReorderState.BLOCKED);
expect(snapshot.context.blockedReason).toBe(GuildReorderBlockedReason.SAME_SOURCE_AND_TARGET);
snapshot = transitionGuildReorderSnapshot({snapshot, event: {type: 'drag.leave'}});
expect(snapshot.value).toBe(DNDReorderState.IDLE);
expect(snapshot.context.intent).toBeNull();
expect(snapshot.context.blockedReason).toBeNull();
});
it('clears targeting state after drop', () => {
let snapshot = createGuildReorderSnapshot();
snapshot = transitionGuildReorderSnapshot({
snapshot,
event: {
type: 'drag.hover',
item: guildItem('guild-source'),
target: rootGuildTarget(),
clientOffset: point(104),
targetRect: rect,
},
});
snapshot = transitionGuildReorderSnapshot({snapshot, event: {type: 'drag.drop'}});
expect(snapshot.value).toBe(DNDReorderState.IDLE);
expect(snapshot.context.intent).toBeNull();
});
it('covers every pixel of a top-level guild target with either reorder-before or combine', () => {
const item = guildItem('guild-source');
const target = rootGuildTarget();
for (let y = rect.top; y <= rect.bottom; y++) {
const intent = GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect});
expect(intent, `expected intent for y=${y}`).not.toBeNull();
expect([RelativePosition.BEFORE, DropPlacement.COMBINE]).toContain(intent?.result.position);
}
});
it('uses one top-level insertion target between adjacent guilds by never returning after from an item hover', () => {
const item = guildItem('guild-source');
const target = rootGuildTarget('guild-next');
const upperEdgeIntent = GuildReorderStateMachine.selectIntent({
item,
target,
clientOffset: point(101),
targetRect: rect,
});
const middleIntent = GuildReorderStateMachine.selectIntent({
item,
target,
clientOffset: point(130),
targetRect: rect,
});
const lowerEdgeIntent = GuildReorderStateMachine.selectIntent({
item,
target,
clientOffset: point(153),
targetRect: rect,
});
expect(upperEdgeIntent?.result.position).toBe(RelativePosition.BEFORE);
expect(middleIntent?.result.position).toBe(DropPlacement.COMBINE);
expect(lowerEdgeIntent?.result.position).toBe(DropPlacement.COMBINE);
expect(middleIntent?.result.position).not.toBe(RelativePosition.AFTER);
expect(lowerEdgeIntent?.result.position).not.toBe(RelativePosition.AFTER);
});
it('maps root guild top edge to reorder and center/bottom to folder combine for guild sources', () => {
const item = guildItem('guild-source');
const target = rootGuildTarget();
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(100), targetRect: rect}),
).toMatchObject({
indicator: Edge.TOP,
result: {position: RelativePosition.BEFORE},
});
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(114), targetRect: rect}),
).toMatchObject({
indicator: DropPlacement.COMBINE,
combineSourceGuildId: 'guild-source',
result: {position: DropPlacement.COMBINE},
});
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(154), targetRect: rect}),
).toMatchObject({
indicator: DropPlacement.COMBINE,
combineSourceGuildId: 'guild-source',
result: {position: DropPlacement.COMBINE},
});
});
it('does not combine folders with root guilds while dragging a folder', () => {
const item = folderItem();
const target = rootGuildTarget();
for (const y of [rect.top, 127, rect.bottom]) {
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect}),
).toMatchObject({
indicator: Edge.TOP,
result: {
position: RelativePosition.BEFORE,
targetIsFolder: false,
},
});
}
});
it('uses a single before-target slot for non-terminal guilds inside a folder', () => {
const item = guildItem('guild-source', 1);
const target = folderGuildTarget('guild-middle', false);
for (const y of [rect.top, 127, rect.bottom]) {
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect}),
).toMatchObject({
indicator: Edge.TOP,
result: {
targetId: 'guild-middle',
position: RelativePosition.BEFORE,
targetIsFolder: false,
targetFolderId: 1,
},
});
}
});
it('allows the terminal guild inside a folder to own the final after slot', () => {
const item = guildItem('guild-source', 1);
const target = folderGuildTarget('guild-last', true);
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(101), targetRect: rect}),
).toMatchObject({
indicator: Edge.TOP,
result: {
targetId: 'guild-last',
position: RelativePosition.BEFORE,
targetFolderId: 1,
},
});
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(153), targetRect: rect}),
).toMatchObject({
indicator: Edge.BOTTOM,
result: {
targetId: 'guild-last',
position: RelativePosition.AFTER,
targetFolderId: 1,
},
});
});
it('blocks dragging a folder onto a guild inside another folder', () => {
const item = folderItem('folder-2', 2);
const target = folderGuildTarget('guild-inside-folder', true);
expect(GuildReorderStateMachine.canDrop({item, target})).toBe(false);
expect(resolveGuildReorderHover({item, target, clientOffset: point(127), targetRect: rect})).toEqual({
intent: null,
blockedReason: GuildReorderBlockedReason.FOLDER_INTO_FOLDER_GUILD,
});
});
it('uses top edge for reordering before a folder and the rest of a folder target for adding a guild inside', () => {
const item = guildItem('guild-source');
const target = folderTarget('folder-1');
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(101), targetRect: rect}),
).toMatchObject({
indicator: Edge.TOP,
result: {
targetId: 'folder-1',
position: RelativePosition.BEFORE,
targetIsFolder: true,
},
});
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(127), targetRect: rect}),
).toMatchObject({
indicator: DropPlacement.INSIDE,
result: {
targetId: 'folder-1',
position: DropPlacement.INSIDE,
targetIsFolder: true,
},
});
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(153), targetRect: rect}),
).toMatchObject({
indicator: DropPlacement.INSIDE,
result: {
targetId: 'folder-1',
position: DropPlacement.INSIDE,
targetIsFolder: true,
},
});
});
it('only reorders before a folder when dragging another folder', () => {
const item = folderItem('folder-2', 2);
const target = folderTarget('folder-1');
for (const y of [rect.top, 127, rect.bottom]) {
expect(
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect}),
).toMatchObject({
indicator: Edge.TOP,
result: {
targetId: 'folder-1',
position: RelativePosition.BEFORE,
targetIsFolder: true,
},
});
}
});
it('blocks zero-height target rects instead of producing a void result', () => {
const emptyRect = {...rect, bottom: rect.top};
expect(
resolveGuildReorderHover({
item: guildItem('guild-source'),
target: rootGuildTarget(),
clientOffset: point(rect.top),
targetRect: emptyRect,
}),
).toEqual({
intent: null,
blockedReason: GuildReorderBlockedReason.EMPTY_TARGET_RECT,
});
});
});
@@ -1,191 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
shouldShowCategoryWhenHidingMutedChannels,
shouldShowChannelInCollapsedCategory,
shouldShowChannelWhenHidingMutedChannels,
} from '@app/features/app/components/layout/utils/ChannelListVisibility';
import {describe, expect, it} from 'vitest';
describe('shouldShowChannelWhenHidingMutedChannels', () => {
it('keeps muted channels with visible unread state so mentions are findable', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: false,
isChannelMuted: true,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
}),
).toBe(true);
});
it('hides muted channels without visible unread state', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: false,
isChannelMuted: true,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(false);
});
it('inherits the mute from a muted category', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: true,
isChannelMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(false);
});
it('keeps channels of a muted category that have visible unread state', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: true,
isChannelMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
}),
).toBe(true);
});
it('keeps selected and connected muted channels visible', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: true,
isChannelMuted: true,
isSelected: true,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(true);
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: true,
isChannelMuted: false,
isSelected: false,
isConnected: true,
hasVisibleUnread: false,
}),
).toBe(true);
});
it('keeps unmuted channels visible', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: false,
isChannelMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(true);
});
});
describe('shouldShowCategoryWhenHidingMutedChannels', () => {
it('keeps a category that has no channels at all', () => {
expect(
shouldShowCategoryWhenHidingMutedChannels({
hasChannels: false,
hasVisibleChannels: false,
}),
).toBe(true);
});
it('hides a category whose channels were all filtered out as muted', () => {
expect(
shouldShowCategoryWhenHidingMutedChannels({
hasChannels: true,
hasVisibleChannels: false,
}),
).toBe(false);
});
it('keeps a category that still has visible channels', () => {
expect(
shouldShowCategoryWhenHidingMutedChannels({
hasChannels: true,
hasVisibleChannels: true,
}),
).toBe(true);
});
});
describe('shouldShowChannelInCollapsedCategory', () => {
it('keeps visible unread channels reachable in collapsed categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
hasMentions: false,
}),
).toBe(true);
});
it('keeps selected and connected channels visible without unread state, even in muted categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: true,
isSelected: true,
isConnected: false,
hasVisibleUnread: false,
hasMentions: false,
}),
).toBe(true);
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: true,
isSelected: false,
isConnected: true,
hasVisibleUnread: false,
hasMentions: false,
}),
).toBe(true);
});
it('hides read channels in collapsed categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
hasMentions: false,
}),
).toBe(false);
});
it('hides unread channels without mentions in muted collapsed categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: true,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
hasMentions: false,
}),
).toBe(false);
});
it('keeps mentioned channels reachable in muted collapsed categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: true,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
hasMentions: true,
}),
).toBe(true);
});
});
@@ -1,91 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {describe, expect, it} from 'vitest';
describe('getChannelUnreadState', () => {
it('shows a normal unread indicator for all-messages unread badges', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: MessageNotifications.ALL_MESSAGES,
});
expect(state.shouldShowUnreadIndicator).toBe(true);
expect(state.isUnreadIndicatorMuted).toBe(false);
expect(state.isHighlight).toBe(true);
});
it('shows a muted unread indicator for only-mentions unread badges without highlighting the channel', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: MessageNotifications.ONLY_MENTIONS,
});
expect(state.shouldShowUnreadIndicator).toBe(true);
expect(state.isUnreadIndicatorMuted).toBe(true);
expect(state.isHighlight).toBe(false);
});
it('hides unread and mention surfaces when unread badges are disabled', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 1,
isMuted: false,
showFadedUnreadOnMutedChannels: true,
unreadBadgesLevel: MessageNotifications.NO_MESSAGES,
});
expect(state.shouldShowUnreadIndicator).toBe(false);
expect(state.hasMentions).toBe(false);
expect(state.hasVisibleUnread).toBe(false);
});
it('keeps legacy muted-channel fading for channels without an unread-badges level', () => {
const hiddenState = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: true,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: null,
});
const fadedState = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: true,
showFadedUnreadOnMutedChannels: true,
unreadBadgesLevel: null,
});
expect(hiddenState.shouldShowUnreadIndicator).toBe(false);
expect(fadedState.shouldShowUnreadIndicator).toBe(true);
expect(fadedState.isUnreadIndicatorMuted).toBe(true);
});
it('shows the indicator from the unread flag rather than the message count', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 0,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: null,
});
expect(state.shouldShowUnreadIndicator).toBe(true);
});
it('hides the indicator for a read channel even if a stale count survives', () => {
const state = getChannelUnreadState({
hasUnread: false,
unreadCount: 7,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: null,
});
expect(state.shouldShowUnreadIndicator).toBe(false);
expect(state.hasVisibleUnread).toBe(false);
});
});
@@ -1,85 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelUnreadStateInput} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {
type ChannelUnreadStateValue,
createChannelUnreadSnapshot,
selectChannelUnreadState,
transitionChannelUnreadSnapshot,
} from '@app/features/app/components/layout/utils/ChannelUnreadStateMachine';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {describe, expect, it} from 'vitest';
function input(overrides: Partial<ChannelUnreadStateInput> = {}): ChannelUnreadStateInput {
return {
hasUnread: false,
unreadCount: 0,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: null,
...overrides,
};
}
function expectState(overrides: Partial<ChannelUnreadStateInput>, expected: ChannelUnreadStateValue): void {
const snapshot = createChannelUnreadSnapshot(input(overrides));
expect(snapshot.value).toBe(expected);
}
describe('channelUnreadStateMachine', () => {
it('routes each unread badge policy to an explicit state', () => {
expectState({unreadBadgesLevel: MessageNotifications.NO_MESSAGES}, 'disabled');
expectState({unreadBadgesLevel: MessageNotifications.ONLY_MENTIONS}, 'onlyMentions');
expectState({unreadBadgesLevel: MessageNotifications.ALL_MESSAGES}, 'allMessages');
expectState({unreadBadgesLevel: null}, 'legacy');
});
it('suppresses all visible unread surfaces when badges are disabled', () => {
const snapshot = createChannelUnreadSnapshot(
input({
unreadBadgesLevel: MessageNotifications.NO_MESSAGES,
hasUnread: true,
unreadCount: 1,
mentionCount: 1,
}),
);
expect(selectChannelUnreadState(snapshot)).toMatchObject({
hasUnreadMessages: true,
hasMentions: false,
isHighlight: false,
shouldShowUnreadIndicator: false,
hasVisibleUnread: false,
});
});
it('transitions without preserving stale policy output', () => {
const legacySnapshot = createChannelUnreadSnapshot(
input({
hasUnread: true,
unreadCount: 2,
isMuted: true,
showFadedUnreadOnMutedChannels: false,
}),
);
expect(selectChannelUnreadState(legacySnapshot).shouldShowUnreadIndicator).toBe(false);
const allMessagesSnapshot = transitionChannelUnreadSnapshot(legacySnapshot, {
type: 'channelUnread.updated',
input: input({
unreadBadgesLevel: MessageNotifications.ALL_MESSAGES,
hasUnread: true,
unreadCount: 2,
isMuted: true,
}),
});
expect(allMessagesSnapshot.value).toBe('allMessages');
expect(selectChannelUnreadState(allMessagesSnapshot)).toMatchObject({
isHighlight: true,
shouldShowUnreadIndicator: true,
isUnreadIndicatorMuted: false,
});
});
});
@@ -1,123 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createSetupWizardSnapshot,
type SetupWizardMachineEvent,
type SetupWizardSnapshot,
selectSetupWizardModel,
transitionSetupWizardSnapshot,
} from '@app/features/app/components/setup/SetupWizardStateMachine';
import {describe, expect, it} from 'vitest';
function transition(snapshot: SetupWizardSnapshot, event: SetupWizardMachineEvent): SetupWizardSnapshot {
return transitionSetupWizardSnapshot(snapshot, event);
}
function sync(snapshot: SetupWizardSnapshot, isAuthenticated: boolean, hasConfig: boolean): SetupWizardSnapshot {
return transition(snapshot, {type: 'wizard.sync', isAuthenticated, hasConfig});
}
describe('setupWizardStateMachine', () => {
it('starts unauthenticated in the register phase on the welcome step', () => {
const model = selectSetupWizardModel(createSetupWizardSnapshot());
expect(model).toMatchObject({phase: 'register', step: 'welcome', direction: 0, stepIndex: 0});
});
it('advances and retreats within the register phase by step identity', () => {
let snapshot = createSetupWizardSnapshot();
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot)).toMatchObject({step: 'theme', direction: 1});
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('admin_intro');
snapshot = transition(snapshot, {type: 'wizard.back'});
expect(selectSetupWizardModel(snapshot)).toMatchObject({step: 'theme', direction: -1});
});
it('does not advance past the last register step', () => {
let snapshot = createSetupWizardSnapshot();
for (let index = 0; index < 8; index += 1) {
snapshot = transition(snapshot, {type: 'wizard.next'});
}
expect(selectSetupWizardModel(snapshot).step).toBe('admin_account');
});
it('does not retreat before the first step', () => {
const snapshot = transition(createSetupWizardSnapshot(), {type: 'wizard.back'});
expect(selectSetupWizardModel(snapshot).step).toBe('welcome');
});
it('routes to the loading phase once authenticated before the config loads', () => {
let snapshot = createSetupWizardSnapshot();
snapshot = transition(snapshot, {type: 'wizard.next'});
snapshot = transition(snapshot, {type: 'wizard.next'});
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('admin_account');
snapshot = sync(snapshot, true, false);
expect(selectSetupWizardModel(snapshot)).toMatchObject({phase: 'loading', step: 'loading', direction: 0});
});
it('lands on branding then registration after registering, never skipping them', () => {
let snapshot = createSetupWizardSnapshot();
snapshot = transition(snapshot, {type: 'wizard.next'});
snapshot = transition(snapshot, {type: 'wizard.next'});
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('admin_account');
snapshot = sync(snapshot, true, false);
snapshot = sync(snapshot, true, true);
expect(selectSetupWizardModel(snapshot)).toMatchObject({phase: 'configure', step: 'welcome', direction: 0});
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('branding');
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('registration');
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('community');
});
it('enters the configure phase at welcome when already authenticated with config', () => {
const snapshot = sync(createSetupWizardSnapshot(), true, true);
expect(selectSetupWizardModel(snapshot)).toMatchObject({phase: 'configure', step: 'welcome'});
});
it('preserves the current step when a redundant sync keeps the same phase', () => {
let snapshot = sync(createSetupWizardSnapshot(), true, true);
snapshot = transition(snapshot, {type: 'wizard.next'});
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('registration');
snapshot = sync(snapshot, true, true);
expect(selectSetupWizardModel(snapshot).step).toBe('registration');
});
it('does not advance past the final configure step', () => {
let snapshot = sync(createSetupWizardSnapshot(), true, true);
for (let index = 0; index < 20; index += 1) {
snapshot = transition(snapshot, {type: 'wizard.next'});
}
expect(selectSetupWizardModel(snapshot).step).toBe('finish');
});
it('falls back to the register phase when authentication is lost', () => {
let snapshot = sync(createSetupWizardSnapshot(), true, true);
snapshot = transition(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('branding');
snapshot = sync(snapshot, false, false);
expect(selectSetupWizardModel(snapshot)).toMatchObject({phase: 'register', step: 'welcome'});
});
});
@@ -1,54 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createRandomWelcomeRotationState,
createWelcomeRotationState,
WELCOME_ROTATION,
type WelcomeRotationState,
} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
import {describe, expect, it} from 'vitest';
function constantRandom(value: number): () => number {
return () => value;
}
function welcomeTexts(state: WelcomeRotationState): ReadonlyArray<string> {
return state.order.map((index) => WELCOME_ROTATION[index]?.text ?? '');
}
function firstWelcomeIndexForCode(code: string): number {
return WELCOME_ROTATION.findIndex((entry) => entry.code === code);
}
describe('setup wizard welcome rotation', () => {
it('starts with the selected locale welcome text', () => {
const state = createWelcomeRotationState('sv-SE', constantRandom(0));
const firstText = WELCOME_ROTATION[state.order[0] ?? 0]?.text;
expect(firstText).toBe('Välkommen');
});
it('shows each rendered welcome text once per locale-seeded pass', () => {
for (const code of ['en-US', 'en-GB', 'es-419', 'da', 'no']) {
const texts = welcomeTexts(createWelcomeRotationState(code, constantRandom(0)));
expect(new Set(texts).size).toBe(texts.length);
}
});
it('shows each rendered welcome text once per random pass', () => {
const previousIndex = firstWelcomeIndexForCode('en-US');
const texts = welcomeTexts(createRandomWelcomeRotationState(previousIndex, constantRandom(0)));
expect(new Set(texts).size).toBe(texts.length);
});
it('does not restart a random pass with the previous rendered welcome text', () => {
const previousIndex = firstWelcomeIndexForCode('en-US');
const state = createRandomWelcomeRotationState(previousIndex, constantRandom(0));
const previousText = WELCOME_ROTATION[previousIndex]?.text;
const nextText = WELCOME_ROTATION[state.order[0] ?? 0]?.text;
expect(nextText).not.toBe(previousText);
});
});
@@ -1,72 +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';
vi.mock('@app/features/expressions/utils/CustomEmojiImageUrl', () => ({
buildCustomEmojiURL: ({id, animated}: {id: string; animated: boolean}) =>
`https://cdn.test/emojis/${id}.${animated ? 'gif' : 'png'}`,
}));
vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({getEmojiURL: () => 'https://cdn.test/twemoji.svg'}));
vi.mock('@app/features/expressions/utils/UnicodeEmojis', () => ({
default: {
normalizeEmojiNameToSurrogate: (value: string) => value,
nameForSurrogate: (_surrogate: string, _colons: boolean, fallback: string) => fallback,
},
}));
vi.mock('@app/features/emoji/state/Emoji', () => ({default: {getEmojiById: () => null}}));
vi.mock('@app/features/ui/state/MobileLayout', () => ({default: {enabled: false}}));
vi.mock('@app/features/app/hooks/useShouldAnimate', () => ({useShouldAnimate: () => true}));
vi.mock('@app/features/presence/hooks/usePresenceCustomStatus', () => ({usePresenceCustomStatus: () => null}));
vi.mock('@app/features/ui/hooks/useTextOverflow', () => ({useTextOverflow: () => false}));
vi.mock('@app/features/theme/layout/RemFromPx', () => ({remFromPx: (value: number) => value}));
vi.mock('@app/features/ui/focus_ring/FocusRing', () => ({
default: ({children}: {children?: ReactNode}) => children,
}));
vi.mock('@lingui/react/macro', () => ({Trans: ({children}: {children?: ReactNode}) => children}));
vi.mock('@phosphor-icons/react', () => ({
PencilIcon: () => createElement('span'),
SmileyIcon: () => createElement('span'),
}));
vi.mock('@app/features/ui/tooltip/Tooltip', () => ({
Tooltip: ({text, children}: {text?: unknown; children?: ReactNode}) =>
createElement('div', {'data-simple-tooltip': String(text ?? '')}, children),
}));
const {CustomStatusDisplay} = await import(
'@app/features/app/components/shared/custom_status_display/CustomStatusDisplay'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const STATUS = {text: null, expiresAt: null, emojiId: '4001', emojiName: 'partyblob', emojiAnimated: false};
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(): void {
act(() => {
root.render(createElement(CustomStatusDisplay, {customStatus: STATUS, showTooltip: true}));
});
}
describe('CustomStatusDisplay status emoji tooltip', () => {
it('renders the emoji name tooltip', () => {
render();
expect(container.querySelector('[data-simple-tooltip=":partyblob:"]')).not.toBeNull();
});
});
@@ -1,25 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createHoverStateSnapshot,
getHoverStateValue,
selectIsHovering,
transitionHoverStateSnapshot,
} from '@app/features/app/hooks/HoverStateMachine';
import {describe, expect, it} from 'vitest';
describe('HoverStateMachine', () => {
it('transitions between idle and hovering', () => {
let snapshot = createHoverStateSnapshot();
expect(getHoverStateValue(snapshot)).toBe('idle');
expect(selectIsHovering(snapshot)).toBe(false);
snapshot = transitionHoverStateSnapshot(snapshot, {type: 'hover.enter'});
expect(getHoverStateValue(snapshot)).toBe('hovering');
expect(selectIsHovering(snapshot)).toBe(true);
snapshot = transitionHoverStateSnapshot(snapshot, {type: 'hover.leave'});
expect(getHoverStateValue(snapshot)).toBe('idle');
expect(selectIsHovering(snapshot)).toBe(false);
});
});
@@ -1,56 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating';
import {act, createElement, useLayoutEffect, useState} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
let host: HTMLDivElement;
let root: Root;
let target: HTMLDivElement;
function PortalLikeFloating({onReady}: {onReady: (isReady: boolean) => void}) {
const {setFloating, state} = useAntiShiftFloating(target, true, {placement: 'top'});
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
onReady(state.isReady);
return mounted ? createElement('div', {ref: setFloating, 'data-testid': 'floating'}) : null;
}
async function settle(): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
await Promise.resolve();
});
}
}
beforeEach(() => {
host = document.createElement('div');
target = document.createElement('div');
document.body.append(host, target);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
document.body.replaceChildren();
});
describe('useAntiShiftFloating', () => {
it('positions a floating element that mounts after the first commit', async () => {
const readyStates: Array<boolean> = [];
act(() => {
root.render(createElement(PortalLikeFloating, {onReady: (isReady) => readyStates.push(isReady)}));
});
await settle();
expect(readyStates.at(-1)).toBe(true);
expect(host.querySelector<HTMLElement>('[data-testid="floating"]')?.style.visibility).toBe('visible');
});
});
@@ -1,273 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
keyboardEventCanRecoverStaleMacMetaPress,
keyboardEventMatchesCombo,
keyboardEventReleasesComboModifier,
keyboardEventStartsComboPress,
keyboardEventTriggerMatchesCombo,
shouldAllowLocalShortcutForChannelTextarea,
} from '@app/features/app/keybindings/KeybindEventUtils';
import {
isKeybindAllowedDuringVoiceCallFullscreen,
isKeybindBlockedByCompactVoiceCallView,
} from '@app/features/app/keybindings/KeybindScopeUtils';
import {comboToCombokeysStrings} from '@app/features/app/keybindings/utils/ComboShortcutStrings';
import {keyNameForGlobalHook, physicalKeyNameForGlobalHook} from '@app/features/app/keybindings/utils/GlobalHookKeys';
import {hookShortcutIdForKeybind} from '@app/features/app/keybindings/utils/HookShortcutIds';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {describe, expect, it} from 'vitest';
const keyEvent = (
overrides: Partial<{
key: string;
code: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
repeat: boolean;
}>,
) => ({
key: '',
code: '',
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
repeat: false,
...overrides,
});
const runtimeKeybind = (combo: {key: string; code?: string; ctrlOrMeta?: boolean; shift?: boolean; global?: boolean}) =>
({
action: 'voice_toggle_mute',
combo,
}) as const;
describe('KeybindManager editable shortcut matching', () => {
it('matches Ctrl+Shift+U exactly for the upload shortcut on non-mac platforms', () => {
expect(
keyboardEventMatchesCombo(
{key: 'u', ctrlOrMeta: true, shift: true},
keyEvent({key: 'U', code: 'KeyU', ctrlKey: true, shiftKey: true}),
{isMacOS: false},
),
).toBe(true);
});
it('treats Command keyup as releasing a macOS ctrlOrMeta shortcut latch', () => {
expect(
keyboardEventReleasesComboModifier({key: 'g', ctrlOrMeta: true}, keyEvent({key: 'Meta', code: 'MetaLeft'}), {
isMacOS: true,
}),
).toBe(true);
expect(
keyboardEventReleasesComboModifier({key: 'g', ctrlOrMeta: true}, keyEvent({key: 'Meta', code: 'MetaLeft'}), {
isMacOS: false,
}),
).toBe(false);
});
it('treats a fresh Command+G keydown as a shortcut start while Command remains held', () => {
expect(
keyboardEventStartsComboPress({key: 'g', ctrlOrMeta: true}, keyEvent({key: 'g', code: 'KeyG', metaKey: true}), {
isMacOS: true,
}),
).toBe(true);
expect(
keyboardEventStartsComboPress(
{key: 'g', ctrlOrMeta: true},
keyEvent({key: 'g', code: 'KeyG', metaKey: true, repeat: true}),
{isMacOS: true},
),
).toBe(false);
});
it('only allows stale press recovery for macOS Command shortcuts', () => {
expect(
keyboardEventCanRecoverStaleMacMetaPress(
{key: 'g', ctrlOrMeta: true},
keyEvent({key: 'g', code: 'KeyG', metaKey: true}),
{isMacOS: true},
),
).toBe(true);
expect(
keyboardEventCanRecoverStaleMacMetaPress(
{key: 'd', ctrlOrMeta: true, shift: true},
keyEvent({key: 'd', code: 'KeyD', ctrlKey: true, shiftKey: true}),
{isMacOS: false},
),
).toBe(false);
expect(
keyboardEventCanRecoverStaleMacMetaPress(
{key: 'd', ctrlOrMeta: true, shift: true},
keyEvent({key: 'd', code: 'KeyD', metaKey: true, shiftKey: true, repeat: true}),
{isMacOS: true},
),
).toBe(false);
});
it('matches repeated trigger-key presses while macOS modifiers stay held', () => {
const combo = {key: 'd', ctrlOrMeta: true, shift: true};
const keyDown = keyEvent({key: 'd', code: 'KeyD', metaKey: true, shiftKey: true});
const keyUp = keyEvent({key: 'd', code: 'KeyD', metaKey: true, shiftKey: true});
expect(keyboardEventStartsComboPress(combo, keyDown, {isMacOS: true})).toBe(true);
expect(keyboardEventTriggerMatchesCombo(combo, keyUp)).toBe(true);
expect(keyboardEventStartsComboPress(combo, keyDown, {isMacOS: true})).toBe(true);
});
it('treats Control keyup as releasing a non-mac ctrlOrMeta shortcut latch', () => {
expect(
keyboardEventReleasesComboModifier(
{key: 'g', ctrlOrMeta: true},
keyEvent({key: 'Control', code: 'ControlLeft'}),
{isMacOS: false},
),
).toBe(true);
expect(
keyboardEventReleasesComboModifier(
{key: 'g', ctrlOrMeta: true},
keyEvent({key: 'Control', code: 'ControlLeft'}),
{isMacOS: true},
),
).toBe(false);
});
it('does not let extra Shift make Ctrl+U match Ctrl+Shift+U', () => {
expect(
keyboardEventMatchesCombo(
{key: 'u', ctrlOrMeta: true},
keyEvent({key: 'U', code: 'KeyU', ctrlKey: true, shiftKey: true}),
{isMacOS: false},
),
).toBe(false);
});
it('allows Shift when a layout needs it to produce a printable punctuation shortcut', () => {
expect(
keyboardEventMatchesCombo(
{key: '/', ctrlOrMeta: true},
keyEvent({key: '/', code: 'Digit7', metaKey: true, shiftKey: true}),
{isMacOS: true},
),
).toBe(true);
expect(
keyboardEventMatchesCombo(
{key: '1', ctrlOrMeta: true},
keyEvent({key: '!', code: 'Digit1', metaKey: true, shiftKey: true}),
{isMacOS: true},
),
).toBe(false);
});
it('matches printable shortcuts by layout key before physical code', () => {
const combo = {key: 'Z', code: 'KeyZ', ctrlOrMeta: true, shift: true};
expect(
keyboardEventMatchesCombo(combo, keyEvent({key: 'Z', code: 'KeyY', ctrlKey: true, shiftKey: true}), {
isMacOS: false,
}),
).toBe(true);
expect(
keyboardEventMatchesCombo(combo, keyEvent({key: 'Y', code: 'KeyZ', ctrlKey: true, shiftKey: true}), {
isMacOS: false,
}),
).toBe(false);
});
it('falls back to physical code for Ctrl+Alt printable shortcuts when the layout key is transformed', () => {
expect(
keyboardEventMatchesCombo(
{key: 'v', code: 'KeyV', ctrlOrMeta: true, alt: true},
keyEvent({key: '@', code: 'KeyV', ctrlKey: true, altKey: true}),
{isMacOS: false},
),
).toBe(true);
expect(
keyboardEventMatchesCombo(
{key: 'v', code: 'KeyB', ctrlOrMeta: true, alt: true},
keyEvent({key: '@', code: 'KeyV', ctrlKey: true, altKey: true}),
{isMacOS: false},
),
).toBe(false);
});
it('serializes printable global shortcuts with the layout-aware key', () => {
const combo = {key: 'Z', code: 'KeyY', ctrlOrMeta: true, shift: true, global: true};
expect(comboToCombokeysStrings(combo)).toEqual(['mod+shift+z']);
expect(keyNameForGlobalHook(combo)).toBe('Z');
expect(physicalKeyNameForGlobalHook(combo)).toBe('Y');
expect(hookShortcutIdForKeybind(runtimeKeybind(combo))).toBe('key:voice_toggle_mute:mod+shift:KeyY:Z');
});
it('keeps numpad global hook names code-based', () => {
expect(keyNameForGlobalHook({key: '1', code: 'Numpad1', ctrlOrMeta: true})).toBe('Numpad1');
});
it('maps a CapsLock hold combo to the name the native hooks emit', () => {
const combo = {key: 'CapsLock', code: 'CapsLock', enabled: true, global: true};
expect(keyNameForGlobalHook(combo)).toBe('CapsLock');
expect(physicalKeyNameForGlobalHook(combo)).toBe('CapsLock');
});
it('normalizes Pause and Break for global hook registration and matching', () => {
const combo = {key: 'Pause', code: 'Pause', ctrlOrMeta: true, global: true};
expect(comboToCombokeysStrings(combo)).toEqual(['mod+pause']);
expect(keyNameForGlobalHook(combo)).toBe('Pause');
expect(keyNameForGlobalHook({key: 'Break', code: 'Pause'})).toBe('Pause');
expect(hookShortcutIdForKeybind(runtimeKeybind(combo))).toBe('key:voice_toggle_mute:mod:Pause:Pause');
expect(
keyboardEventMatchesCombo(combo, keyEvent({key: 'Break', code: 'Pause', ctrlKey: true}), {isMacOS: false}),
).toBe(true);
expect(keyboardEventTriggerMatchesCombo(combo, keyEvent({key: 'Break', code: 'Pause'}))).toBe(true);
});
it('serializes special numpad, media, and native-only keys for the native global hook', () => {
expect(keyNameForGlobalHook({key: '0', code: 'Numpad0'})).toBe('Numpad0');
expect(keyNameForGlobalHook({key: 'AudioVolumeMute', code: 'AudioVolumeMute'})).toBe('AudioVolumeMute');
expect(keyNameForGlobalHook({key: 'BrowserBack', code: 'BrowserBack'})).toBe('BrowserBack');
});
it('allows page-scroll shortcuts from an empty channel textarea only', () => {
expect(shouldAllowLocalShortcutForChannelTextarea({editableFocusBehavior: 'allow_when_empty'}, '')).toBe(true);
expect(shouldAllowLocalShortcutForChannelTextarea({editableFocusBehavior: 'allow_when_empty'}, 'draft')).toBe(
false,
);
});
it('allows explicit editable-focus shortcuts even when the textarea has content', () => {
expect(shouldAllowLocalShortcutForChannelTextarea({editableFocusBehavior: 'allow'}, 'draft')).toBe(true);
});
it('limits fullscreen voice call shortcuts to voice controls', () => {
expect(isKeybindAllowedDuringVoiceCallFullscreen('voice_toggle_mute')).toBe(true);
expect(isKeybindAllowedDuringVoiceCallFullscreen('voice_disconnect')).toBe(true);
expect(isKeybindAllowedDuringVoiceCallFullscreen('voice_toggle_compact_call_view')).toBe(false);
expect(isKeybindAllowedDuringVoiceCallFullscreen('nav_channel_next')).toBe(false);
expect(isKeybindAllowedDuringVoiceCallFullscreen('chat_toggle_inbox')).toBe(false);
});
it('blocks textarea-scoped shortcuts while compact call chat is hidden', () => {
expect(
isKeybindBlockedByCompactVoiceCallView({
action: 'chat_focus_textarea',
channelType: ChannelTypes.GUILD_VOICE,
isPrivateChannel: false,
isGuildVoiceCallExpanded: true,
isConnectedToPrivateCall: false,
isPrivateCompactCallExpanded: false,
}),
).toBe(true);
expect(
isKeybindBlockedByCompactVoiceCallView({
action: 'chat_toggle_emoji',
channelType: ChannelTypes.GROUP_DM,
isPrivateChannel: true,
isGuildVoiceCallExpanded: false,
isConnectedToPrivateCall: true,
isPrivateCompactCallExpanded: true,
}),
).toBe(true);
expect(
isKeybindBlockedByCompactVoiceCallView({
action: 'chat_toggle_emoji',
channelType: ChannelTypes.GROUP_DM,
isPrivateChannel: true,
isGuildVoiceCallExpanded: false,
isConnectedToPrivateCall: true,
isPrivateCompactCallExpanded: false,
}),
).toBe(false);
expect(
isKeybindBlockedByCompactVoiceCallView({
action: 'voice_toggle_mute',
channelType: ChannelTypes.GUILD_VOICE,
isPrivateChannel: false,
isGuildVoiceCallExpanded: true,
isConnectedToPrivateCall: false,
isPrivateCompactCallExpanded: false,
}),
).toBe(false);
});
});
@@ -1,60 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildCustomRuntimeKeybinds,
buildDefaultRuntimeKeybinds,
getCustomActionOverrides,
} from '@app/features/app/keybindings/utils/RuntimeKeybinds';
import type {CustomKeybindEntry, KeybindCommand, KeybindConfig, KeyCombo} from '@app/features/input/state/InputKeybind';
import {describe, expect, it} from 'vitest';
const action: KeybindCommand = 'voice_toggle_deafen';
const keybind = (combo: KeyCombo): KeybindConfig => ({
action,
label: 'Deafen',
combo,
section: 'voice_and_video',
});
const custom = (combo: KeyCombo, enabled: boolean): CustomKeybindEntry => ({
id: 'custom-1',
action,
combo,
enabled,
});
const resolver =
(defaults: ReadonlyArray<KeybindConfig>) =>
(candidate: KeybindCommand): KeybindConfig | null =>
defaults.find((entry) => entry.action === candidate) ?? null;
describe('runtime keybind resolution', () => {
it('treats disabled custom bindings as default action overrides', () => {
const defaults = [keybind({key: 'd', ctrlOrMeta: true, shift: true})];
const customs = [custom({key: ''}, false)];
const overriddenActions = getCustomActionOverrides(customs);
expect(buildDefaultRuntimeKeybinds(defaults, overriddenActions)).toEqual([]);
expect(buildCustomRuntimeKeybinds(customs, resolver(defaults))).toEqual([]);
});
it('builds enabled custom runtime keybinds from the default action metadata', () => {
const defaults = [keybind({key: 'd', ctrlOrMeta: true, shift: true})];
const override = custom({key: 'm', ctrlOrMeta: true, global: true}, true);
expect(buildCustomRuntimeKeybinds([override], resolver(defaults))).toEqual([
{
...defaults[0],
combo: override.combo,
},
]);
});
it('keeps a local-only default local while an enabled custom replacement can be global', () => {
const defaults = [{...keybind({key: 'd', ctrlOrMeta: true, shift: true, global: false}), allowGlobal: true}];
const override = custom({key: 'F13', code: 'F13', global: true}, true);
expect(buildDefaultRuntimeKeybinds(defaults, new Set())).toEqual(defaults);
expect(buildDefaultRuntimeKeybinds(defaults, getCustomActionOverrides([override]))).toEqual([]);
expect(buildCustomRuntimeKeybinds([override], resolver(defaults))).toEqual([
{
...defaults[0],
combo: override.combo,
},
]);
});
});
@@ -1,30 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {normalizeGifProviderInfo} from '@app/features/app/state/GifProviderConfig';
import {describe, expect, it} from 'vitest';
describe('normalizeGifProviderInfo', () => {
it('uses Klipy as the default GIF provider display name', () => {
expect(normalizeGifProviderInfo()).toEqual({
name: 'klipy',
displayName: 'Klipy',
attributionRequired: false,
});
});
it('uses the canonical Tenor display name for the tenor provider', () => {
expect(normalizeGifProviderInfo({provider: 'tenor', attributionRequired: false})).toEqual({
name: 'tenor',
displayName: 'Tenor',
attributionRequired: false,
});
});
it('falls back unknown provider names to Klipy', () => {
expect(normalizeGifProviderInfo({name: 'other'})).toEqual({
name: 'klipy',
displayName: 'Klipy',
attributionRequired: false,
});
});
});
@@ -1,313 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {UpdaterEvent} from '@app/types/electron.d';
import {afterEach, describe, expect, test, vi} from 'vitest';
interface ManualUpdatePrompt {
options: ReadonlyArray<UpdaterDownloadOption>;
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
}
const {
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
} = vi.hoisted(() => ({
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
pushUnsupportedUpdateModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateReadyModal: vi.fn(),
pushUpToDateModal: vi.fn(),
}));
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
pushDesktopUpdateDownloadFailedModal: vi.fn(),
pushDesktopUpdateInstallFailedModal: vi.fn(),
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateAvailableModal: vi.fn(),
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
}));
vi.mock('@lingui/core/macro', () => ({
msg: (descriptor: {message: string}) => descriptor,
}));
vi.mock('@app/features/platform/utils/ClientInfo', () => ({
getClientInfo: () =>
Promise.resolve({desktopVersion: '1.0.0', desktopChannel: 'canary', desktopArch: 'x64', arch: 'x64'}),
}));
installVoiceMenuTestBootstrap();
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const REPORTED_DEB_DOWNLOAD = [
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
];
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
let loadedUpdater: {dispose: () => void} | null = null;
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
Promise.resolve({success: true}),
);
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
function installElectronApi(platform: string): void {
nativeEventListener = null;
onUpdaterCheck = null;
(window as unknown as {electron: unknown}).electron = {
platform,
buildChannel: 'canary',
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
nativeEventListener = listener;
return () => {
nativeEventListener = null;
};
},
updaterCheck,
updaterDownload: () => Promise.resolve(),
updaterInstall: () => Promise.resolve(),
downloadFile,
openExternal,
};
}
function emit(event: UpdaterEvent): void {
if (!nativeEventListener) throw new Error('Updater never subscribed to native updater events');
nativeEventListener(event);
}
async function loadUpdater(platform = 'win32') {
vi.resetModules();
installElectronApi(platform);
const {default: Updater} = await import('@app/features/app/state/Updater');
loadedUpdater = Updater;
await vi.waitFor(() => {
expect(nativeEventListener).not.toBeNull();
expect(Updater.lastCheckedAt).not.toBeNull();
});
vi.clearAllMocks();
return Updater;
}
function emitUserDownloadCompletion(version: string): void {
emit({type: 'available', context: 'user', version, downloadSize: 1000, downloadStarted: true});
emit({type: 'downloaded', context: 'user', version});
}
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
emit({
type: 'available',
context,
version,
downloadStarted: false,
downloadUrl: `${CANARY_X64}/latest/appimage`,
downloadOptions: [
{
format: 'deb',
label: 'DEB package',
url: `${CANARY_X64}/latest/deb`,
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
],
});
}
async function openLinuxManualUpdatePrompt() {
const Updater = await loadUpdater('linux');
emitLinuxManualUpdate('background', REPORTED_VERSION);
await Updater.applyUpdate();
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
const debOption = prompt?.options.find((option) => option.format === 'deb');
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
}
afterEach(() => {
vi.useRealTimers();
loadedUpdater?.dispose();
loadedUpdater = null;
});
describe('updater update-ready surface', () => {
test('does not push a blocking modal when a user-initiated download finishes outside a check', async () => {
const Updater = await loadUpdater();
emitUserDownloadCompletion('2.0.0');
expect(Updater.nativeUpdateReady).toBe(true);
expect(pushUpdateReadyModal).not.toHaveBeenCalled();
});
test('announces the ready update through a dismissible nagbar instead', async () => {
const Updater = await loadUpdater();
expect(Updater.shouldShowUpdateReadyNagbar).toBe(false);
emitUserDownloadCompletion('2.0.0');
expect(Updater.shouldShowUpdateReadyNagbar).toBe(true);
Updater.dismissUpdateReadyNagbar();
expect(Updater.shouldShowUpdateReadyNagbar).toBe(false);
emit({type: 'downloaded', context: 'background', version: '2.1.0'});
expect(Updater.shouldShowUpdateReadyNagbar).toBe(true);
});
test('still answers a user-initiated check with the update ready modal', async () => {
const Updater = await loadUpdater();
emitUserDownloadCompletion('2.0.0');
pushUpdateReadyModal.mockClear();
onUpdaterCheck = () => emit({type: 'available', context: 'user', version: '2.0.0', downloadStarted: false});
await Updater.checkForUpdates(true, true);
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
});
});
describe('updater manual Linux package download', () => {
test('refreshes the check as the user and saves a newer build under its own name', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => {
emit({type: 'checking', context: 'user'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(Updater.displayVersion).toBe(NEWER_VERSION);
expect(Updater.isChecking).toBe(false);
});
test('saves the build the prompt named when the refresh fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh finds no update', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpToDateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh does not answer in time', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(Updater.isChecking).toBe(false);
});
test.each([
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
['no update', () => emit({type: 'not-available', context: 'user'})],
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
answerLate();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpToDateModal).not.toHaveBeenCalled();
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const firstDownload = download();
await vi.advanceTimersByTimeAsync(5_000);
await firstDownload;
onUpdaterCheck = () => {
emit({type: 'error', context: 'user', message: 'fetch failed'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('still shows the failure of a user check made while a package is being saved', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
void download();
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await Updater.checkForUpdates(true, true);
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
});
test('does not refresh when a check is already running at the click', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
void Updater.checkForUpdates(true);
await download();
expect(updaterCheck).not.toHaveBeenCalled();
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
});
test('saves one package when the button is pressed twice during the refresh', async () => {
const {download} = await openLinuxManualUpdatePrompt();
let answerRefresh = () => {};
onUpdaterCheck = () =>
new Promise<void>((resolve) => {
answerRefresh = () => {
emitLinuxManualUpdate('user', NEWER_VERSION);
resolve();
};
});
const firstClick = download();
const secondClick = download();
answerRefresh();
await Promise.all([firstClick, secondClick]);
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
});
test('opens the pinned link and never the download page when saving fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockResolvedValueOnce({success: false});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
});
});
@@ -1,25 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
shouldShowNativeDesktopUpdateDownloadProgress,
shouldShowNativeDesktopUpdateInApp,
} from '@app/features/app/utils/UpdaterPlatformUtils';
import {describe, expect, it} from 'vitest';
describe('UpdaterPlatformUtils', () => {
it('surfaces native desktop update availability in-app on Linux, macOS, and Windows', () => {
expect(shouldShowNativeDesktopUpdateInApp('linux')).toBe(true);
expect(shouldShowNativeDesktopUpdateInApp('darwin')).toBe(true);
expect(shouldShowNativeDesktopUpdateInApp('win32')).toBe(true);
});
it('does not surface native desktop update availability for unknown renderers', () => {
expect(shouldShowNativeDesktopUpdateInApp(undefined)).toBe(false);
expect(shouldShowNativeDesktopUpdateInApp(null)).toBe(false);
});
it('only surfaces native desktop download progress on Windows', () => {
expect(shouldShowNativeDesktopUpdateDownloadProgress('win32')).toBe(true);
expect(shouldShowNativeDesktopUpdateDownloadProgress('darwin')).toBe(false);
expect(shouldShowNativeDesktopUpdateDownloadProgress('linux')).toBe(false);
expect(shouldShowNativeDesktopUpdateDownloadProgress(undefined)).toBe(false);
});
});