From 4e1c36955a0663e8bea3b28099df41535e5d1da7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 12 Oct 2026 22:16:31 +0200 Subject: [PATCH] Test desktop stream discovery, playback, pressure and teardown --- tools/chatgui/CMakeLists.txt | 11 ++ tools/chatgui/tests/test_stream_view.cpp | 153 +++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tools/chatgui/tests/test_stream_view.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 0efa4722..97f9bd52 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -502,6 +502,17 @@ else() endif() add_executable(test_ptt_key_chord tests/test_ptt_key_chord.cpp) +add_executable(test_stream_view tests/test_stream_view.cpp src/streamplayer_window.cpp transport/stream_bridge.cpp + src/videoplayer_window.cpp src/videoplayer_engine.cpp src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp + transport/miniaudio_impl.c transport/audio_diagnostics.cpp) +target_include_directories(test_stream_view PRIVATE ${UTUN_INCLUDE_DIRS} ${FFMPEG_INCLUDE_DIRS}) +target_link_libraries(test_stream_view PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread ${CMAKE_DL_LIBS} ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) +if(FFMPEG_FOUND) + target_compile_definitions(test_stream_view PRIVATE HAVE_FFMPEG) +endif() +add_test(NAME test_stream_view COMMAND test_stream_view) +set_tests_properties(test_stream_view PROPERTIES TIMEOUT 30 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + target_link_libraries(test_ptt_key_chord PRIVATE ${QT_CORE}) add_test(NAME test_ptt_key_chord COMMAND test_ptt_key_chord) diff --git a/tools/chatgui/tests/test_stream_view.cpp b/tools/chatgui/tests/test_stream_view.cpp new file mode 100644 index 00000000..0855fb1f --- /dev/null +++ b/tools/chatgui/tests/test_stream_view.cpp @@ -0,0 +1,153 @@ +/* Настоящее ядро/actor, подписанный медиаблок и H.264 decoder: каталог до подписки, эпоха, показ и teardown. */ +#include "../src/streamplayer_window.h" +#include "../transport/stream_bridge.h" +#include +#include +#include +#include + +extern "C" { +#include "utun_instance.h" +#include "chat/chat_core.h" +#include "chat/chat_event.h" +#include "stream/stream.h" +#include "stream/stream_member.h" +#include "debug_config.h" +#include "mem.h" +#include "sqlite3.h" +#include "opus_codec.h" +#ifdef HAVE_FFMPEG +#include +#endif +} + +static QList snapshots; +static void sourceEvent(UTUN_INSTANCE*, int type, const uint8_t* data, int size) { + if (type == CHAT_EVT_STREAM_SOURCES) snapshots.push_back(QByteArray(reinterpret_cast(data), size)); +} + +class StreamViewTest : public QObject { + Q_OBJECT + UASYNC* ua = nullptr; + UTUN_INSTANCE* inst = nullptr; + STREAM_HANDLE* publisher = nullptr; + uint64_t group = 0, epoch = 0, frameId = 0; + QTimer poll; + size_t allocations = 0; + + void publish(const QByteArray& video, const QByteArray& audio = {}) { + stream_media_block media{}; media.format = {160, 90, 90, STREAM_MEDIA_H264, static_cast(audio.isEmpty() ? 0 : STREAM_MEDIA_OPUS)}; + media.first_frame_offset = 0; media.video_count = 1; + media.video[0] = {++frameId, frameId * 100000, static_cast(video.size()), 0, + static_cast(video.size()), STREAM_MEDIA_KEY, reinterpret_cast(video.constData())}; + if (!audio.isEmpty()) { + media.audio_count = 1; + media.audio[0] = {frameId * 100000, static_cast(audio.size()), 960, + reinterpret_cast(audio.constData())}; + } + uint8_t wire[7080]; size_t length = 0; + QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK); + QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK); + } + +private slots: + void initTestCase() { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_INFO); + allocations = u_get_allocated_count(); ua = uasync_create(); QVERIFY(ua); + inst = utun_instance_create_from_str(ua, + "[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"); + QVERIFY(inst); QCOMPARE(utun_core_start(inst), 0); QCOMPARE(chat_service_start(inst), 0); + chat_core_create_channel_auto(inst, "Stream test"); + sqlite3_stmt* statement = nullptr; + QCOMPARE(sqlite3_prepare_v2(chat_core_get_db(inst), "SELECT channel_id FROM channels LIMIT 1", -1, &statement, nullptr), SQLITE_OK); + QCOMPARE(sqlite3_step(statement), SQLITE_ROW); + group = QString::fromUtf8(reinterpret_cast(sqlite3_column_text(statement, 0))).toULongLong(); + sqlite3_finalize(statement); QVERIFY(group); + chat_event_set_handler(inst, sourceEvent); QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua); + connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1); + } + + void catalogueWithoutViewer() { + QVERIFY(gui_stream_watch(group)); QTRY_COMPARE(snapshots.size(), 1); + QCOMPARE(snapshots.last().mid(9), QByteArray("[]")); QVERIFY(!stream_service_needs_activity(inst)); + QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); + stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; + QTRY_COMPARE(snapshots.size(), 2); + auto sources = QJsonDocument::fromJson(snapshots.last().mid(9)).array(); QCOMPARE(sources.size(), 1); + QCOMPARE(stream_read_be(reinterpret_cast(snapshots.last().constData()), 8), group); + QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); + } + + void staleSelectionAndCancellation() { + QVERIFY(gui_stream_open(100, group, inst->node_id, epoch ^ 1)); + StreamRecord event; + QTRY_VERIFY(gui_stream_pull(100, event)); QCOMPARE(event.kind, StreamRecordKind::Closed); + QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED); + QVERIFY(gui_stream_open(101, group, inst->node_id, epoch)); gui_stream_stop(101); QTest::qWait(20); + stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); + } + + void mediaAndPressure() { + QVERIFY(gui_stream_open(102, group, inst->node_id, epoch)); StreamRecord event; + QTRY_VERIFY(gui_stream_pull(102, event)); QCOMPARE(event.kind, StreamRecordKind::Opened); + const QByteArray frame(1500, 'k'); const QByteArray audio("opus"); publish(frame, audio); + QTRY_VERIFY(gui_stream_pull(102, event)); QCOMPARE(event.kind, StreamRecordKind::Video); QCOMPARE(event.data, frame); + QCOMPARE(event.format.rotation, uint16_t(90)); + QTRY_VERIFY(gui_stream_pull(102, event)); QCOMPARE(event.kind, StreamRecordKind::Audio); QCOMPARE(event.data, audio); + for (int i = 0; i < 270; i++) { publish(frame); uasync_poll(ua, 0); } + bool gap = false; unsigned records = 0; + while (gui_stream_pull(102, event)) { gap |= event.kind == StreamRecordKind::Gap; records++; } + QVERIFY(gap); QVERIFY(records <= 256); + publish(frame); QTRY_VERIFY(gui_stream_pull(102, event)); QCOMPARE(event.kind, StreamRecordKind::Video); + gui_stream_stop(102); QTest::qWait(20); + stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); + } + + void realH264RenderingAndClose() { +#ifdef HAVE_FFMPEG + const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_H264); + if (!encoder) QSKIP("H264 encoder is unavailable for the decoder fixture"); + AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context); + context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P; + context->time_base = {1, 10}; context->framerate = {10, 1}; context->max_b_frames = 0; + AVDictionary* options = nullptr; av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0); + QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options); + AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt; + QCOMPARE(av_frame_get_buffer(frame, 32), 0); + for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++) + memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160); + frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0); + AVPacket* packet = av_packet_alloc(); QVERIFY(packet); QCOMPARE(avcodec_receive_packet(context, packet), 0); + QByteArray video(reinterpret_cast(packet->data), packet->size); + av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context); + StreamPlayerWindow window(group, inst->node_id, epoch, "Source"); + QSignalSpy shown(&window, &StreamPlayerWindow::framePresented); window.show(); + QTimer sender; connect(&sender, &QTimer::timeout, this, [this, video] { publish(video); }); sender.start(100); + QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 5000); + sender.stop(); window.close(); QTest::qWait(20); + stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); +#else + QSKIP("FFmpeg is disabled"); +#endif + } + + void finishAndDetachPendingOwner() { + QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]")); + stream_close(publisher); publisher = nullptr; + QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); + stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QVERIFY(info.descriptor.source_epoch != epoch); + QVERIFY(gui_stream_open(103, group, inst->node_id, epoch)); StreamRecord event; + QTRY_VERIFY(gui_stream_pull(103, event)); QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED); + QVERIFY(gui_stream_open(104, group, inst->node_id, info.descriptor.source_epoch)); + gui_stream_detach(inst); QVERIFY(!gui_stream_ready()); QTest::qWait(20); + QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); + } + + void cleanupTestCase() { + poll.stop(); gui_stream_detach(inst); stream_close(publisher); uasync_mark_stopped(ua); + utun_instance_destroy(inst); uasync_destroy(ua, 0); + QCOMPARE(u_get_allocated_count(), allocations); + } +}; +QTEST_MAIN(StreamViewTest) +#include "test_stream_view.moc"