feat: complete bark battle playable demo

This commit is contained in:
2026-05-12 14:42:58 +08:00
parent 22810245f5
commit 33c9079d3b
16 changed files with 639 additions and 196 deletions

View File

@@ -1,4 +1,4 @@
import { type BarkBattleSession,createBarkBattleSession } from '../domain/BarkBattleSession';
import { type BarkBattleSession, createBarkBattleSession } from '../domain/BarkBattleSession';
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
import { BarkDetector } from '../domain/BarkDetector';
import type { BarkBattleConfig } from './BarkBattleConfig';
@@ -17,6 +17,10 @@ export class BarkBattleController {
return this.session.snapshot;
}
getSampleClockMs() {
return this.sampleClockMs;
}
updateConfig(config: BarkBattleConfig) {
this.config = config;
this.restart();
@@ -38,13 +42,32 @@ export class BarkBattleController {
this.sampleClockMs = 0;
}
submitMockSample(volume: number) {
const events = this.detector.acceptSample({ atMs: this.sampleClockMs, volume });
forcePlayerBark(volume = 0.9) {
if (this.session.snapshot.phase !== 'playing') {
this.session = this.session.startMockRound();
}
if (this.session.snapshot.phase === 'countdown') {
this.session = this.session.tick(this.session.snapshot.countdownMs);
}
this.session = this.session.applyPlayerBark({
side: 'player',
atMs: this.sampleClockMs,
peakVolume: volume,
durationMs: this.config.minBarkDurationMs,
});
}
submitInputSample(volume: number, atMs = this.sampleClockMs) {
const events = this.detector.acceptSample({ atMs, volume });
for (const event of events) {
this.session = this.session.applyPlayerBark(event);
}
}
submitMockSample(volume: number) {
this.submitInputSample(volume);
}
tick(deltaMs: number) {
this.sampleClockMs += deltaMs;
this.session = this.session.tick(deltaMs);
@@ -64,8 +87,6 @@ export class BarkBattleController {
return new BarkDetector({
threshold: this.config.barkThreshold,
minBarkGapMs: this.config.minBarkGapMs,
minBarkDurationMs: this.config.minBarkDurationMs,
maxBarkDurationMs: this.config.maxBarkDurationMs,
});
}
}

View File

@@ -54,4 +54,22 @@ describe('BarkBattleController', () => {
expect(controller.getSnapshot().energy).toBe(0);
expect(controller.getSnapshot().result).toBeNull();
});
it('真实输入采样可使用高精度采样时间戳连续触发 100ms 级别叫声', () => {
const controller = new BarkBattleController({
...DEFAULT_BARK_BATTLE_CONFIG,
countdownMs: 0,
barkThreshold: 0.5,
minBarkDurationMs: 40,
minBarkGapMs: 100,
});
controller.startWithMockInput();
controller.submitInputSample(0.82, 0);
controller.submitInputSample(0.1, 60);
controller.submitInputSample(0.9, 120);
controller.submitInputSample(0.1, 180);
expect(controller.getSnapshot().player.barkCount).toBe(2);
});
});

View File

