fix(web): пинг участника показывается только при наведении
Постоянная подпись в плитке занимала место и была не нужна: задержка теперь живёт в подсказке индикатора связи («Отличное соединение, 46 мс — задержка до голосового сервера»), а плитка осталась компактной. Тесты и e2e проверяют `title`/`aria-label` индикатора вместо видимого текста.
This commit is contained in:
@@ -340,14 +340,14 @@ test.describe('голос: два клиента в одной комнате',
|
|||||||
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пинг участника виден в плитке и обновляется: своё значение измеряется
|
// Пинг участника показывается при наведении на индикатор связи: своё
|
||||||
// по статистике WebRTC, чужое приходит по data-каналу LiveKit.
|
// значение измеряется по статистике WebRTC, чужое приходит по data-каналу.
|
||||||
for (const session of [adminSession, guestSession]) {
|
for (const session of [adminSession, guestSession]) {
|
||||||
const ownLabel = session.page
|
const ownQuality = session.page
|
||||||
.getByTestId(`voice-tile-${session === adminSession ? fixture.adminId : fixture.guestId}`)
|
.getByTestId(`voice-tile-${session === adminSession ? fixture.adminId : fixture.guestId}`)
|
||||||
.getByTestId('voice-quality-label');
|
.getByTestId('voice-quality');
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await ownLabel.textContent()) ?? '', { timeout: 30_000 })
|
.poll(async () => (await ownQuality.getAttribute('title')) ?? '', { timeout: 30_000 })
|
||||||
.toMatch(/\d+ мс/u);
|
.toMatch(/\d+ мс/u);
|
||||||
}
|
}
|
||||||
// Каждый видит пинг другого участника.
|
// Каждый видит пинг другого участника.
|
||||||
@@ -356,8 +356,8 @@ test.describe('голос: два клиента в одной комнате',
|
|||||||
async () =>
|
async () =>
|
||||||
(await adminSession.page
|
(await adminSession.page
|
||||||
.getByTestId(`voice-tile-${fixture.guestId}`)
|
.getByTestId(`voice-tile-${fixture.guestId}`)
|
||||||
.getByTestId('voice-quality-label')
|
.getByTestId('voice-quality')
|
||||||
.textContent()) ?? '',
|
.getAttribute('title')) ?? '',
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toMatch(/\d+ мс/u);
|
.toMatch(/\d+ мс/u);
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export function VoiceQualityDot({
|
|||||||
if (quality === null) {
|
if (quality === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const label = qualityLabel(t, quality, latencyMs);
|
// Пинг показываем только в подсказке: в плитке он не занимает место.
|
||||||
|
const label = `${qualityLabel(t, quality, latencyMs)} — ${t('voice.latencyHint')}`;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
@@ -42,34 +43,6 @@ export function VoiceQualityDot({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Точка качества с видимой подписью: «Отличное соединение, 42 мс» в плитке
|
|
||||||
* участника. Пинг обновляется раз в секунду вместе с состоянием связи.
|
|
||||||
*/
|
|
||||||
export function VoiceQualityBadge({
|
|
||||||
quality,
|
|
||||||
latencyMs,
|
|
||||||
}: {
|
|
||||||
quality: VoiceQuality | null;
|
|
||||||
latencyMs?: number | undefined;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (quality === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs text-fg-muted"
|
|
||||||
data-testid="voice-quality-label"
|
|
||||||
data-latency={latencyMs ?? ''}
|
|
||||||
title={t('voice.latencyHint')}
|
|
||||||
>
|
|
||||||
<VoiceQualityDot quality={quality} latencyMs={latencyMs} />
|
|
||||||
<span className="tabular-nums">{qualityLabel(t, quality, latencyMs)}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Иконка состояния участника: перечёркнутая, когда флаг выключен.
|
* Иконка состояния участника: перечёркнутая, когда флаг выключен.
|
||||||
* Подпись обязательна — по ней состояние читают и тесты, и скринридер.
|
* Подпись обязательна — по ней состояние читают и тесты, и скринридер.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Avatar } from '@/components/ui/Avatar';
|
|||||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||||
import { Card, Button } from '@/components/ui/primitives';
|
import { Card, Button } from '@/components/ui/primitives';
|
||||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||||
import { VoiceFlags, VoiceQualityBadge } from '@/components/voice/VoiceIndicators';
|
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
||||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||||
@@ -110,7 +110,7 @@ function VoiceTile({
|
|||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
||||||
{occupant.name}
|
{occupant.name}
|
||||||
</span>
|
</span>
|
||||||
<VoiceQualityBadge quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
<VoiceQualityDot quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
||||||
{occupant.isLocal ? (
|
{occupant.isLocal ? (
|
||||||
<>
|
<>
|
||||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
||||||
|
|||||||
@@ -346,7 +346,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"latency": "{{ms}} ms",
|
"latency": "{{ms}} ms",
|
||||||
"latencyHint": "Participant ping to the instance",
|
"latencyHint": "latency to the voice server",
|
||||||
"latencyUnknown": "ping unknown"
|
"latencyUnknown": "ping unknown"
|
||||||
},
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
|
|||||||
@@ -346,7 +346,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"latency": "{{ms}} мс",
|
"latency": "{{ms}} мс",
|
||||||
"latencyHint": "Пинг участника до инстанса",
|
"latencyHint": "задержка до голосового сервера",
|
||||||
"latencyUnknown": "пинг неизвестен"
|
"latencyUnknown": "пинг неизвестен"
|
||||||
},
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
|
|||||||
+16
-10
@@ -198,7 +198,7 @@ describe('голосовая комната: вход', () => {
|
|||||||
expect(voice.joinCount()).toBe(0);
|
expect(voice.joinCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('показывает пинг участника рядом с качеством связи', { timeout: 20_000 }, async () => {
|
it('показывает пинг участника в подсказке индикатора связи', { timeout: 20_000 }, async () => {
|
||||||
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
||||||
|
|
||||||
await clickChannel('Голосовая');
|
await clickChannel('Голосовая');
|
||||||
@@ -211,19 +211,24 @@ describe('голосовая комната: вход', () => {
|
|||||||
['pair', { type: 'candidate-pair', state: 'succeeded', currentRoundTripTime: 0.042 }],
|
['pair', { type: 'candidate-pair', state: 'succeeded', currentRoundTripTime: 0.042 }],
|
||||||
]);
|
]);
|
||||||
// Первый такт может прийтись на момент, когда микрофон ещё не опубликован,
|
// Первый такт может прийтись на момент, когда микрофон ещё не опубликован,
|
||||||
// поэтому ждём следующего (интервал публикации пинга — секунда).
|
// поэтому ждём следующего (интервал публикации пинга — секунда). Пинг виден
|
||||||
|
// только при наведении: он попадает в подсказку индикатора связи.
|
||||||
|
const ownQuality = () =>
|
||||||
|
within(screen.getByTestId('voice-tile-user-1')).getByTestId('voice-quality');
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(
|
expect(ownQuality()).toHaveAttribute(
|
||||||
within(screen.getByTestId('voice-tile-user-1')).getByTestId('voice-quality-label'),
|
'title',
|
||||||
).toHaveTextContent('42 мс');
|
expect.stringContaining('Отличное соединение, 42 мс'),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
);
|
);
|
||||||
const ownLabel = within(screen.getByTestId('voice-tile-user-1')).getByTestId(
|
expect(ownQuality()).toHaveAttribute('data-latency', '42');
|
||||||
'voice-quality-label',
|
expect(ownQuality()).toHaveAttribute(
|
||||||
|
'aria-label',
|
||||||
|
expect.stringContaining('задержка до голосового сервера'),
|
||||||
);
|
);
|
||||||
expect(ownLabel).toHaveTextContent('Отличное соединение, 42 мс');
|
|
||||||
|
|
||||||
// Второй участник подключается к комнате и присылает свой пинг.
|
// Второй участник подключается к комнате и присылает свой пинг.
|
||||||
const remote = room.addRemote('user-2', 'Bob');
|
const remote = room.addRemote('user-2', 'Bob');
|
||||||
@@ -237,8 +242,9 @@ describe('голосовая комната: вход', () => {
|
|||||||
const remoteTile = await screen.findByTestId('voice-tile-user-2');
|
const remoteTile = await screen.findByTestId('voice-tile-user-2');
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(within(remoteTile).getByTestId('voice-quality-label')).toHaveTextContent(
|
expect(within(remoteTile).getByTestId('voice-quality')).toHaveAttribute(
|
||||||
'Отличное соединение, 128 мс',
|
'title',
|
||||||
|
expect.stringContaining('Отличное соединение, 128 мс'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
|
|||||||
Reference in New Issue
Block a user