Files
banban/tools/test_conversation_timeout.py
T
chenjintao db6109172c feat: add device diagnostics for missing voice replies
Track microphone levels, short reads and PCM buffer drops. Report audio
queue admission separately from TLS writes, expose server replies and
ACKs before filtering, and capture per-round counters before timeouts.

Add diagnostic regression coverage and support POSIX hosts in WSS tests.
2026-10-26 18:35:24 +08:00

220 lines
13 KiB
Python

"""Run timeout, round and role-notice scheduling with scripted RTOS and transport."""
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parents[1]
source = (root / 'ql-application/threadx/interface/custom_app/ws_talkingq.c').read_text()
def function(name):
start = source.index(name + '(')
start = source.rfind('\n', 0, start) + 1
opening = source.index('{', start)
depth = 1
end = opening + 1
while depth:
depth += (source[end] == '{') - (source[end] == '}')
end += 1
return source[start:end] + '\n'
state_start = source.index('static ql_mutex_t conversation_mutex')
state_end = source.index('static volatile int player_mq_couter')
harness = r'''
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stddef.h>
#include <string.h>
typedef void *ql_mutex_t;
static int g_mic_working, recording, shine_init, fota_start;
enum { STATE_IDLE, STATE_RECORDING_WAIT_SOUND, STATE_RECORDING_ACTIVE, STATE_WAITING_COMMAND };
static int tts_playing, tts_play_status, audio_packet_type, no_speech_timeout;
static int power_down_status, sleeping, vad_state;
static int queue_failure, last_queue_type;
static char last_queue_url[256];
static int get_sleep_mode(void) { return sleeping; }
static int vad_audio_get_state(void) { return vad_state; }
static int queued, disconnected, flags_reset, mutex_locked, player_mq_couter, vad_starts;
static uint64_t now;
static unsigned int gSequenceNumber;
static int end_result;
static char sessionId[33] = "test-session";
typedef struct { unsigned int sequenceNumber; int packetType, dataSize; } AudioPacket;
static int ql_rtos_mutex_lock(ql_mutex_t m, unsigned int t) {
(void)m; (void)t; assert(!mutex_locked); mutex_locked = 1; return 0;
}
static void ql_rtos_mutex_unlock(ql_mutex_t m) { (void)m; assert(mutex_locked); mutex_locked = 0; }
static int vad_audio_is_recording(void) { return recording; }
static uint64_t hal_os_up_time_ms(void) { return now; }
static void vad_set_speech_timeout_flag(int value) { no_speech_timeout = value; }
static void vad_reset_conversation_flags(void) { no_speech_timeout = 0; flags_reset++; }
static int build_random_tts_error_url(char *p, size_t n) { (void)p; (void)n; return 1; }
static int add_file_to_play_player_queue(char *p, int type) {
assert(type == 3 || type == 5);
if (queue_failure) return -1;
queued++; player_mq_couter++; last_queue_type = type;
snprintf(last_queue_url, sizeof(last_queue_url), "%s", p);
return 0;
}
static void vad_start_func(void) { vad_starts++; recording = 1; vad_state = STATE_RECORDING_WAIT_SOUND; g_mic_working = 0; }
static void app_play_tips_file(int n) { (void)n; assert(0); }
static int conversation_try_begin(void);
static void talkingq_ws_disconnect(void) {
assert(!mutex_locked); disconnected++;
/* A key racing a committed timeout cannot start inside disconnect. */
assert(conversation_try_begin() == -1);
}
static int sendRecordingAudioPacket(AudioPacket *p) { assert(p->packetType == 2); return end_result; }
'''
harness += source[state_start:state_end]
for name in ['voice_diag_snapshot', 'conversation_try_begin', 'sendEndPacket', 'conversation_check_timeout', 'conversation_accept_voice_message', 'conversation_finish_startup_prompt', 'conversation_find_session_id', 'role_notice_is_url', 'role_notice_start_locked', 'role_notice_poll_locked', 'app_role_notice_handle_url', 'app_role_notice_before_finish', 'role_notice_finished']:
harness += function(name)
harness += r'''
static void reset(void) {
g_mic_working = recording = shine_init = fota_start = 0;
conversation_round = wait_round = sender_round = 0;
conversation_wait = WAIT_NONE; timeout_action_busy = round_response_received = 0;
queued = disconnected = flags_reset = no_speech_timeout = mutex_locked = 0;
power_down_status = sleeping = queue_failure = 0; vad_state = STATE_IDLE;
role_notice_pending = role_notice_playing = role_notice_consumed = 0;
role_notice_round = 0; last_queue_type = 0; last_queue_url[0] = 0;
now = 1000; end_result = 0; player_mq_couter = vad_starts = 0; active_session_valid = 0; startup_start_ms = 0; tts_playing = 0;
}
int main(void) {
char id[33];
const char *tagged = "PROMPT_SOUND_URL:http://host/26101309423304_EFGHIJKLMNOPQRSTUV.mp3";
const char *plain = "PROMPT_SOUND_URL:http://host/tts_error.mp3";
assert(conversation_find_session_id(tagged, (unsigned int)strlen(tagged), id));
assert(strcmp(id, "26101309423304EFGHIJKLMNOPQRSTUV") == 0);
assert(!conversation_find_session_id(plain, (unsigned int)strlen(plain), id));
assert(!conversation_find_session_id(tagged, 20, id));
/* No confirmed speech: prompt remains, without disconnect or reply wait. */
reset(); assert(conversation_try_begin() == 0); g_mic_working = 0;
conversation_no_speech(); conversation_check_timeout();
assert(queued == 1 && disconnected == 0 && no_speech_timeout == 1);
conversation_check_timeout(); assert(queued == 1);
/* New key cancels pending old no-speech prompt. */
reset(); conversation_no_speech(); assert(conversation_try_begin() == 0);
g_mic_working = 0; now += 20000; conversation_check_timeout();
assert(!queued && !disconnected && !no_speech_timeout);
/* Repeated keys during either waiting-for-speech or active speech are ignored. */
reset(); recording = 1; conversation_wait = WAIT_REPLY; wait_round = 0;
assert(conversation_try_begin() == -1);
assert(conversation_round == 0 && conversation_wait == WAIT_REPLY && !flags_reset);
/* END starts reply deadline; stale round and early URL cannot arm it. */
reset(); sendEndPacket(); assert(conversation_wait == WAIT_REPLY);
now += 5000; conversation_check_timeout(); assert(!queued);
now++; conversation_check_timeout(); assert(queued == 1 && disconnected == 1);
reset(); sendEndPacket(); assert(conversation_try_begin() == 0);
g_mic_working = 0; now += 20000; conversation_check_timeout(); assert(!queued && !disconnected);
reset(); conversation_round = 1; sendEndPacket(); assert(conversation_wait == WAIT_NONE);
reset(); round_response_received = 1; sendEndPacket(); assert(conversation_wait == WAIT_NONE);
/* END queue failure still produces an error instead of waiting forever. */
reset(); end_result = -1; sendEndPacket(); assert(conversation_wait == WAIT_SEND_FAILURE);
conversation_check_timeout(); assert(queued == 1 && disconnected == 1);
/* Prompt completion preserves the key-reserved round; automatic listening advances. */
reset(); assert(conversation_try_begin() == 0); conversation_record_begin(); assert(conversation_round == 1);
g_mic_working = 0; conversation_record_begin(); assert(conversation_round == 2);
/* Force-stop cancels waits; stale timeout owner cannot affect a new round. */
reset(); conversation_no_speech(); conversation_cancel_wait(); conversation_check_timeout(); assert(!queued);
reset(); conversation_wait = WAIT_REPLY; wait_round = 9; now += 20000;
conversation_check_timeout(); assert(!queued && !disconnected);
/* Reproduce ai_active followed by a late untagged backend error. */
reset(); active_session_valid = 1; active_session_round = 0;
assert(conversation_try_begin() == 0);
assert(!conversation_accept_voice_message("PROMPT_SOUND_URL:http://host/tts_error.mp3"));
assert(!conversation_accept_voice_message("TTS_START"));
assert(conversation_accept_voice_message("NFC_SOUND_URL:http://host/card.mp3"));
/* A following queue entry cannot consume the startup action. */
player_mq_couter = 1;
conversation_finish_startup_prompt(conversation_round, 0);
assert(vad_starts == 1 && recording && !g_mic_working);
assert(!conversation_accept_voice_message("PROMPT_SOUND_URL:http://host/tts_error.mp3"));
active_session_valid = 1; active_session_round = conversation_round;
assert(conversation_accept_voice_message("PROMPT_SOUND_URL:http://host/tts_error.mp3"));
/* Stale prompt completion must not start another round. */
reset(); assert(conversation_try_begin() == 0);
conversation_finish_startup_prompt(conversation_round - 1, 0); assert(!vad_starts);
conversation_finish_startup_prompt(conversation_round, -1); assert(vad_starts == 1);
/* Lost startup releases reservation; active work is never recovered. */
reset(); assert(conversation_try_begin() == 0); now += 30001;
conversation_check_timeout(); assert(!g_mic_working);
assert(conversation_try_begin() == 0);
now += 30001; player_mq_couter = 1; conversation_check_timeout(); assert(g_mic_working);
player_mq_couter = 0; recording = 1; conversation_check_timeout(); assert(g_mic_working);
/* The exact resource alone is routed; unrelated notices retain original path. */
reset();
assert(!app_role_notice_handle_url("http://host/assets/audio/card.mp3"));
assert(!app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3.extra"));
assert(!role_notice_is_url("http://host/other/switch_role.mp3"));
assert(!queued);
/* No speech: retain one notice until tts_error + optional local fallback ends. */
reset(); recording = 1; vad_state = STATE_RECORDING_WAIT_SOUND;
assert(app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3"));
assert(!queued && role_notice_pending);
recording = 0; vad_state = STATE_WAITING_COMMAND; conversation_wait = WAIT_NO_SPEECH;
assert(app_role_notice_before_finish() && !queued);
conversation_wait = WAIT_NONE; player_mq_couter = 1; no_speech_timeout = 1;
assert(app_role_notice_before_finish() && !queued);
player_mq_couter = 0;
assert(app_role_notice_before_finish() && queued == 1 && last_queue_type == 5);
assert(no_speech_timeout == 1); /* Error flag never becomes auto-listening. */
assert(app_role_notice_before_finish() && queued == 1);
player_mq_couter = 0; role_notice_finished(conversation_round, 0);
assert(!app_role_notice_before_finish() && no_speech_timeout == 1);
/* Speech: zero insertion while uploading/waiting or between reply segments. */
reset(); recording = 1; vad_state = STATE_RECORDING_ACTIVE;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
recording = 0; vad_state = STATE_WAITING_COMMAND; conversation_wait = WAIT_REPLY;
assert(app_role_notice_before_finish() && !queued);
conversation_wait = WAIT_NONE; tts_playing = 1;
assert(app_role_notice_before_finish() && !queued);
tts_playing = 0; player_mq_couter = 2;
assert(app_role_notice_before_finish() && !queued);
player_mq_couter = 0;
assert(app_role_notice_before_finish() && queued == 1);
role_notice_finished(conversation_round, 0); player_mq_couter = 0;
assert(!app_role_notice_before_finish() && no_speech_timeout == 0);
/* Repeated switches produce one latest URL at the end, never six entries. */
reset(); vad_state = STATE_WAITING_COMMAND; tts_playing = 1;
for (int n = 0; n < 6; n++) {
char url[128]; snprintf(url, sizeof(url), "http://host/assets/audio/switch_role.mp3?v=%d", n);
assert(app_role_notice_handle_url(url)); assert(!queued);
}
tts_playing = 0;
assert(app_role_notice_before_finish() && queued == 1);
assert(strstr(last_queue_url, "v=5"));
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(queued == 1 && !role_notice_pending);
role_notice_finished(conversation_round, 0); player_mq_couter = 0;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(queued == 1 && !app_role_notice_before_finish());
/* Idle plays promptly; playback/queue failure releases completion too. */
reset(); app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(queued == 1); player_mq_couter = 0;
role_notice_finished(conversation_round, -1); assert(!app_role_notice_before_finish());
reset(); queue_failure = 1;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(!queued && !app_role_notice_before_finish());
/* Accepted restart/force-stop cancels notice; no replay into next round. */
reset(); vad_state = STATE_WAITING_COMMAND; tts_playing = 1;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(conversation_try_begin() == 0 && !role_notice_pending);
reset(); recording = 1; app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
conversation_cancel_wait(); assert(!role_notice_pending && !role_notice_playing);
for (int mode = 0; mode < 3; mode++) {
reset(); vad_state = STATE_WAITING_COMMAND;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
fota_start = mode == 0; power_down_status = mode == 1; sleeping = mode == 2;
assert(!app_role_notice_before_finish() && !role_notice_pending && !queued);
}
puts("PASS: role notices: silent/speech end, latest-only coalescing, idle, failure, restart/OTA/shutdown/sleep isolation; late backend error admission, prompt completion with queued audio, stale prompt, failed playback, stalled-startup recovery; no-speech prompt, new-round cancellation, ignored recording keys, END deadline, stale rounds, early reply, send failure, timeout/key race, auto-listening");
}
'''
with tempfile.TemporaryDirectory(prefix='banban-timeout-') as directory:
cfile = Path(directory) / 'timeout.c'
exe = Path(directory) / 'timeout-test'
cfile.write_text(harness)
subprocess.run(['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', str(cfile), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)