fix(web): пинг участника показывается только при наведении

Постоянная подпись в плитке занимала место и была не нужна: задержка теперь
живёт в подсказке индикатора связи («Отличное соединение, 46 мс — задержка до
голосового сервера»), а плитка осталась компактной. Тесты и e2e проверяют
`title`/`aria-label` индикатора вместо видимого текста.
This commit is contained in:
2026-09-20 22:30:24 +03:00
parent 96acc40031
commit cc16c18b13
6 changed files with 29 additions and 50 deletions
+7 -7
View File
@@ -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);
+2 -29
View File
@@ -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>
);
}
/** /**
* Иконка состояния участника: перечёркнутая, когда флаг выключен. * Иконка состояния участника: перечёркнутая, когда флаг выключен.
* Подпись обязательна — по ней состояние читают и тесты, и скринридер. * Подпись обязательна — по ней состояние читают и тесты, и скринридер.
+2 -2
View File
@@ -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). */}
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -346,7 +346,7 @@
} }
}, },
"latency": "{{ms}} мс", "latency": "{{ms}} мс",
"latencyHint": "Пинг участника до инстанса", "latencyHint": "задержка до голосового сервера",
"latencyUnknown": "пинг неизвестен" "latencyUnknown": "пинг неизвестен"
}, },
"friends": { "friends": {
+16 -10
View File
@@ -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 },