Files
banban/talkingq-url/tests/test_voice_target_lifecycle.py
T

568 lines
26 KiB
Python

"""Regression for stale message intents swallowing the next AI recording."""
import asyncio
import struct
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import Mock
import pytest
from handlers import audio_packet_parser as parser
from handlers import audio_session_handler as audio_handler
from handlers import session_cleanup_handler as cleanup
from handlers import websocket_message_handler as messages
from handlers import websocket_handler as endpoint
from services import device_target_cache as targets_module
from services.audio_session import AudioSessionManager
from services.connection_manager import ConnectionManager
from services.device_target_cache import DeviceTargetCache
from services.target_audio_cache import TargetAudioCache
DEVICE = 'TalkingQ_local_voice'
PROCESS_PARENT_MESSAGE = messages.process_parent_leave_message
PROCESS_DEVICE_MESSAGE = messages.process_cached_audio
def packet(sid, kind, audio=b''):
return DEVICE.encode()+b'\0'+sid.encode()+b'\0'+struct.pack('<IBI', 1, kind, len(audio))+audio
@pytest.fixture
def state(monkeypatch):
now = [100.0]
connections, sessions, buffers = ConnectionManager(), AudioSessionManager(), TargetAudioCache()
targets = DeviceTargetCache(timeout_seconds=10, clock=lambda: now[0])
started, ended = [], []
ws = SimpleNamespace(send_text=AsyncMock(), accept=AsyncMock(), client='local',
client_state=SimpleNamespace(name='CONNECTED'))
for module in [messages, endpoint, targets_module]:
monkeypatch.setattr(module, 'connection_manager', connections)
for module in [messages, cleanup]:
monkeypatch.setattr(module, 'device_target_cache', targets)
for module in [messages, targets_module]:
monkeypatch.setattr(module, 'target_audio_cache', buffers)
for module in [messages, cleanup, parser]:
monkeypatch.setattr(module, 'audio_session_manager', sessions)
monkeypatch.setattr(cleanup, 'task_manager', SimpleNamespace(cancel_session_tasks=AsyncMock()))
monkeypatch.setattr(parser, 'get_asr', AsyncMock(side_effect=lambda _: SimpleNamespace(connected=False)))
monkeypatch.setattr(parser, 'get_llm', AsyncMock(side_effect=lambda _: SimpleNamespace(close=AsyncMock())))
monkeypatch.setattr(parser, 'get_tts', AsyncMock(side_effect=lambda _: SimpleNamespace(close=AsyncMock())))
async def prepare(session, device_id, sid):
started.append(sid)
session.asr_service.connected = True
session.send_queue = asyncio.Queue()
async def finish(session, key):
chunks = []
while not session.send_queue.empty():
chunks.append(session.send_queue.get_nowait())
ended.append((key[1], b''.join(chunks)))
monkeypatch.setattr(audio_handler, 'handle_start_packet', prepare)
monkeypatch.setattr(audio_handler, 'handle_end_packet', finish)
monkeypatch.setattr(messages, 'process_parent_leave_message', AsyncMock())
monkeypatch.setattr(messages, 'process_cached_audio', AsyncMock())
return SimpleNamespace(now=now, connections=connections, sessions=sessions, buffers=buffers,
targets=targets, ws=ws, started=started, ended=ended)
async def send(state, sid, kind, audio=b'', *, ws=None, active=None):
return await messages.handle_binary_message(ws or state.ws, DEVICE, 'LOCAL-SERIAL',
packet(sid, kind, audio), active, None)
async def set_target(state, kind):
if kind == 'parent':
await state.targets.set_parent_target(DEVICE, DEVICE)
else:
await state.targets.set_target(DEVICE, 'TalkingQ_receiver')
return await state.targets.get_voice_target(DEVICE)
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
@pytest.mark.parametrize('old_disconnect', ['before_connect', 'after_connect', 'after_start', 'none'])
async def test_fresh_command_survives_socket_replacement_and_completes_message(state, kind, old_disconnect):
"""Reproduce 03111: card command, then a new recording socket nine seconds later."""
old = state.ws
await state.connections.add_connection(DEVICE, old)
target = await set_target(state, kind)
deadline = target.expires_at
state.now[0] += 9
if old_disconnect == 'before_connect':
await state.connections.remove_connection(DEVICE, old)
await cleanup.cleanup_device_sessions(DEVICE, old)
new = SimpleNamespace(send_text=AsyncMock())
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
if old_disconnect == 'after_connect':
await cleanup.cleanup_device_sessions(DEVICE, old)
assert await state.targets.get_voice_target(DEVICE) is target
assert target.websocket is None
assert target.expires_at == deadline
await send(state, 'fresh-message', 1, ws=new)
if old_disconnect == 'after_start':
await cleanup.cleanup_device_sessions(DEVICE, old)
await send(state, 'old-late', 1, ws=old)
await send(state, 'fresh-message', 4, b'first-', ws=new)
await send(state, 'fresh-message', 4, b'tail', ws=new)
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
archived = []
async def process(*args, **kwargs):
archived.append(await state.buffers.get_audio_data(target.audio_cache_key))
processor.side_effect = process
await send(state, 'fresh-message', 2, ws=new)
processor.assert_awaited_once()
assert archived == [b'first-tail']
assert state.started == []
assert [c.args[0] for c in new.send_text.call_args_list] == ['START_ACK', 'END_ACK']
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
@pytest.mark.asyncio
async def test_unowned_message_reports_failure_without_starting_ai_or_consuming_new_intent(state):
await send(state, 'unowned', 1)
await send(state, 'unowned', 4, b'unowned-audio')
fresh = await set_target(state, 'parent')
await send(state, 'unowned', 0, b'wrong-mode')
await send(state, 'unowned', 2)
assert state.started == []
assert state.ended == []
assert await state.targets.get_voice_target(DEVICE) is fresh
replies = [c.args[0] for c in state.ws.send_text.call_args_list]
assert replies[:2] == ['START_ACK', 'END_ACK']
assert replies[2].endswith('/assets/audio/save_audio_fail.mp3')
messages.process_parent_leave_message.assert_not_awaited()
messages.process_cached_audio.assert_not_awaited()
@pytest.mark.asyncio
async def test_silent_ai_still_starts_and_finishes_once(state):
await send(state, 'silent-ai', 1)
await send(state, 'silent-ai', 2)
assert state.started == ['silent-ai']
assert state.ended == [('silent-ai', b'')]
assert [c.args[0] for c in state.ws.send_text.call_args_list] == ['START_ACK', 'END_ACK']
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
@pytest.mark.parametrize('previous', ['message_cancelled', 'message_disconnected', 'message_expired', 'ai_disconnected'])
async def test_incomplete_recording_then_fresh_message_saves_only_new_audio(state, kind, previous):
await state.connections.add_connection(DEVICE, state.ws)
old_target = None
if previous.startswith('message'):
old_target = await set_target(state, kind)
await send(state, 'incomplete', 1)
await send(state, 'incomplete', 0 if previous == 'ai_disconnected' else 4, b'old-audio')
if previous == 'message_cancelled':
await send(state, 'incomplete', 3)
elif previous == 'message_expired':
state.now[0] += 10
assert await state.targets.get_voice_target(DEVICE) is None
else:
session = await state.sessions.get_session((DEVICE, 'incomplete'))
session.set_interrupted = AsyncMock()
await state.connections.remove_connection(DEVICE, state.ws)
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
fresh = await set_target(state, kind)
new = SimpleNamespace(send_text=AsyncMock())
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
await send(state, 'fresh', 1, ws=new)
await send(state, 'fresh', 4, b'new-audio', ws=new)
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
archived = []
async def process(*args, **kwargs):
archived.append(await state.buffers.get_audio_data(fresh.audio_cache_key))
processor.side_effect = process
await send(state, 'fresh', 2, ws=new)
assert archived == [b'new-audio']
processor.assert_awaited_once()
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(fresh.audio_cache_key) is None
if old_target:
assert await state.buffers.get_audio_data(old_target.audio_cache_key) is None
assert state.started == (['incomplete'] if previous == 'ai_disconnected' else [])
@pytest.mark.asyncio
async def test_reconnect_does_not_extend_pending_target_lifetime(state):
target = await set_target(state, 'parent')
for _ in range(2):
new = SimpleNamespace(send_text=AsyncMock())
state.now[0] += 4
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
assert await state.targets.get_voice_target(DEVICE) is target
state.now[0] += 2
await state.targets.bind_connection(DEVICE, new)
assert await state.targets.get_voice_target(DEVICE) is None
await send(state, 'expired-message', 1, ws=new)
await send(state, 'expired-message', 4, b'late', ws=new)
await send(state, 'expired-message', 2, ws=new)
assert state.started == []
assert new.send_text.call_args.args[0].endswith('/assets/audio/save_audio_fail.mp3')
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
async def test_mqtt_command_then_replacement_ws_reaches_real_message_processor(state, monkeypatch, kind):
"""Use actual MQTT handlers, /ws and message processors; stub external I/O."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.websocket import router
from handlers import mqtt_handler as mqtt
monkeypatch.setattr(mqtt, 'device_target_cache', state.targets)
monkeypatch.setattr(messages, 'process_parent_leave_message', PROCESS_PARENT_MESSAGE)
monkeypatch.setattr(messages, 'process_cached_audio', PROCESS_DEVICE_MESSAGE)
monkeypatch.setattr(mqtt.card_service, 'get_card_by_uuid',
AsyncMock(return_value=SimpleNamespace(device_id='TalkingQ_receiver', status=1)))
monkeypatch.setattr(mqtt.card_service, 'is_active_for_device', AsyncMock(return_value=False))
upload = AsyncMock(return_value=SimpleNamespace(file_key='local-test/parent.wav'))
create = AsyncMock(return_value=(SimpleNamespace(child_name='local-test'), None, []))
notify = Mock()
save = AsyncMock(return_value=('http://localhost/peer.wav', '/local-test/peer.wav'))
archive = AsyncMock(return_value=SimpleNamespace(message_id=123, media_file_key='local-test/peer.wav'))
pending = AsyncMock()
monkeypatch.setattr(messages, 'upload_message_audio', upload)
monkeypatch.setattr(messages.im_conversation_service, 'create_device_parent_leave_message', create)
monkeypatch.setattr(messages.wechat_mp_notification_service, 'schedule_leave_message_notification', notify)
monkeypatch.setattr(messages.device_audio_cache_service, 'save_device_audio', save)
monkeypatch.setattr(messages.device_voice_archive_service, 'archive_peer_voice_message', archive)
monkeypatch.setattr(messages.pending_voice_message_service, 'add_pending_message', pending)
service = mqtt.TalkingQMQTTService({'broker': ''})
service._publish = AsyncMock(return_value=True)
async def authenticate(ws):
await state.connections.add_connection(DEVICE, ws)
await ws.send_text('LOCAL_AUTHENTICATED')
return True, DEVICE, 'LOCAL-SERIAL'
monkeypatch.setattr(endpoint, 'authenticate_websocket', authenticate)
app = FastAPI()
app.include_router(router)
@app.post('/local-command')
async def command():
if kind == 'parent':
await service._handle_short_press_message(DEVICE, {'params': {'uuid': DEVICE}})
else:
await service._handle_nfc_listen_report(DEVICE, {'params': {'uuid': 'LOCAL-CARD'}})
return {'ok': True}
audio = b'\x01\x00' * 800
with TestClient(app) as client:
with client.websocket_connect('/ws') as old:
assert old.receive_text() == 'LOCAL_AUTHENTICATED'
assert client.post('/local-command').json() == {'ok': True}
payload = service._publish.call_args.args[1]
assert payload['msg_id'] == ('011' if kind == 'parent' else '005')
if kind == 'device':
assert payload['type'] == 1
state.now[0] += 9
with client.websocket_connect('/ws') as new:
assert new.receive_text() == 'LOCAL_AUTHENTICATED'
new.send_bytes(packet('replacement', 1))
assert new.receive_text() == 'START_ACK'
old.close()
new.send_bytes(packet('replacement', 4, audio[:800]))
new.send_bytes(packet('replacement', 4, audio[800:]))
new.send_bytes(packet('replacement', 2))
assert new.receive_text() == 'END_ACK'
assert new.receive_text().endswith('/assets/audio/message_ok.mp3')
assert state.started == []
assert await state.targets.get_voice_target(DEVICE) is None
assert not state.buffers.audio_data_cache
assert await state.sessions.get_all_sessions() == []
if kind == 'parent':
assert create.call_args.kwargs['audio_content'][44:] == audio
upload.assert_awaited_once()
notify.assert_called_once()
pending.assert_not_awaited()
else:
save.assert_awaited_once_with(audio, device_id='TalkingQ_receiver')
archive.assert_awaited_once_with(sender_device_id=DEVICE, receiver_device_id='TalkingQ_receiver',
local_audio_path='/local-test/peer.wav')
assert pending.call_args.kwargs['im_message_id'] == 123
assert pending.call_args.kwargs['sender_device_id'] == DEVICE
assert pending.call_args.kwargs['target_device_id'] == 'TalkingQ_receiver'
create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
async def test_ai_audio_recovers_from_pending_message_without_losing_first_chunk(state, kind):
target = await set_target(state, kind)
await send(state, 'ai', 1)
assert state.started == []
await send(state, 'ai', 0, b'first-audio')
await send(state, 'ai', 0, b'-tail')
await send(state, 'ai', 2)
assert state.started == ['ai']
assert state.ended == [('ai', b'first-audio-tail')]
assert [c.args[0] for c in state.ws.send_text.call_args_list] == ['START_ACK', 'END_ACK']
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
messages.process_parent_leave_message.assert_not_awaited()
messages.process_cached_audio.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
@pytest.mark.parametrize('outcome', ['success', 'empty', 'error', 'cancelled'])
async def test_message_terminal_paths_release_only_their_recording(state, kind, outcome):
target = await set_target(state, kind)
await send(state, 'message', 1)
if outcome != 'empty':
await send(state, 'message', 4, b'message-audio')
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
if outcome == 'error':
processor.side_effect = RuntimeError('upload failed')
with pytest.raises(RuntimeError, match='upload failed'):
await send(state, 'message', 2)
else:
await send(state, 'message', 3 if outcome == 'cancelled' else 2)
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
assert state.started == []
if outcome == 'cancelled':
processor.assert_not_awaited()
assert [c.args[0] for c in state.ws.send_text.call_args_list][-2:] == ['INTERRUPT_ACK', 'INTERRUPT_PROCESSED']
else:
processor.assert_awaited_once()
await send(state, 'next-ai', 1)
await send(state, 'next-ai', 0, b'next-question')
await send(state, 'next-ai', 2)
assert state.ended == [('next-ai', b'next-question')]
@pytest.mark.asyncio
async def test_cancelled_message_cannot_receive_late_audio_or_end(state):
await set_target(state, 'parent')
await send(state, 'cancelled', 1)
await send(state, 'cancelled', 4, b'old')
await send(state, 'cancelled', 3)
await set_target(state, 'parent')
fresh = await state.targets.get_voice_target(DEVICE)
await send(state, 'fresh', 1)
await send(state, 'fresh', 4, b'new')
await send(state, 'cancelled', 4, b'late')
await send(state, 'cancelled', 2)
assert await state.targets.get_voice_target(DEVICE) is fresh
assert await state.buffers.get_audio_data(fresh.audio_cache_key) == b'new'
messages.process_parent_leave_message.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('claimed', [False, True])
async def test_timeout_clears_pending_or_partial_message_and_allows_ai(state, claimed):
target = await set_target(state, 'parent')
if claimed:
await send(state, 'expired', 1)
await send(state, 'expired', 4, b'partial')
state.now[0] += 11
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
await send(state, 'ai', 1)
await send(state, 'ai', 0, b'question')
await send(state, 'ai', 2)
assert state.ended == [('ai', b'question')]
@pytest.mark.asyncio
async def test_active_message_refreshes_idle_timeout(state):
target = await set_target(state, 'parent')
await send(state, 'message', 1)
for _ in range(4):
state.now[0] += 9
await send(state, 'message', 4, b'a')
assert await state.targets.get_voice_target(DEVICE) is target
assert await state.buffers.get_audio_data(target.audio_cache_key) == b'aaaa'
@pytest.mark.asyncio
async def test_replacement_during_upload_survives_old_recording_cleanup(state):
old = await set_target(state, 'parent')
await send(state, 'old', 1)
await send(state, 'old', 4, b'old-audio')
replacement = []
async def upload(*args):
await set_target(state, 'parent')
replacement.append(await state.targets.get_voice_target(DEVICE))
messages.process_parent_leave_message.side_effect = upload
await send(state, 'old', 2)
assert await state.targets.get_voice_target(DEVICE) is replacement[0]
assert replacement[0].audio_cache_key != old.audio_cache_key
assert await state.buffers.get_audio_data(old.audio_cache_key) is None
@pytest.mark.asyncio
async def test_new_message_command_during_ai_does_not_hijack_its_end(state):
await send(state, 'ai', 1)
await send(state, 'ai', 0, b'question')
target = await set_target(state, 'parent')
await send(state, 'ai', 2)
assert state.ended == [('ai', b'question')]
assert await state.targets.get_voice_target(DEVICE) is target
messages.process_parent_leave_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_two_senders_to_same_receiver_use_separate_buffers(state):
await state.targets.set_target(DEVICE, 'receiver')
await state.targets.set_target('other-sender', 'receiver')
a = await state.targets.claim_target(DEVICE, state.ws, 'a')
b = await state.targets.claim_target('other-sender', state.ws, 'b')
await state.targets.append_audio(DEVICE, a, state.ws, 'a', b'one')
await state.targets.append_audio('other-sender', b, state.ws, 'b', b'two')
await state.targets.remove_target(DEVICE, expected=a)
assert await state.buffers.get_audio_data(b.audio_cache_key) == b'two'
@pytest.mark.asyncio
@pytest.mark.parametrize('claimed', [False, True])
async def test_disconnect_only_clears_target_claimed_by_that_recording(state, claimed):
await state.connections.add_connection(DEVICE, state.ws)
target = await set_target(state, 'parent')
if claimed:
await send(state, 'message', 1)
await send(state, 'message', 4, b'partial')
session = await state.sessions.get_session((DEVICE, 'message'))
session.set_interrupted = AsyncMock()
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
assert await state.targets.get_voice_target(DEVICE) is (None if claimed else target)
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
assert await state.sessions.get_all_sessions() == []
@pytest.mark.asyncio
async def test_old_socket_cleanup_and_late_packets_preserve_new_connection(state, monkeypatch):
old = state.ws
new = SimpleNamespace(send_text=AsyncMock())
async def authenticate(ws):
await state.connections.add_connection(DEVICE, old)
return True, DEVICE, 'LOCAL'
async def replacement_connects(*args):
await state.connections.add_connection(DEVICE, new)
await set_target(state, 'parent')
await send(state, 'new', 1, ws=new)
await send(state, 'new', 4, b'new-audio', ws=new)
await send(state, 'old-late-start', 1, ws=old)
await send(state, 'old-late-end', 2, ws=old)
monkeypatch.setattr(endpoint, 'authenticate_websocket', authenticate)
monkeypatch.setattr(endpoint, 'handle_websocket_messages', replacement_connects)
await endpoint.websocket_endpoint(old)
target = await state.targets.get_voice_target(DEVICE)
assert target.websocket is new
assert await state.buffers.get_audio_data(target.audio_cache_key) == b'new-audio'
assert await state.connections.get_connection(DEVICE) is new
assert await state.sessions.get_session((DEVICE, 'new')) is not None
cleanup.task_manager.cancel_session_tasks.assert_not_awaited()
@pytest.mark.asyncio
async def test_pending_command_survives_early_disconnect_without_extending_deadline(state):
target = await set_target(state, 'parent')
assert target.websocket is None
await state.connections.add_connection(DEVICE, state.ws)
await state.targets.bind_connection(DEVICE, state.ws)
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
assert await state.targets.get_voice_target(DEVICE) is target
assert target.websocket is None
state.now[0] += 10
assert await state.targets.get_voice_target(DEVICE) is None
@pytest.mark.asyncio
async def test_reconnect_discards_old_partial_message(state):
await state.connections.add_connection(DEVICE, state.ws)
old = await set_target(state, 'parent')
await send(state, 'old', 1)
await send(state, 'old', 4, b'old')
new = SimpleNamespace(send_text=AsyncMock())
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(old.audio_cache_key) is None
await send(state, 'new-ai', 1, ws=new)
await send(state, 'new-ai', 0, b'reconnected-question', ws=new)
await send(state, 'new-ai', 2, ws=new)
assert state.ended == [('new-ai', b'reconnected-question')]
@pytest.mark.asyncio
@pytest.mark.parametrize('outcome', ['success', 'empty', 'upload_error'])
async def test_real_parent_message_processing_keeps_notifications_and_clears_state(state, monkeypatch, outcome):
monkeypatch.setattr(messages, 'process_parent_leave_message', PROCESS_PARENT_MESSAGE)
upload = AsyncMock(return_value=SimpleNamespace(file_key='local-test/audio.wav'))
create = AsyncMock(return_value=(SimpleNamespace(child_name='local-test'), None, []))
notify = Mock()
if outcome == 'upload_error':
upload.side_effect = RuntimeError('simulated upload failure')
monkeypatch.setattr(messages, 'upload_message_audio', upload)
monkeypatch.setattr(messages.im_conversation_service, 'create_device_parent_leave_message', create)
monkeypatch.setattr(messages.wechat_mp_notification_service, 'schedule_leave_message_notification', notify)
await state.connections.add_connection(DEVICE, state.ws)
target = await set_target(state, 'parent')
await send(state, 'parent', 1)
if outcome != 'empty':
await send(state, 'parent', 4, b'\x01\x00'*400)
await send(state, 'parent', 2)
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
if outcome == 'success':
upload.assert_awaited_once()
create.assert_awaited_once()
assert create.call_args.kwargs['audio_content'].startswith(b'RIFF')
notify.assert_called_once()
assert state.ws.send_text.call_args.args[0].startswith('PROMPT_SOUND_URL:')
else:
create.assert_not_awaited()
notify.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('first_kind', [0, 4])
async def test_recording_cannot_switch_between_ai_and_message_after_audio(state, first_kind):
target = await set_target(state, 'parent')
await send(state, 'mixed', 1)
await send(state, 'mixed', first_kind, b'first')
await send(state, 'mixed', 4 if first_kind == 0 else 0, b'wrong-mode')
if first_kind == 0:
await send(state, 'mixed', 2)
assert state.ended == [('mixed', b'first')]
messages.process_parent_leave_message.assert_not_awaited()
else:
assert await state.buffers.get_audio_data(target.audio_cache_key) == b'first'
assert state.started == []
await send(state, 'mixed', 2)
messages.process_parent_leave_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_new_recording_recovers_abandoned_message_without_reusing_audio(state):
abandoned = await set_target(state, 'parent')
await send(state, 'abandoned', 1)
await send(state, 'abandoned', 4, b'abandoned-audio')
await send(state, 'new-ai', 1)
await send(state, 'new-ai', 0, b'question')
await send(state, 'abandoned', 2, active=(DEVICE, 'new-ai'))
await send(state, 'new-ai', 2)
assert state.ended == [('new-ai', b'question')]
assert await state.buffers.get_audio_data(abandoned.audio_cache_key) is None
messages.process_parent_leave_message.assert_not_awaited()
assert not any('save_audio_fail.mp3' in c.args[0] for c in state.ws.send_text.call_args_list)