From 8e3b84409b212146bde14b7c93c13b37f06e8cb3 Mon Sep 17 00:00:00 2001 From: Reimanbow Date: Fri, 13 Feb 2026 14:47:06 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fixed:=20=E3=83=98=E3=83=83=E3=83=80?= =?UTF-8?q?=E3=81=AB=E6=9C=80=E5=A4=A7MTU=E3=82=B5=E3=82=A4=E3=82=BA?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cast_chunker.c | 1 + src/cast_internal.h | 1 + test/host/test_cast_chunker.cpp | 71 ++++++++++++++++++++++----------- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/cast_chunker.c b/src/cast_chunker.c index 558c095..2842231 100644 --- a/src/cast_chunker.c +++ b/src/cast_chunker.c @@ -38,6 +38,7 @@ esp_err_t cast_send_frame( header->frame_id = frame_counter; header->chunk_index = i; header->total_chunks = total_chunks; + header->max_payload = (uint16_t)max_payload; header->payload_len = (uint16_t)current_payload_len; // 2. データのコピー(ヘッダの直後へ) diff --git a/src/cast_internal.h b/src/cast_internal.h index 8608185..da07873 100644 --- a/src/cast_internal.h +++ b/src/cast_internal.h @@ -40,6 +40,7 @@ typedef struct { uint16_t frame_id; // 対象フレームID uint16_t chunk_index; // チャンク番号、または応答対象のチャンク番号 uint16_t total_chunks; // 総チャンク数、または制御用パラメータ + uint16_t max_payload; // 送信側が分割に使用した1チャンクの最大サイズ uint16_t payload_len; // このパケットに含まれるデータ長(DATA以外では通常0) /* @note この後に最大MTU-2のサイズとなるまで実際のデータが入る */ } __attribute__((packed)) cast_header_t; diff --git a/test/host/test_cast_chunker.cpp b/test/host/test_cast_chunker.cpp index 7382d8a..5e79f8f 100644 --- a/test/host/test_cast_chunker.cpp +++ b/test/host/test_cast_chunker.cpp @@ -56,6 +56,19 @@ static const uint8_t *packet_payload(size_t index) return g_captured_packets[index].data() + sizeof(cast_header_t); } +// テスト用にmax_payloadを計算するヘルパー +static size_t calc_max_payload(void) +{ + return g_mock_mtu - CAST_PROTOCOL_OVERHEAD; +} + +// 指定データ長に対する期待チャンク数を計算するヘルパー +static size_t calc_expected_chunks(size_t data_len) +{ + size_t mp = calc_max_payload(); + return (data_len + mp - 1) / mp; +} + // --------------------------------------------------------------------------- // テストフィクスチャ // --------------------------------------------------------------------------- @@ -78,9 +91,7 @@ class ChunkerTest : public ::testing::Test { // データが max_payload の倍数 → 端数なし TEST_F(ChunkerTest, ExactFitChunkCount) { - // MTU=64, overhead=sizeof(cast_header_t)+2=13, max_payload=51 - // データ102バイト → 102/51 = ちょうど2チャンク - const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + const size_t max_payload = calc_max_payload(); const size_t data_len = max_payload * 2; std::vector data(data_len, 0xAA); @@ -93,8 +104,9 @@ TEST_F(ChunkerTest, ExactFitChunkCount) // データが max_payload の倍数でない → 端数あり TEST_F(ChunkerTest, PartialLastChunkCount) { - // max_payload=51, データ100バイト → ceil(100/51) = 2チャンク - std::vector data(100, 0xBB); + const size_t max_payload = calc_max_payload(); + // max_payload + 1 バイトで確実に2チャンク + std::vector data(max_payload + 1, 0xBB); esp_err_t ret = cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); @@ -116,8 +128,9 @@ TEST_F(ChunkerTest, SingleChunk) // 3チャンクに分割されるケース TEST_F(ChunkerTest, ThreeChunks) { - // max_payload=51, データ130バイト → ceil(130/51) = 3チャンク (51+51+28) - std::vector data(130, 0xDD); + const size_t max_payload = calc_max_payload(); + const size_t data_len = max_payload * 2 + 1; // 確実に3チャンク + std::vector data(data_len, 0xDD); esp_err_t ret = cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); @@ -131,7 +144,9 @@ TEST_F(ChunkerTest, ThreeChunks) TEST_F(ChunkerTest, HeaderConsistency) { - std::vector data(130, 0x00); + const size_t max_payload = calc_max_payload(); + const size_t data_len = max_payload * 2 + 1; // 3チャンク + std::vector data(data_len, 0x00); cast_send_frame(data.data(), data.size(), CAST_FMT_RGB565, &g_mock_transport); @@ -169,6 +184,11 @@ TEST_F(ChunkerTest, HeaderConsistency) EXPECT_EQ(h0->chunk_index, 0); EXPECT_EQ(h1->chunk_index, 1); EXPECT_EQ(h2->chunk_index, 2); + + // max_payload は全パケットで同じ値 + EXPECT_EQ(h0->max_payload, max_payload); + EXPECT_EQ(h1->max_payload, max_payload); + EXPECT_EQ(h2->max_payload, max_payload); } // =========================================================================== @@ -177,10 +197,11 @@ TEST_F(ChunkerTest, HeaderConsistency) TEST_F(ChunkerTest, PayloadBoundary) { - const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + const size_t max_payload = calc_max_payload(); + const size_t data_len = max_payload * 2 + 10; // 3チャンク (末尾10バイト) - // 0, 1, 2, ... , 129 の連番データ - std::vector data(130); + // 0, 1, 2, ... の連番データ + std::vector data(data_len); for (size_t i = 0; i < data.size(); i++) { data[i] = (uint8_t)(i & 0xFF); } @@ -190,9 +211,9 @@ TEST_F(ChunkerTest, PayloadBoundary) ASSERT_EQ(g_captured_packets.size(), 3u); // 各チャンクの payload_len を確認 - const size_t expect_len0 = max_payload; // 51 - const size_t expect_len1 = max_payload; // 51 - const size_t expect_len2 = 130 - max_payload * 2; // 28 + const size_t expect_len0 = max_payload; + const size_t expect_len1 = max_payload; + const size_t expect_len2 = data_len - max_payload * 2; EXPECT_EQ(packet_header(0)->payload_len, expect_len0); EXPECT_EQ(packet_header(1)->payload_len, expect_len1); @@ -201,14 +222,14 @@ TEST_F(ChunkerTest, PayloadBoundary) // チャンク0の末尾とチャンク1の先頭が連続しているか const uint8_t *p0 = packet_payload(0); const uint8_t *p1 = packet_payload(1); - EXPECT_EQ(p0[0], 0); // 先頭 - EXPECT_EQ(p0[max_payload - 1], max_payload - 1); // チャンク0の末尾 - EXPECT_EQ(p1[0], max_payload); // チャンク1の先頭 = チャンク0の続き + EXPECT_EQ(p0[0], 0); // 先頭 + EXPECT_EQ(p0[max_payload - 1], (uint8_t)(max_payload - 1)); // チャンク0の末尾 + EXPECT_EQ(p1[0], (uint8_t)max_payload); // チャンク1の先頭 = チャンク0の続き // チャンク2(最後)の先頭と末尾 const uint8_t *p2 = packet_payload(2); EXPECT_EQ(p2[0], (uint8_t)(max_payload * 2)); - EXPECT_EQ(p2[expect_len2 - 1], 129); + EXPECT_EQ(p2[expect_len2 - 1], (uint8_t)(data_len - 1)); // 全ペイロードを結合して元データと一致するか std::vector reassembled; @@ -250,7 +271,8 @@ TEST_F(ChunkerTest, FrameIdIncrements) // 2番目のsendで失敗 → 即座にエラーを返し、残りは送らない TEST_F(ChunkerTest, SendFailureMidway) { - std::vector data(130, 0xEE); // 3チャンク + const size_t max_payload = calc_max_payload(); + std::vector data(max_payload * 2 + 1, 0xEE); // 3チャンク g_send_fail_at = 1; // 2番目(index=1)のsendで失敗 esp_err_t ret = cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); @@ -294,28 +316,31 @@ TEST_F(ChunkerTest, PacketSizeNeverExceedsMtu) TEST_F(ChunkerTest, SmallMtu) { - g_mock_mtu = 24; // overhead=13 → max_payload=11 - const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + g_mock_mtu = 26; // overhead=15 → max_payload=11 + const size_t max_payload = calc_max_payload(); std::vector data(50, 0x11); cast_send_frame(data.data(), data.size(), CAST_FMT_GRAYSCALE, &g_mock_transport); - size_t expected_chunks = (50 + max_payload - 1) / max_payload; + size_t expected_chunks = calc_expected_chunks(50); EXPECT_EQ(g_captured_packets.size(), expected_chunks); for (size_t i = 0; i < g_captured_packets.size(); i++) { EXPECT_LE(g_captured_packets[i].size(), g_mock_mtu); + EXPECT_EQ(packet_header(i)->max_payload, max_payload); } } TEST_F(ChunkerTest, LargeMtu) { g_mock_mtu = 250; // ESP-NOW相当 + const size_t max_payload = calc_max_payload(); std::vector data(200, 0x22); cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); - // max_payload=237, 200 < 237 → 1チャンク + // 200 < max_payload → 1チャンク EXPECT_EQ(g_captured_packets.size(), 1u); EXPECT_EQ(packet_header(0)->payload_len, 200); + EXPECT_EQ(packet_header(0)->max_payload, max_payload); } From 591c64fadca341c15b363a29a2a0723a5c09cd82 Mon Sep 17 00:00:00 2001 From: Reimanbow Date: Fri, 13 Feb 2026 15:24:46 +0900 Subject: [PATCH 2/3] add: Reassembler --- CMakeLists.txt | 1 + include/cast_protocol.h | 21 ++++++++ src/cast_chunker.c | 4 +- src/cast_internal.h | 8 ++-- src/cast_reassembler.c | 103 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 src/cast_reassembler.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 2447ec2..58dd024 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,6 @@ idf_component_register( SRCS "src/cast_chunker.c" + "src/cast_reassembler.c" INCLUDE_DIRS "include" REQUIRES "" ) diff --git a/include/cast_protocol.h b/include/cast_protocol.h index 5f4022c..57c7567 100644 --- a/include/cast_protocol.h +++ b/include/cast_protocol.h @@ -40,6 +40,27 @@ esp_err_t cast_send_frame( cast_transport_interface_t *transport ); +/** + * @brief 画像が完成したときに呼ばれる関数の型 + * @note dataはcallbackから戻った後にミドルウェアが解放する。保持する場合はコピーすること。 + */ +typedef void (*cast_on_frame_ready_t)(const uint8_t *data, size_t len, cast_image_format_t fmt); + +/** + * @brief 受信側の初期化 + * transportのrecvコールバックを登録し、受信したパケットをreassemblerに渡す + * @param transport トランスポートインタフェース + * @param callback フレーム完成時に呼ばれるコールバック + * @return esp_err_t ESP_OK on success + */ +esp_err_t cast_init_receiver(cast_transport_interface_t *transport, cast_on_frame_ready_t callback); + +/** + * @brief 受信中のフレーム組み立てをリセットする + * 途中のフレームを破棄してバッファを解放する + */ +void cast_reassembler_reset(void); + #ifdef __cplusplus } #endif diff --git a/src/cast_chunker.c b/src/cast_chunker.c index 2842231..eb80bd9 100644 --- a/src/cast_chunker.c +++ b/src/cast_chunker.c @@ -46,10 +46,10 @@ esp_err_t cast_send_frame( // 3. トレーラ(CRC)の付与(今は0固定) uint16_t crc = 0; - memcpy(packet_buf + sizeof(cast_header_t) + current_payload_len, &crc, 2); + memcpy(packet_buf + sizeof(cast_header_t) + current_payload_len, &crc, CAST_CRC_SIZE); // 4. 送信 - size_t total_packet_len = sizeof(cast_header_t) + current_payload_len + 2; + size_t total_packet_len = sizeof(cast_header_t) + current_payload_len + CAST_CRC_SIZE; esp_err_t err = transport->send(packet_buf, total_packet_len); if (err != ESP_OK) { diff --git a/src/cast_internal.h b/src/cast_internal.h index da07873..2963250 100644 --- a/src/cast_internal.h +++ b/src/cast_internal.h @@ -20,7 +20,7 @@ typedef enum { } cast_packet_type_t; /** - * @brief CASTプロトコル共通ヘッダ (11バイト) + * @brief CASTプロトコル共通ヘッダ (13バイト) * * [フィールド再利用(Overloading)の設計指針] * プロトコルの軽量化のため、PacketTypeに応じて以下の通り意味を読み替える。 * * 1. DATA時: すべてのフィールドを定義通り使用。 @@ -45,7 +45,9 @@ typedef struct { /* @note この後に最大MTU-2のサイズとなるまで実際のデータが入る */ } __attribute__((packed)) cast_header_t; -// CRC(2バイト)を考慮したパケット最大オーバーヘッド -#define CAST_PROTOCOL_OVERHEAD (sizeof(cast_header_t) + 2) +#define CAST_CRC_SIZE 2 + +// CRCを考慮したパケット最大オーバーヘッド +#define CAST_PROTOCOL_OVERHEAD (sizeof(cast_header_t) + CAST_CRC_SIZE) #endif /* CAST_INTERNAL_H */ \ No newline at end of file diff --git a/src/cast_reassembler.c b/src/cast_reassembler.c new file mode 100644 index 0000000..5cc16f6 --- /dev/null +++ b/src/cast_reassembler.c @@ -0,0 +1,103 @@ +/** + * @file cast_reassembler.c + */ +#include "cast_protocol.h" +#include "cast_internal.h" + +#include +#include +#include +#include + +/** + * @brief 受信機内部で持つ情報 + */ +typedef struct { + uint8_t *buffer; // 組み立て用バッファ + size_t allocated_size; // 現在確保しているサイズ + uint16_t current_frame_id; // 追跡中のフレームID + uint16_t chunks_received; // 到着済みチャンク数 + uint16_t total_chunks; // 期待される総数 + uint16_t max_payload_ref; // オフセット計算の基準値 + cast_image_format_t format; // 画像フォーマット + bool is_active; +} cast_reassembler_ctx_t; + +static cast_reassembler_ctx_t ctx = {0}; +static cast_on_frame_ready_t app_callback = NULL; + +// 前方宣言 +static void cast_reassembler_push_packet(const uint8_t *data, size_t len); + +void cast_reassembler_reset(void) { + if (ctx.buffer) { + free(ctx.buffer); + ctx.buffer = NULL; + } + ctx.is_active = false; + ctx.chunks_received = 0; +} + +static void cast_reassembler_init(cast_on_frame_ready_t callback) { + app_callback = callback; + cast_reassembler_reset(); +} + +esp_err_t cast_init_receiver(cast_transport_interface_t *transport, cast_on_frame_ready_t callback) { + cast_reassembler_init(callback); + return transport->set_recv_callback(cast_reassembler_push_packet); +} + +static void cast_reassembler_push_packet(const uint8_t *data, size_t len) { + // 最小サイズチェック (Header + CRC) + if (len < sizeof(cast_header_t) + CAST_CRC_SIZE) return; + + cast_header_t *header = (cast_header_t *)data; + if (header->magic != CAST_MAGIC_BYTE) return; + + // TODO: CRC検証 + // uint16_t received_crc; + // memcpy(&received_crc, data + sizeof(cast_header_t) + header->payload_len, CAST_CRC_SIZE); + // if (calc_crc(data, len - CAST_CRC_SIZE) != received_crc) return; + + // 1. 新しいフレームの開始判断 + if (!ctx.is_active || header->frame_id != ctx.current_frame_id) { + // 前の未完成のフレームがあればリセット + cast_reassembler_reset(); + + // メモリ確保: 安全のため(総数*最大サイズ)で確保 + size_t reserve_size = header->total_chunks * header->max_payload; + ctx.buffer = (uint8_t *)malloc(reserve_size); + if (!ctx.buffer) return; + + ctx.allocated_size = reserve_size; + ctx.current_frame_id = header->frame_id; + ctx.total_chunks = header->total_chunks; + ctx.max_payload_ref = header->max_payload; + ctx.format = (cast_image_format_t)header->format; + ctx.chunks_received = 0; + ctx.is_active = true; + } + + // 2. オフセット計算と書き込み(ダイレクトマッピング) + size_t offset = header->chunk_index * ctx.max_payload_ref; + const uint8_t *payload = data + sizeof(cast_header_t); + + if (offset + header->payload_len <= ctx.allocated_size) { + memcpy(ctx.buffer + offset, payload, header->payload_len); + ctx.chunks_received++; + } + + if (ctx.chunks_received == ctx.total_chunks) { + if (app_callback) { + size_t final_size = offset + header->payload_len; + app_callback(ctx.buffer, final_size, ctx.format); + } + + // callback から戻ったらミドルウェア側で解放 + // アプリはcallback内で必要ならコピーすること + free(ctx.buffer); + ctx.buffer = NULL; + ctx.is_active = false; + } +} From 42a18030aa837d939f3cfd9326329c13ed172005 Mon Sep 17 00:00:00 2001 From: Reimanbow Date: Fri, 13 Feb 2026 15:41:00 +0900 Subject: [PATCH 3/3] add: Reassembler Navite Test --- src/cast_reassembler.c | 8 +- test/host/CMakeLists.txt | 5 + test/host/placeholder_jp_16x16.jpg | Bin 0 -> 765 bytes test/host/placeholder_jp_200x200.jpg | Bin 0 -> 3978 bytes test/host/placeholder_jp_640x480.jpg | Bin 0 -> 20868 bytes test/host/test_cast_reassembler.cpp | 410 +++++++++++++++++++++++++++ 6 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 test/host/placeholder_jp_16x16.jpg create mode 100644 test/host/placeholder_jp_200x200.jpg create mode 100644 test/host/placeholder_jp_640x480.jpg create mode 100644 test/host/test_cast_reassembler.cpp diff --git a/src/cast_reassembler.c b/src/cast_reassembler.c index 5cc16f6..5fff28e 100644 --- a/src/cast_reassembler.c +++ b/src/cast_reassembler.c @@ -19,6 +19,7 @@ typedef struct { uint16_t chunks_received; // 到着済みチャンク数 uint16_t total_chunks; // 期待される総数 uint16_t max_payload_ref; // オフセット計算の基準値 + uint16_t last_chunk_len; // 最終チャンクのペイロード長 cast_image_format_t format; // 画像フォーマット bool is_active; } cast_reassembler_ctx_t; @@ -86,11 +87,16 @@ static void cast_reassembler_push_packet(const uint8_t *data, size_t len) { if (offset + header->payload_len <= ctx.allocated_size) { memcpy(ctx.buffer + offset, payload, header->payload_len); ctx.chunks_received++; + + // 最終チャンクの payload_len を記録(順不同対応) + if (header->chunk_index == ctx.total_chunks - 1) { + ctx.last_chunk_len = header->payload_len; + } } if (ctx.chunks_received == ctx.total_chunks) { if (app_callback) { - size_t final_size = offset + header->payload_len; + size_t final_size = (ctx.total_chunks - 1) * ctx.max_payload_ref + ctx.last_chunk_len; app_callback(ctx.buffer, final_size, ctx.format); } diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index a402e4b..ecb5539 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -15,6 +15,7 @@ FetchContent_MakeAvailable(googletest) # Component source (compiled as C) add_library(component STATIC ../../src/cast_chunker.c + ../../src/cast_reassembler.c ) target_include_directories(component PUBLIC ../../include @@ -22,9 +23,13 @@ target_include_directories(component PUBLIC mocks ) +# Test data directory +add_compile_definitions(TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + # Test executable add_executable(host_test test_cast_chunker.cpp + test_cast_reassembler.cpp ) target_link_libraries(host_test PRIVATE component GTest::gtest_main) diff --git a/test/host/placeholder_jp_16x16.jpg b/test/host/placeholder_jp_16x16.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8a6ed5b5ca1dbc694c47556aa2aee2f9e1961dd8 GIT binary patch literal 765 zcmex= zRY=j$kxe)-kzJ`!#HexNLJno8jR!@8E`CrkPAY2R|V^&07y2J$~}^+4C1KUw!=a z`ODXD-+%o41@ado12e>1aG#<1OAzQUCSV+}u!H=?$W#u*%z`YeiiT`Lj)Clng~Cck zjT|CQ6Blkg$f;}`^g%SK=pvVxipfLOk07sseMX$en#l4Q++zrT-D2QjW&}navmk># z!;k6iAL^E`zv*9`nXYpodR^o(hU9%Fw|4%1yDQ?@)zdW(9*bnf@n89pD<}Q?v}JDY z@|kZR8$Fb~Yf&KZ=Lqvbivu?4Ho+$IMP6007#&YsasQ^f!EL331s^-vp5zEDNy+Qw zzbf)gB)(#K@RcuDH|@N#{+-UXv>gK81@8r>Zr=HAa_@0<^_3^r-`M+lUgZCq0F1;6 AAOHXW literal 0 HcmV?d00001 diff --git a/test/host/placeholder_jp_200x200.jpg b/test/host/placeholder_jp_200x200.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0aae62d37f0a99e5764515ec854299cc48a01a1b GIT binary patch literal 3978 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 ae@>aNlX1nI@s)A40sia%Sq_N&iGKi%to}3r literal 0 HcmV?d00001 diff --git a/test/host/placeholder_jp_640x480.jpg b/test/host/placeholder_jp_640x480.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fec4ee043d4cae02a725e5658bc5f38863c3703a GIT binary patch literal 20868 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 R@UIR0YXko)8-O28{9l`rS2zFw literal 0 HcmV?d00001 diff --git a/test/host/test_cast_reassembler.cpp b/test/host/test_cast_reassembler.cpp new file mode 100644 index 0000000..9c29d19 --- /dev/null +++ b/test/host/test_cast_reassembler.cpp @@ -0,0 +1,410 @@ +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "cast_protocol.h" +#include "cast_internal.h" +} + +// --------------------------------------------------------------------------- +// Mock transport (Chunker送信キャプチャ + Reassemblerコールバック登録) +// --------------------------------------------------------------------------- + +static std::vector> g_captured_packets; +static size_t g_mock_mtu = 64; +static void (*g_recv_callback)(const uint8_t *data, size_t len) = nullptr; + +static esp_err_t mock_send(const uint8_t *data, size_t len) +{ + g_captured_packets.emplace_back(data, data + len); + return ESP_OK; +} + +static size_t mock_get_mtu(void) { return g_mock_mtu; } +static int16_t mock_get_rssi(void) { return -50; } +static bool mock_is_connected(void) { return true; } +static bool mock_is_ready(void) { return true; } + +static esp_err_t mock_set_recv_cb(void (*cb)(const uint8_t *, size_t)) +{ + g_recv_callback = cb; + return ESP_OK; +} + +static cast_transport_interface_t g_mock_transport = { + .send = mock_send, + .get_mtu = mock_get_mtu, + .get_rssi = mock_get_rssi, + .is_connected = mock_is_connected, + .is_ready = mock_is_ready, + .set_recv_callback = mock_set_recv_cb, +}; + +// --------------------------------------------------------------------------- +// 完成フレームのキャプチャ +// --------------------------------------------------------------------------- + +struct CapturedFrame { + std::vector data; + cast_image_format_t fmt; +}; + +static std::vector g_completed_frames; + +static void on_frame_ready(const uint8_t *data, size_t len, cast_image_format_t fmt) +{ + CapturedFrame f; + f.data.assign(data, data + len); + f.fmt = fmt; + g_completed_frames.push_back(std::move(f)); +} + +// --------------------------------------------------------------------------- +// ヘルパー: キャプチャしたパケットをReassemblerに投入 +// --------------------------------------------------------------------------- + +static void feed_all_packets() +{ + for (auto &pkt : g_captured_packets) { + g_recv_callback(pkt.data(), pkt.size()); + } +} + +static void feed_packets_in_order(const std::vector &order) +{ + for (size_t idx : order) { + g_recv_callback(g_captured_packets[idx].data(), g_captured_packets[idx].size()); + } +} + +// ヘルパー: ファイルをバイト列として読み込む +static std::vector read_file(const std::string &path) +{ + std::ifstream ifs(path, std::ios::binary); + return std::vector( + std::istreambuf_iterator(ifs), + std::istreambuf_iterator() + ); +} + +// --------------------------------------------------------------------------- +// テストフィクスチャ +// --------------------------------------------------------------------------- + +class ReassemblerTest : public ::testing::Test { +protected: + void SetUp() override + { + g_captured_packets.clear(); + g_completed_frames.clear(); + g_mock_mtu = 64; + g_recv_callback = nullptr; + cast_init_receiver(&g_mock_transport, on_frame_ready); + } + + void TearDown() override + { + cast_reassembler_reset(); + } +}; + +// =========================================================================== +// 1. 正常系:一気通貫(Loopback)テスト +// =========================================================================== + +// 1-1: 最小データ (1バイト) +TEST_F(ReassemblerTest, LoopbackMinimal) +{ + uint8_t data[] = {0x42}; + cast_send_frame(data, 1, CAST_FMT_JPEG, &g_mock_transport); + ASSERT_EQ(g_captured_packets.size(), 1u); + + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data[0], 0x42); +} + +// 1-2: MTUぴったり (max_payload と同じデータ長) +TEST_F(ReassemblerTest, LoopbackExactMtu) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(max_payload); + std::iota(data.begin(), data.end(), 0); + + cast_send_frame(data.data(), data.size(), CAST_FMT_RGB565, &g_mock_transport); + ASSERT_EQ(g_captured_packets.size(), 1u); + + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data, data); +} + +// 1-3: MTU+1バイト (2チャンク、端数1バイト) +TEST_F(ReassemblerTest, LoopbackMtuPlusOne) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(max_payload + 1); + std::iota(data.begin(), data.end(), 0); + + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + ASSERT_EQ(g_captured_packets.size(), 2u); + + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data, data); +} + +// 1-4: 実JPEG画像 (16x16) +TEST_F(ReassemblerTest, LoopbackJpeg16x16) +{ + auto jpeg = read_file(std::string(TEST_DATA_DIR) + "/placeholder_jp_16x16.jpg"); + ASSERT_FALSE(jpeg.empty()); + + cast_send_frame(jpeg.data(), jpeg.size(), CAST_FMT_JPEG, &g_mock_transport); + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), jpeg.size()); + EXPECT_EQ(g_completed_frames[0].data, jpeg); + EXPECT_EQ(g_completed_frames[0].fmt, CAST_FMT_JPEG); +} + +// 1-4b: 実JPEG画像 (200x200) +TEST_F(ReassemblerTest, LoopbackJpeg200x200) +{ + auto jpeg = read_file(std::string(TEST_DATA_DIR) + "/placeholder_jp_200x200.jpg"); + ASSERT_FALSE(jpeg.empty()); + + cast_send_frame(jpeg.data(), jpeg.size(), CAST_FMT_JPEG, &g_mock_transport); + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), jpeg.size()); + EXPECT_EQ(g_completed_frames[0].data, jpeg); +} + +// 1-4c: 実JPEG画像 (640x480) - ESP-NOW相当のMTU +TEST_F(ReassemblerTest, LoopbackJpeg640x480_LargeMtu) +{ + g_mock_mtu = 250; + // MTU変更後にreceiverを再初期化 + cast_init_receiver(&g_mock_transport, on_frame_ready); + + auto jpeg = read_file(std::string(TEST_DATA_DIR) + "/placeholder_jp_640x480.jpg"); + ASSERT_FALSE(jpeg.empty()); + + cast_send_frame(jpeg.data(), jpeg.size(), CAST_FMT_JPEG, &g_mock_transport); + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), jpeg.size()); + EXPECT_EQ(g_completed_frames[0].data, jpeg); +} + +// =========================================================================== +// 2. 無線耐性系:順序制御テスト +// =========================================================================== + +// 2-1: 逆順到着 +TEST_F(ReassemblerTest, ReverseOrder) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(max_payload * 2 + 10); + std::iota(data.begin(), data.end(), 0); + + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + ASSERT_EQ(g_captured_packets.size(), 3u); + + // 逆順: [2] → [1] → [0] + feed_packets_in_order({2, 1, 0}); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data, data); +} + +// 2-2: ランダム順序 (JPEG 200x200を使用) +TEST_F(ReassemblerTest, RandomOrder) +{ + auto jpeg = read_file(std::string(TEST_DATA_DIR) + "/placeholder_jp_200x200.jpg"); + ASSERT_FALSE(jpeg.empty()); + + cast_send_frame(jpeg.data(), jpeg.size(), CAST_FMT_JPEG, &g_mock_transport); + size_t n = g_captured_packets.size(); + ASSERT_GT(n, 2u); + + // インデックスをシャッフル + std::vector order(n); + std::iota(order.begin(), order.end(), 0); + std::mt19937 rng(12345); // 固定シード(再現性のため) + std::shuffle(order.begin(), order.end(), rng); + + feed_packets_in_order(order); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), jpeg.size()); + EXPECT_EQ(g_completed_frames[0].data, jpeg); +} + +// =========================================================================== +// 3. 異常系:フレーム管理・リセットテスト +// =========================================================================== + +// 3-1: フレームID切り替わりによるリセット +TEST_F(ReassemblerTest, FrameIdSwitchResetsState) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data1(max_payload * 2 + 5); + std::iota(data1.begin(), data1.end(), 0); + + std::vector data2(max_payload + 10); + std::iota(data2.begin(), data2.end(), 100); + + // フレーム1を送信(3チャンク) + cast_send_frame(data1.data(), data1.size(), CAST_FMT_JPEG, &g_mock_transport); + size_t frame1_count = g_captured_packets.size(); + + // フレーム1のチャンク0だけ投入(未完成) + g_recv_callback(g_captured_packets[0].data(), g_captured_packets[0].size()); + EXPECT_EQ(g_completed_frames.size(), 0u); + + // フレーム2を送信 + g_captured_packets.clear(); + cast_send_frame(data2.data(), data2.size(), CAST_FMT_JPEG, &g_mock_transport); + + // フレーム2の全チャンクを投入 → フレーム1がリセットされフレーム2が完成 + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data, data2); +} + +// 3-2: 短すぎるパケットは無視される +TEST_F(ReassemblerTest, TooShortPacketIgnored) +{ + uint8_t short_data[] = {0xCA, 0x00}; + g_recv_callback(short_data, sizeof(short_data)); + + EXPECT_EQ(g_completed_frames.size(), 0u); +} + +// 3-3: magicバイトが違うパケットは無視される +TEST_F(ReassemblerTest, WrongMagicIgnored) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(10, 0xAA); + + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + ASSERT_EQ(g_captured_packets.size(), 1u); + + // magicバイトを壊す + g_captured_packets[0][0] = 0xFF; + feed_all_packets(); + + EXPECT_EQ(g_completed_frames.size(), 0u); +} + +// 3-4: バッファオーバーランチェック +// chunk_index が total_chunks を超える不正なパケットを投入 +TEST_F(ReassemblerTest, OutOfBoundsChunkIndexIgnored) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(max_payload * 2 + 1); + std::iota(data.begin(), data.end(), 0); + + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + ASSERT_EQ(g_captured_packets.size(), 3u); + + // チャンク0を投入(フレーム初期化) + g_recv_callback(g_captured_packets[0].data(), g_captured_packets[0].size()); + + // 不正パケット: chunk_index を 999 に改ざん + auto bad_pkt = g_captured_packets[1]; + cast_header_t *h = (cast_header_t *)bad_pkt.data(); + h->chunk_index = 999; + g_recv_callback(bad_pkt.data(), bad_pkt.size()); + + // 正常なチャンク1, 2を投入して完成させる + g_recv_callback(g_captured_packets[1].data(), g_captured_packets[1].size()); + g_recv_callback(g_captured_packets[2].data(), g_captured_packets[2].size()); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data, data); +} + +// =========================================================================== +// 4. アプリ連携系:フォーマット・完了通知テスト +// =========================================================================== + +// 4-1: フォーマット情報の伝搬 +TEST_F(ReassemblerTest, FormatPropagation) +{ + std::vector data(50, 0x11); + + // JPEG + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + feed_all_packets(); + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].fmt, CAST_FMT_JPEG); + + // RGB565 + g_captured_packets.clear(); + g_completed_frames.clear(); + cast_send_frame(data.data(), data.size(), CAST_FMT_RGB565, &g_mock_transport); + feed_all_packets(); + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].fmt, CAST_FMT_RGB565); + + // GRAYSCALE + g_captured_packets.clear(); + g_completed_frames.clear(); + cast_send_frame(data.data(), data.size(), CAST_FMT_GRAYSCALE, &g_mock_transport); + feed_all_packets(); + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].fmt, CAST_FMT_GRAYSCALE); +} + +// 4-2: 最終サイズの完全一致(連番データ) +TEST_F(ReassemblerTest, FinalSizeExact) +{ + // 端数が出るサイズで確認 + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(max_payload * 3 + 7); + std::iota(data.begin(), data.end(), 0); + + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + feed_all_packets(); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), data.size()); + EXPECT_EQ(g_completed_frames[0].data, data); +} + +// 4-2b: 最終サイズの完全一致(逆順でも正しいサイズ) +TEST_F(ReassemblerTest, FinalSizeExactReverseOrder) +{ + const size_t max_payload = g_mock_mtu - CAST_PROTOCOL_OVERHEAD; + std::vector data(max_payload * 3 + 7); + std::iota(data.begin(), data.end(), 0); + + cast_send_frame(data.data(), data.size(), CAST_FMT_JPEG, &g_mock_transport); + size_t n = g_captured_packets.size(); + + // 逆順投入 + std::vector order(n); + std::iota(order.begin(), order.end(), 0); + std::reverse(order.begin(), order.end()); + feed_packets_in_order(order); + + ASSERT_EQ(g_completed_frames.size(), 1u); + EXPECT_EQ(g_completed_frames[0].data.size(), data.size()); + EXPECT_EQ(g_completed_frames[0].data, data); +}