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