diff --git a/src/ifcviewer/StreamingLoader.cpp b/src/ifcviewer/StreamingLoader.cpp index 969cca099b..b9f25b0d60 100644 --- a/src/ifcviewer/StreamingLoader.cpp +++ b/src/ifcviewer/StreamingLoader.cpp @@ -47,14 +47,32 @@ struct SidecarHeaderRaw { uint32_t endian; }; -template -bool readVec(FILE* f, std::vector& v) { - uint32_t n; - if (std::fread(&n, 4, 1, f) != 1) return false; - v.resize(n); - if (n > 0 && std::fread(v.data(), sizeof(T), n, f) != n) return false; - return true; -} +// Bounds-checked forward cursor over an in-memory buffer. parseSidecarTail +// walks the metadata tail through one of these so a truncated buffer fails +// cleanly (return false) instead of reading out of bounds. +struct BufCursor { + const uint8_t* p; + size_t remaining; + + bool take(void* dst, size_t bytes) { + if (bytes > remaining) return false; + std::memcpy(dst, p, bytes); + p += bytes; + remaining -= bytes; + return true; + } + + // Read a uint32 length prefix followed by length*sizeof(T) elements. + template + bool takeVec(std::vector& v) { + uint32_t n; + if (!take(&n, 4)) return false; + if (uint64_t(n) * sizeof(T) > remaining) return false; + v.resize(n); + if (n > 0 && !take(v.data(), size_t(n) * sizeof(T))) return false; + return true; + } +}; std::string sidecarPath(const std::string& ifc_path) { std::string p = ifc_path; @@ -70,6 +88,37 @@ std::string sidecarPath(const std::string& ifc_path) { } // namespace +bool parseSidecarHead(const uint8_t* data, size_t n, uint32_t& out_num_vertex_bytes) { + if (n < SIDECAR_HEAD_BYTES) return false; + SidecarHeaderRaw hdr; + std::memcpy(&hdr, data, sizeof(hdr)); + if (hdr.magic != SIDECAR_MAGIC) return false; + if (hdr.version != SIDECAR_VERSION) return false; + if (hdr.endian != SIDECAR_ENDIAN) return false; + std::memcpy(&out_num_vertex_bytes, data + sizeof(hdr), 4); + return true; +} + +bool parseSidecarTail(const uint8_t* data, size_t n, SidecarData& out) { + BufCursor c{data, n}; + if (!c.takeVec(out.meshes)) return false; + if (!c.takeVec(out.instances)) return false; + + // v11 georef block (148 bytes total). + if (!c.take(&out.has_coordinate_operation, 4)) return false; + if (!c.take(out.coordinate_operation_meters, sizeof(double) * 16)) return false; + if (!c.take(&out.project_length_to_meters, sizeof(double))) return false; + if (!c.take(&out.map_unit_to_meters, sizeof(double))) return false; + + if (!c.takeVec(out.elements)) return false; + uint32_t stbl_len = 0; + if (!c.take(&stbl_len, 4)) return false; + if (stbl_len > c.remaining) return false; + out.string_table.resize(stbl_len); + if (stbl_len > 0 && !c.take(out.string_table.data(), stbl_len)) return false; + return true; +} + std::optional readSidecarMetadataOnly(const std::string& ifc_path) { const std::string path = sidecarPath(ifc_path); FILE* f = std::fopen(path.c_str(), "rb"); @@ -80,52 +129,40 @@ std::optional readSidecarMetadataOnly(const std::string& ifc_p return std::nullopt; }; - SidecarHeaderRaw hdr; - if (std::fread(&hdr, sizeof(hdr), 1, f) != 1) return fail(); - if (hdr.magic != SIDECAR_MAGIC) return fail(); - if (hdr.version != SIDECAR_VERSION) return fail(); - if (hdr.endian != SIDECAR_ENDIAN) return fail(); + // Head: 12-byte header + the vertex-byte count. The vertex section starts + // immediately after, at SIDECAR_HEAD_BYTES. + uint8_t head[SIDECAR_HEAD_BYTES]; + if (std::fread(head, 1, SIDECAR_HEAD_BYTES, f) != SIDECAR_HEAD_BYTES) return fail(); + uint32_t num_vertex_bytes = 0; + if (!parseSidecarHead(head, SIDECAR_HEAD_BYTES, num_vertex_bytes)) return fail(); StreamingSidecar out; - out.file_path = path; - - // Vertex section: read count, record offset of data, seek past. - uint32_t num_vertex_bytes = 0; - if (std::fread(&num_vertex_bytes, 4, 1, f) != 1) return fail(); - out.vertex_section_offset = uint64_t(std::ftell(f)); + out.file_path = path; + out.vertex_section_offset = SIDECAR_HEAD_BYTES; out.vertex_total_bytes = num_vertex_bytes; - if (std::fseek(f, long(num_vertex_bytes), SEEK_CUR) != 0) return fail(); - // Index section: same dance, in u32 units. + // Skip the vertex section; read the index count that follows it. + if (std::fseek(f, long(num_vertex_bytes), SEEK_CUR) != 0) return fail(); uint32_t num_indices = 0; if (std::fread(&num_indices, 4, 1, f) != 1) return fail(); out.index_section_offset = uint64_t(std::ftell(f)); out.index_total_count = num_indices; + + // Skip the index section; the metadata tail runs from there to EOF. if (std::fseek(f, long(num_indices) * 4, SEEK_CUR) != 0) return fail(); + const long tail_off = std::ftell(f); + if (tail_off < 0) return fail(); + if (std::fseek(f, 0, SEEK_END) != 0) return fail(); + const long file_end = std::ftell(f); + if (file_end < tail_off) return fail(); + if (std::fseek(f, tail_off, SEEK_SET) != 0) return fail(); - // Mesh dict + instance dict — small, load into meta. - if (!readVec(f, out.meta.meshes)) return fail(); - if (!readVec(f, out.meta.instances)) return fail(); - - // v11 georef block (148 bytes total). - if (std::fread(&out.meta.has_coordinate_operation, 4, 1, f) != 1) return fail(); - if (std::fread(out.meta.coordinate_operation_meters, - sizeof(double), 16, f) != 16) return fail(); - if (std::fread(&out.meta.project_length_to_meters, - sizeof(double), 1, f) != 1) return fail(); - if (std::fread(&out.meta.map_unit_to_meters, - sizeof(double), 1, f) != 1) return fail(); - - // Element table + string table. - if (!readVec(f, out.meta.elements)) return fail(); - uint32_t stbl_len = 0; - if (std::fread(&stbl_len, 4, 1, f) != 1) return fail(); - out.meta.string_table.resize(stbl_len); - if (stbl_len > 0 && - std::fread(out.meta.string_table.data(), 1, stbl_len, f) != stbl_len) + std::vector tail(size_t(file_end - tail_off)); + if (!tail.empty() && std::fread(tail.data(), 1, tail.size(), f) != tail.size()) return fail(); - std::fclose(f); + + if (!parseSidecarTail(tail.data(), tail.size(), out.meta)) return std::nullopt; return out; } @@ -171,33 +208,14 @@ bool readSidecarIndexChunk(const std::string& ifc_path, return got == size_t(chunk_index_count); } -// Coalesce ranges that are close in file order into single reads. The -// input order is preserved in the destination buffer; we just merge -// reads on the file side. A `max_gap_bytes` tolerance lets us swallow -// small file gaps when reading would be cheaper than seeking. +// Coalesce ranges that are close in file order into single reads. The input +// order is preserved in the destination buffer; we just merge reads on the +// source side. A `max_gap_bytes` tolerance lets us swallow small gaps when one +// read is cheaper than a seek + fresh read. // -// SIDE EFFECT: callers must give the dst buffer in INPUT order; the -// reader scatters bytes via per-input-range dst offsets after a single -// coalesced fread. Returns false on any I/O failure. -namespace { - -struct ReadPlan { - uint64_t file_offset; // absolute file offset - uint64_t read_size; // total bytes to read - // Per input range: where its bytes land in this read, and where to - // copy them into the destination buffer. - struct Slice { - uint64_t src_offset; // offset within the read buffer - uint64_t dst_offset; // offset within the destination buffer - uint64_t bytes; - }; - std::vector slices; -}; - -// Build a plan that merges adjacent file ranges into single reads. -// `ranges` are (section-relative offset, size). `max_gap_bytes` is the -// largest "wasted bytes" we'll read to bridge two ranges into one read. -std::vector buildReadPlan( +// Callers must lay out the destination in INPUT order; the reader scatters +// bytes via per-input-range dst offsets after a single coalesced read. +std::vector planSidecarReadRanges( uint64_t section_offset, const std::vector>& ranges, uint64_t max_gap_bytes) { @@ -214,11 +232,11 @@ std::vector buildReadPlan( std::sort(sorted.begin(), sorted.end(), [](const Indexed& a, const Indexed& b) { return a.off < b.off; }); - std::vector plans; + std::vector plans; for (const auto& r : sorted) { if (r.size == 0) continue; if (!plans.empty()) { - ReadPlan& back = plans.back(); + SidecarReadPlan& back = plans.back(); const uint64_t end_of_back = back.file_offset + back.read_size; const uint64_t r_file = section_offset + r.off; if (r_file >= end_of_back && r_file - end_of_back <= max_gap_bytes) { @@ -233,7 +251,7 @@ std::vector buildReadPlan( continue; } } - ReadPlan np; + SidecarReadPlan np; np.file_offset = section_offset + r.off; np.read_size = r.size; np.slices.push_back({0, r.dst, r.size}); @@ -242,8 +260,6 @@ std::vector buildReadPlan( return plans; } -} // namespace - bool readSidecarVertexRanges(const std::string& ifc_path, uint64_t vertex_section_offset, const std::vector>& ranges, @@ -255,7 +271,7 @@ bool readSidecarVertexRanges(const std::string& ifc_path, // 64 KB max gap: on SSDs a small contiguous read is much cheaper // than a seek + fresh read, even if some bytes are discarded. - auto plans = buildReadPlan(vertex_section_offset, ranges, 64 * 1024); + auto plans = planSidecarReadRanges(vertex_section_offset, ranges, 64 * 1024); const std::string path = sidecarPath(ifc_path); FILE* f = std::fopen(path.c_str(), "rb"); @@ -296,7 +312,7 @@ bool readSidecarIndexRanges(const std::string& ifc_path, byte_ranges.emplace_back(first_u32 * 4u, count * 4u); out_byte_cursor += count * 4u; } - auto plans = buildReadPlan(index_section_offset, byte_ranges, 64 * 1024); + auto plans = planSidecarReadRanges(index_section_offset, byte_ranges, 64 * 1024); const std::string path = sidecarPath(ifc_path); FILE* f = std::fopen(path.c_str(), "rb"); diff --git a/src/ifcviewer/StreamingLoader.h b/src/ifcviewer/StreamingLoader.h index 228857309e..60ff52e8a3 100644 --- a/src/ifcviewer/StreamingLoader.h +++ b/src/ifcviewer/StreamingLoader.h @@ -22,9 +22,11 @@ #include "SidecarCache.h" +#include #include #include #include +#include #include // Metadata-only sidecar load — the foundation for streaming. Reads the v13 @@ -65,6 +67,56 @@ struct StreamingSidecar { // before return — callers re-open for per-chunk reads. std::optional readSidecarMetadataOnly(const std::string& ifc_path); +// --- Pure, buffer-based building blocks ------------------------------------ +// +// The metadata lives in two disjoint regions of the file: a small fixed +// "head" (12-byte header + the 4-byte vertex-byte count) that precedes the +// bulk vertex/index sections, and a "tail" (mesh dict, instance dict, georef, +// element table, string table) that follows them. Both desktop (FILE*) and +// web (Blob.slice / fetch Range) readers slice those two regions out of the +// source and hand the bytes to these parsers, so the wire-format knowledge +// lives in exactly one place and is unit-testable without touching a file. + +// Bytes the head spans: SidecarHeader (12) + uint32 num_vertex_bytes (4). +inline constexpr std::size_t SIDECAR_HEAD_BYTES = 16; + +// Parse the 16-byte head. Validates magic / version / endian and, on success, +// writes the vertex-section byte count (which locates the index-count field at +// SIDECAR_HEAD_BYTES + out_num_vertex_bytes). Returns false if `n` is short or +// the header is wrong. `data` must point at the start of the file. +bool parseSidecarHead(const std::uint8_t* data, std::size_t n, + std::uint32_t& out_num_vertex_bytes); + +// Parse the metadata tail (everything after the index section): mesh dict, +// instance dict, georef block, element table, string table. `data` points at +// the first tail byte; `n` is the tail length (read to EOF). Returns false on +// any bounds overrun (truncated buffer), leaving out_meta partially filled. +bool parseSidecarTail(const std::uint8_t* data, std::size_t n, + SidecarData& out_meta); + +// A coalesced read plan: a single contiguous source read whose bytes are +// scattered into the destination at the recorded offsets. Merging adjacent +// (or near-adjacent, within max_gap_bytes) ranges into one read amortises seek +// cost on disk and request count over the network / Blob boundary. +struct SidecarReadPlan { + std::uint64_t file_offset; // absolute source offset of this read + std::uint64_t read_size; // bytes to read + struct Slice { + std::uint64_t src_offset; // offset within the read buffer + std::uint64_t dst_offset; // offset within the destination buffer + std::uint64_t bytes; + }; + std::vector slices; +}; + +// Build read plans for `ranges` (section-relative (offset, size) pairs) that +// land in a destination laid out in input order. `section_offset` is added to +// turn section-relative offsets into absolute source offsets. Pure — no I/O. +std::vector planSidecarReadRanges( + std::uint64_t section_offset, + const std::vector>& ranges, + std::uint64_t max_gap_bytes); + // Read a byte range from a sidecar's vertex section. `chunk_byte_offset` is // RELATIVE to vertex_section_offset (i.e. 0 = first vertex byte). Returns // false on I/O error or out-of-range request. diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index 3e6b5dff84..b9709cceed 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -50,6 +50,15 @@ add_ifcviewer_unit_test(test_sidecar_cache SOURCES ${IFCVIEWER_SRC}/SidecarCache.cpp ) +# StreamingLoader: metadata-only read + range readers + the pure buffer-based +# parse / read-plan helpers shared with the web (Blob.slice) path. Needs +# SidecarCache.cpp for writeSidecar (to lay down on-disk fixtures). +add_ifcviewer_unit_test(test_streaming_loader + SOURCES + ${IFCVIEWER_SRC}/StreamingLoader.cpp + ${IFCVIEWER_SRC}/SidecarCache.cpp +) + add_ifcviewer_unit_test(test_instanced_geometry) # ChunkPlanner: Morton sort + greedy-pack — pure CPU, no Qt / no wgpu. diff --git a/src/ifcviewer/tests/test_streaming_loader.cpp b/src/ifcviewer/tests/test_streaming_loader.cpp new file mode 100644 index 0000000000..3dc38d42af --- /dev/null +++ b/src/ifcviewer/tests/test_streaming_loader.cpp @@ -0,0 +1,273 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "SidecarCache.h" +#include "StreamingLoader.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +fs::path makeScratchDir(const char* tag) { + fs::path base = fs::temp_directory_path() / "ifcviewer_test_streaming"; + fs::create_directories(base); + static std::atomic counter{0}; + fs::path dir = base / (std::to_string(counter.fetch_add(1)) + "_" + tag); + fs::create_directories(dir); + return dir; +} + +// Minimal but representative fixture: two meshes sharing one VBO, a non-default +// georef block, and a string table with embedded NULs (so the byte-exact tail +// parse is actually exercised). +SidecarData buildFixture() { + SidecarData sd; + + sd.vertices.resize(4 * INSTANCED_VERTEX_STRIDE_BYTES); + for (size_t i = 0; i < sd.vertices.size(); ++i) sd.vertices[i] = uint8_t(i * 7 + 1); + + sd.indices = {0, 1, 2, 1, 2, 3}; + + MeshInfo m1{}; + m1.vbo_byte_offset = 0; + m1.vertex_count = 2; + m1.ebo_byte_offset = 0; + m1.index_count = 3; + MeshInfo m2{}; + m2.vbo_byte_offset = 2 * INSTANCED_VERTEX_STRIDE_BYTES; + m2.vertex_count = 2; + m2.ebo_byte_offset = 3 * sizeof(uint32_t); + m2.index_count = 3; + sd.meshes = {m1, m2}; + + sd.instances.resize(3); + for (size_t i = 0; i < sd.instances.size(); ++i) { + sd.instances[i].mesh_id = (i < 2) ? 0u : 1u; + sd.instances[i].object_id = uint32_t(100 + i); + sd.instances[i].model_id = 1; + } + + sd.has_coordinate_operation = 1; + for (int k = 0; k < 16; ++k) sd.coordinate_operation_meters[k] = 0.5 + 0.1 * k; + sd.project_length_to_meters = 0.001; + sd.map_unit_to_meters = 1.0; + + sd.string_table = std::string("\0Wall\0Slab\0", 11); + sd.elements.resize(2); + for (size_t i = 0; i < sd.elements.size(); ++i) { + sd.elements[i].object_id = uint32_t(100 + i); + sd.elements[i].model_id = 1; + sd.elements[i].ifc_id = int32_t(1000 + i); + sd.elements[i].parent_id = (i == 0) ? -1 : int32_t(100); + } + return sd; +} + +} // namespace + +TEST_CASE("readSidecarMetadataOnly returns metadata + section offsets, skips bulk", + "[streaming]") { + fs::path dir = makeScratchDir("metaonly"); + fs::path ifc = dir / "model.ifc"; + SidecarData sd = buildFixture(); + REQUIRE(writeSidecar(ifc.string(), sd)); + + auto meta = readSidecarMetadataOnly(ifc.string()); + REQUIRE(meta.has_value()); + + // Bulk sections are skipped, not loaded. + REQUIRE(meta->meta.vertices.empty()); + REQUIRE(meta->meta.indices.empty()); + + // Offsets locate the two skipped sections. The vertex section starts + // right after the 16-byte head. + REQUIRE(meta->vertex_section_offset == SIDECAR_HEAD_BYTES); + REQUIRE(meta->vertex_total_bytes == sd.vertices.size()); + REQUIRE(meta->index_total_count == sd.indices.size()); + REQUIRE(meta->index_section_offset == + SIDECAR_HEAD_BYTES + sd.vertices.size() + 4); + + // Tail metadata round-trips. + REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); + REQUIRE(meta->meta.instances.size() == sd.instances.size()); + REQUIRE(meta->meta.elements.size() == sd.elements.size()); + REQUIRE(meta->meta.string_table == sd.string_table); + REQUIRE(meta->meta.has_coordinate_operation == 1); + REQUIRE(meta->meta.project_length_to_meters == 0.001); + for (int k = 0; k < 16; ++k) + REQUIRE(meta->meta.coordinate_operation_meters[k] == 0.5 + 0.1 * k); + REQUIRE(std::memcmp(&meta->meta.meshes[1], &sd.meshes[1], sizeof(MeshInfo)) == 0); +} + +TEST_CASE("readSidecarMetadataOnly rejects missing / corrupt files", "[streaming]") { + fs::path dir = makeScratchDir("reject"); + REQUIRE_FALSE(readSidecarMetadataOnly((dir / "absent.ifc").string()).has_value()); + + // Truncated head (under 16 bytes). + fs::path bad = dir / "bad.ifc"; + { + FILE* f = std::fopen((dir / "bad.ifcview").string().c_str(), "wb"); + REQUIRE(f); + const char junk[] = "XYZ"; + std::fwrite(junk, 1, sizeof(junk), f); + std::fclose(f); + } + REQUIRE_FALSE(readSidecarMetadataOnly(bad.string()).has_value()); +} + +TEST_CASE("readSidecarVertexRanges scatters byte ranges in input order", "[streaming]") { + fs::path dir = makeScratchDir("vranges"); + fs::path ifc = dir / "model.ifc"; + SidecarData sd = buildFixture(); + REQUIRE(writeSidecar(ifc.string(), sd)); + auto meta = readSidecarMetadataOnly(ifc.string()); + REQUIRE(meta.has_value()); + + // Two section-relative ranges given out of file order; the destination + // must preserve input order (second mesh's bytes first, then first). + const uint64_t stride = INSTANCED_VERTEX_STRIDE_BYTES; + std::vector> ranges = { + {2 * stride, 2 * stride}, // last 2 vertices + {0, 2 * stride}, // first 2 vertices + }; + std::vector out; + REQUIRE(readSidecarVertexRanges(ifc.string(), meta->vertex_section_offset, + ranges, out)); + REQUIRE(out.size() == 4 * stride); + REQUIRE(std::memcmp(out.data(), sd.vertices.data() + 2 * stride, 2 * stride) == 0); + REQUIRE(std::memcmp(out.data() + 2 * stride, sd.vertices.data(), 2 * stride) == 0); +} + +TEST_CASE("readSidecarIndexRanges reads u32 index ranges", "[streaming]") { + fs::path dir = makeScratchDir("iranges"); + fs::path ifc = dir / "model.ifc"; + SidecarData sd = buildFixture(); + REQUIRE(writeSidecar(ifc.string(), sd)); + auto meta = readSidecarMetadataOnly(ifc.string()); + REQUIRE(meta.has_value()); + + std::vector> ranges = {{3, 3}}; // indices[3..6) + std::vector out; + REQUIRE(readSidecarIndexRanges(ifc.string(), meta->index_section_offset, + ranges, out)); + REQUIRE(out == std::vector({1, 2, 3})); +} + +TEST_CASE("parseSidecarHead validates magic / version / length", "[streaming]") { + uint8_t head[SIDECAR_HEAD_BYTES] = {}; + uint32_t magic = SIDECAR_MAGIC, version = SIDECAR_VERSION, endian = SIDECAR_ENDIAN; + uint32_t nvb = 4096; + std::memcpy(head + 0, &magic, 4); + std::memcpy(head + 4, &version, 4); + std::memcpy(head + 8, &endian, 4); + std::memcpy(head + 12, &nvb, 4); + + uint32_t got = 0; + REQUIRE(parseSidecarHead(head, sizeof(head), got)); + REQUIRE(got == 4096); + + // Short buffer. + REQUIRE_FALSE(parseSidecarHead(head, SIDECAR_HEAD_BYTES - 1, got)); + + // Wrong magic. + uint8_t bad[SIDECAR_HEAD_BYTES]; + std::memcpy(bad, head, sizeof(bad)); + bad[0] ^= 0xFF; + REQUIRE_FALSE(parseSidecarHead(bad, sizeof(bad), got)); +} + +TEST_CASE("parseSidecarTail rejects a truncated tail", "[streaming]") { + // A valid full tail, then everything but its last byte must fail. + fs::path dir = makeScratchDir("tailtrunc"); + fs::path ifc = dir / "model.ifc"; + SidecarData sd = buildFixture(); + REQUIRE(writeSidecar(ifc.string(), sd)); + auto meta = readSidecarMetadataOnly(ifc.string()); + REQUIRE(meta.has_value()); + + // Re-read the raw tail bytes from disk (offset = index section end). + const uint64_t tail_off = + meta->index_section_offset + meta->index_total_count * 4u; + FILE* f = std::fopen((dir / "model.ifcview").string().c_str(), "rb"); + REQUIRE(f); + std::fseek(f, 0, SEEK_END); + const long end = std::ftell(f); + const size_t tail_len = size_t(end - long(tail_off)); + std::vector tail(tail_len); + std::fseek(f, long(tail_off), SEEK_SET); + REQUIRE(std::fread(tail.data(), 1, tail_len, f) == tail_len); + std::fclose(f); + + SidecarData full; + REQUIRE(parseSidecarTail(tail.data(), tail.size(), full)); + REQUIRE(full.meshes.size() == sd.meshes.size()); + REQUIRE(full.string_table == sd.string_table); + + SidecarData chopped; + REQUIRE_FALSE(parseSidecarTail(tail.data(), tail.size() - 1, chopped)); +} + +TEST_CASE("planSidecarReadRanges coalesces adjacent ranges, keeps far ones split", + "[streaming]") { + const uint64_t base = 1000; + + SECTION("adjacent ranges merge into one read") { + // Two ranges that touch (0..16, 16..48) plus a gap small enough to + // bridge (gap of 8 within a 64-byte tolerance). + std::vector> ranges = {{0, 16}, {24, 24}}; + auto plans = planSidecarReadRanges(base, ranges, 64); + REQUIRE(plans.size() == 1); + REQUIRE(plans[0].file_offset == base + 0); + REQUIRE(plans[0].read_size == 48); // 0 .. 24+24 + REQUIRE(plans[0].slices.size() == 2); + } + + SECTION("far-apart ranges stay separate") { + std::vector> ranges = {{0, 16}, {1024, 16}}; + auto plans = planSidecarReadRanges(base, ranges, 64); + REQUIRE(plans.size() == 2); + } + + SECTION("input order preserved in destination offsets") { + // Ranges given high-offset-first; dst offsets must follow input order + // (range 0 -> dst 0, range 1 -> dst 16) regardless of file order. + std::vector> ranges = {{2048, 16}, {0, 16}}; + auto plans = planSidecarReadRanges(base, ranges, 64); + REQUIRE(plans.size() == 2); + uint64_t total_bytes = 0; + for (const auto& p : plans) + for (const auto& s : p.slices) total_bytes += s.bytes; + REQUIRE(total_bytes == 32); + // The range at file offset 0 (input index 1) lands at dst 16. + bool found_dst16 = false; + for (const auto& p : plans) + for (const auto& s : p.slices) + if (p.file_offset == base + 0 && s.dst_offset == 16) found_dst16 = true; + REQUIRE(found_dst16); + } +}