@@ -3,59 +3,31 @@ import type { BarkAudioSample, BarkBattleEvent } from './BarkBattleTypes';
export type BarkDetectorConfig = {
threshold: number;
minBarkGapMs: number;
minBarkDurationMs: number;
maxBarkDurationMs: number;
};
type ActiveBark = {
startMs: number;
peakVolume: number;
};
export class BarkDetector {
private activeBark: ActiveBark | null = null;
private lastAcceptedAtMs = Number.NEGATIVE_INFINITY;
constructor(private readonly config: BarkDetectorConfig) {}
acceptSample(sample: BarkAudioSample): BarkBattleEvent[] {
const volume = clamp01(sample.volume);
if (volume >= this.config.threshold) {
this.activeBark = this.activeBark
? {
startMs: this.activeBark.startMs,
peakVolume: Math.max(this.activeBark.peakVolume, volume),
}
: {
startMs: sample.atMs,
peakVolume: volume,
};
if (volume < this.config.threshold) {
return [];
}
if (!this.activeBark) {
return [];
}
const activeBark = this.activeBark;
this.activeBark = null;
const durationMs = sample.atMs - activeBark.startMs;
const accepted =
durationMs >= this.config.minBarkDurationMs &&
durationMs <= this.config.maxBarkDurationMs &&
activeBark.startMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs;
const accepted = sample.atMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs;
if (!accepted) {
return [];
}
this.lastAcceptedAtMs = activeBark.startMs;
this.lastAcceptedAtMs = sample.atMs;
return [
{
side: 'player',
atMs: activeBark.startMs,
peakVolume: activeBark.peakVolume,
durationMs,
atMs: sample.atMs,
peakVolume: volume,
durationMs: 0,
},
];
}

View File

@@ -4,30 +4,23 @@ import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
import { BarkDetector } from '../BarkDetector';
describe('BarkDetector', () => {
it('超过阈值且持续时长合规时只计为一次有效叫声', () => {
it('每个监测点只检测瞬时响度,超过阈值立即触发', () => {
const detector = new BarkDetector({
threshold: 0.45,
minBarkGapMs: DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs,
minBarkDurationMs: 90,
maxBarkDurationMs: 900,
});
expect(detector.acceptSample({ atMs: 0, volume: 0.2 })).toEqual([]);
expect(detector.acceptSample({ atMs: 40, volume: 0.72 })).toEqual([]);
expect(detector.acceptSample({ atMs: 150, volume: 0.76 })).toEqual([]);
const events = detector.acceptSample({ atMs: 180, volume: 0.2 });
const events = detector.acceptSample({ atMs: 40, volume: 0.72 });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ side: 'player', peakVolume: 0.76 });
expect(events[0]?.durationMs).toBe(140);
expect(events[0]).toMatchObject({ side: 'player', atMs: 40, peakVolume: 0.72, durationMs: 0 });
});
it('持续噪音不会在每个 tick 无限计数', () => {
it('持续噪音按冷却间隔触发,不需要等待响度回落', () => {
const detector = new BarkDetector({
threshold: 0.4,
minBarkGapMs: 250,
minBarkDurationMs: 80,
maxBarkDurationMs: 600,
});
const allEvents = [
@@ -36,27 +29,55 @@ describe('BarkDetector', () => {
...detector.acceptSample({ atMs: 200, volume: 0.73 }),
...detector.acceptSample({ atMs: 300, volume: 0.75 }),
...detector.acceptSample({ atMs: 500, volume: 0.2 }),
...detector.acceptSample({ atMs: 560, volume: 0.76 }),
];
expect(allEvents).toHaveLength(1);
expect(allEvents.map((event) => event.atMs)).toEqual([0, 300, 560]);
});
it('低于阈值的背景噪音、过短脉冲和冷却内峰值不计数', () => {
it('低于阈值的背景噪音和冷却内峰值不计数,最短持续时长不再参与判断', () => {
const detector = new BarkDetector({
threshold: 0.5,
minBarkGapMs: 300,
minBarkDurationMs: 80,
maxBarkDurationMs: 800,
});
expect(detector.acceptSample({ atMs: 0, volume: 0.48 })).toEqual([]);
detector.acceptSample({ atMs: 20, volume: 0.9 });
expect(detector.acceptSample({ atMs: 60, volume: 0.2 })).toEqual([]);
expect(detector.acceptSample({ atMs: 20, volume: 0.9 })).toHaveLength(1);
expect(detector.acceptSample({ atMs: 60, volume: 0.95 })).toEqual([]);
detector.acceptSample({ atMs: 500, volume: 0.88 });
expect(detector.acceptSample({ atMs: 620, volume: 0.2 })).toHaveLength(1);
expect(detector.acceptSample({ atMs: 320, volume: 0.88 })).toHaveLength(1);
expect(detector.acceptSample({ atMs: 420, volume: 0.2 })).toEqual([]);
});
detector.acceptSample({ atMs: 700, volume: 0.9 });
expect(detector.acceptSample({ atMs: 820, volume: 0.2 })).toEqual([]);
it('支持 100ms 级别间隔的快速连续有效叫声', () => {
const detector = new BarkDetector({
threshold: 0.5,
minBarkGapMs: 100,
});
const allEvents = [
...detector.acceptSample({ atMs: 0, volume: 0.86 }),
...detector.acceptSample({ atMs: 60, volume: 0.9 }),
...detector.acceptSample({ atMs: 120, volume: 0.91 }),
...detector.acceptSample({ atMs: 180, volume: 0.92 }),
...detector.acceptSample({ atMs: 240, volume: 0.93 }),
];
expect(allEvents).toHaveLength(3);
expect(allEvents.map((event) => event.atMs)).toEqual([0, 120, 240]);
});
it('非有限音量会归零,超过 1 的音量会夹到 1', () => {
const detector = new BarkDetector({
threshold: 0.5,
minBarkGapMs: 100,
});
expect(detector.acceptSample({ atMs: 0, volume: Number.NaN })).toEqual([]);
expect(detector.acceptSample({ atMs: 120, volume: Number.POSITIVE_INFINITY })).toEqual([]);
const events = detector.acceptSample({ atMs: 240, volume: 2 });
expect(events).toHaveLength(1);
expect(events[0]?.peakVolume).toBe(1);
});
});

View File

@@ -22,3 +22,64 @@ export function isMicrophoneApiSupported(windowLike: { isSecureContext?: boolean
export function stopMediaStreamTracks(stream: MediaStream) {
stream.getTracks().forEach((track) => track.stop());
}
export type BrowserMicrophoneSampler = {
stop: () => void;
};
export type BrowserMicrophoneVolumeHandler = (volume: number, atMs: number) => void;
export async function startBrowserMicrophoneSampler(onVolume: BrowserMicrophoneVolumeHandler): Promise<BrowserMicrophoneSampler> {
const supported = isMicrophoneApiSupported(window);
if (!supported.ok) {
throw Object.assign(new Error(supported.reason), { reason: supported.reason });
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioContextCtor) {
stopMediaStreamTracks(stream);
throw Object.assign(new Error('audio-context-blocked'), { reason: 'audio-context-blocked' });
}
const audioContext = new AudioContextCtor();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const data = new Uint8Array(analyser.fftSize);
const sampleStartedAtMs = window.performance.now();
let rafId = 0;
const sample = () => {
analyser.getByteTimeDomainData(data);
let sum = 0;
for (const value of data) {
const centered = (value - 128) / 128;
sum += centered * centered;
}
const volume = Math.min(1, Math.sqrt(sum / data.length) * 3.5);
onVolume(volume, window.performance.now() - sampleStartedAtMs);
rafId = window.requestAnimationFrame(sample);
};
sample();
return {
stop: () => {
window.cancelAnimationFrame(rafId);
source.disconnect();
void audioContext.close();
stopMediaStreamTracks(stream);
},
};
} catch (error) {
const reason = error && typeof error === 'object' && 'reason' in error ? (error as { reason: MicrophoneFailureReason }).reason : mapGetUserMediaError(error);
throw Object.assign(new Error(reason), { reason });
}
}
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}

View File

@@ -155,18 +155,24 @@
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 8;
width: min(92vw, 340px);
max-height: 42svh;
overflow: auto;
width: min(78vw, 240px);
max-height: 56px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 22px;
padding: 12px;
padding: 10px 12px;
color: #fff7ed;
background: rgba(15, 23, 42, 0.72);
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.bark-battle-debug-panel--expanded {
width: min(92vw, 340px);
max-height: 42svh;
overflow: auto;
}
.bark-battle-debug-panel header,
.bark-battle-debug-panel label {
display: flex;
@@ -175,6 +181,28 @@
gap: 10px;
}
.bark-battle-debug-panel header {
min-height: 34px;
}
.bark-battle-debug-panel__toggle {
border: 0;
border-radius: 999px;
padding: 6px 10px;
color: #1f1147;
background: #facc15;
font-size: 12px;
font-weight: 900;
}
.bark-battle-debug-panel__body {
display: none;
}
.bark-battle-debug-panel--expanded .bark-battle-debug-panel__body {
display: block;
}
.bark-battle-debug-panel label {
margin-top: 8px;
font-size: 12px;
@@ -211,6 +239,10 @@
gap: 6px;
}
.bark-battle-debug-metrics__wide {
grid-column: 1 / -1;
}
.bark-battle-debug-events {
display: grid;
gap: 4px;

View File

@@ -5,6 +5,11 @@ import {
DEFAULT_BARK_BATTLE_CONFIG,
} from '../application/BarkBattleConfig';
import { BarkBattleController } from '../application/BarkBattleController';
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
import {
type BrowserMicrophoneSampler,
startBrowserMicrophoneSampler,
} from '../infrastructure/BrowserMicrophoneInput';
import { BarkBattleHud } from './BarkBattleHud';
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
@@ -42,6 +47,22 @@ const DEBUG_CONFIG_FIELDS: Array<{
{ key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 },
];
const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
'unsupported',
'permission-denied',
'non-secure-context',
'not-found',
'not-readable',
'audio-context-blocked',
'calibration-timeout',
'calibration-sample-unreadable',
'unknown',
]);
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason {
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
}
export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) {
const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG);
const controllerRef = useRef<BarkBattleController | null>(null);
@@ -51,6 +72,9 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
const controller = controllerRef.current;
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
const [particleText, setParticleText] = useState('');
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock');
const [liveInputVolume, setLiveInputVolume] = useState(0);
const [isDebugExpanded, setIsDebugExpanded] = useState(false);
const [playerPulseKey, setPlayerPulseKey] = useState(0);
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
@@ -58,6 +82,7 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
const lastPlayerBarkCountRef = useRef(0);
const lastOpponentPowerRef = useRef(0);
const debugEventIdRef = useRef(0);
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1;
@@ -80,6 +105,37 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
setSnapshot(nextSnapshot);
}, [appendDebugEvent, controller]);
const stopMicrophone = useCallback(() => {
microphoneSamplerRef.current?.stop();
microphoneSamplerRef.current = null;
}, []);
const startMicrophone = useCallback(async () => {
stopMicrophone();
try {
controller.startWithMockInput();
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
setLiveInputVolume(volume);
if (volume >= config.barkThreshold) {
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
}
controller.submitInputSample(volume, atMs);
});
microphoneSamplerRef.current = sampler;
setInputMode('microphone');
appendDebugEvent('真实麦克风已开启');
syncSnapshot();
} catch (error) {
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown';
const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown';
controller.failMicrophone(failureReason);
appendDebugEvent(`麦克风不可用:${failureReason}`);
syncSnapshot();
}
}, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]);
useEffect(() => stopMicrophone, [stopMicrophone]);
useEffect(() => {
controller.updateConfig(config);
syncSnapshot();
@@ -88,18 +144,24 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
useEffect(() => {
const timer = window.setInterval(() => {
controller.tick(100);
if (heldRef.current) {
controller.submitMockSample(0.88);
} else {
controller.submitMockSample(0.12);
if (inputMode === 'mock') {
if (heldRef.current) {
controller.submitMockSample(0.88);
} else {
controller.submitMockSample(0.12);
setLiveInputVolume(0);
}
}
syncSnapshot();
}, 100);
return () => window.clearInterval(timer);
}, [controller, syncSnapshot]);
}, [controller, inputMode, syncSnapshot]);
const restart = () => {
heldRef.current = false;
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
controller.restart();
setParticleText('');
setDebugEvents([]);
@@ -109,22 +171,25 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
};
const startMock = () => {
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
controller.startWithMockInput();
appendDebugEvent('开始 mock 对局');
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
syncSnapshot();
};
const finishNow = () => {
heldRef.current = false;
stopMicrophone();
controller.finishNow();
appendDebugEvent('人工结束对局');
syncSnapshot();
};
const bark = () => {
heldRef.current = true;
setPlayerPulseKey((current) => current + 1);
appendDebugEvent('按下模拟叫声按钮');
controller.forcePlayerBark(0.9);
syncSnapshot();
setParticleText('汪!');
window.setTimeout(() => setParticleText(''), 680);
};
@@ -135,50 +200,63 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
snapshot={snapshot}
playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey}
onStartMicrophone={startMock}
onStartMicrophone={startMicrophone}
onMockBark={bark}
onMockQuiet={() => {
heldRef.current = false;
}}
onRestart={restart}
/>
<aside className="bark-battle-debug-panel" aria-label="调试面板">
<aside className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`} aria-label="调试面板">
<header>
<strong></strong>
<button
type="button"
className="bark-battle-debug-panel__toggle"
aria-expanded={isDebugExpanded}
onClick={() => setIsDebugExpanded((current) => !current)}
>
{isDebugExpanded ? '收起' : '展开'}
</button>
<span>{snapshot.phase}</span>
</header>
<div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}></button>
<button type="button" onClick={finishNow}></button>
<button type="button" onClick={restart}></button>
<div className="bark-battle-debug-panel__body">
<div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}></button>
<button type="button" onClick={finishNow}></button>
<button type="button" onClick={restart}></button>
</div>
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
<span className="bark-battle-debug-metrics__wide">{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span>
<span>{(liveInputVolume * 100).toFixed(0)}%</span>
<span>{controller.getSampleClockMs()}ms</span>
<span>{snapshot.player.barkCount}</span>
<span>{(snapshot.player.power * 100).toFixed(0)}%</span>
<span>{(snapshot.opponent.power * 100).toFixed(0)}%</span>
<span>{Math.round(snapshot.energy)}</span>
</div>
<ol className="bark-battle-debug-events" aria-label="触发日志">
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li></li>}
</ol>
{DEBUG_CONFIG_FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<input
aria-label={field.label}
type="range"
min={field.min}
max={field.max}
step={field.step}
value={config[field.key]}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setConfig((current) => ({ ...current, [field.key]: value }));
}}
/>
<output>{config[field.key]}</output>
</label>
))}
</div>
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
<span>{snapshot.player.barkCount}</span>
<span>{(snapshot.player.power * 100).toFixed(0)}%</span>
<span>{(snapshot.opponent.power * 100).toFixed(0)}%</span>
<span>{Math.round(snapshot.energy)}</span>
</div>
<ol className="bark-battle-debug-events" aria-label="触发日志">
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li></li>}
</ol>
{DEBUG_CONFIG_FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<input
aria-label={field.label}
type="range"
min={field.min}
max={field.max}
step={field.step}
value={config[field.key]}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setConfig((current) => ({ ...current, [field.key]: value }));
}}
/>
<output>{config[field.key]}</output>
</label>
))}
</aside>
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
@@ -10,7 +10,12 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
render(<BarkBattleRuntimeShell />);
expect(screen.getByLabelText('调试面板')).toBeTruthy();
const debugPanel = screen.getByLabelText('调试面板');
expect(debugPanel).toBeTruthy();
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy();
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy();
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
@@ -22,9 +27,25 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
expect(screen.getByText(/countdown|playing/u)).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
expect(screen.getAllByText('按下模拟叫声按钮').length).toBeGreaterThan(0);
expect(screen.getAllByText(/ #1/u).length).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: '结束' }));
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
});
it('真实声控入口在不支持麦克风时展示失败原因mock 开始不请求权限', async () => {
render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
expect(screen.getAllByText(/unsupported/u).length).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(screen.getAllByText(/ mock /u).length).toBeGreaterThan(0);
expect(screen.getByText(/Mock /u)).toBeTruthy();
});
});