From bf322baf2147e2f9889f4b4cd5f97a6aea7d9019 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:19:42 +0000 Subject: [PATCH] [verified] feat: prefer selected offline map packs --- .../Hardware/TDeck/MapTileDownloader.h | 3 + lib/tdeck_ui/UI/LXMF/MapScreen.cpp | 255 ++++++++++++++---- lib/tdeck_ui/UI/LXMF/MapScreen.h | 20 +- lib/tdeck_ui/UI/LXMF/MapTileLookupPolicy.h | 52 ++++ lib/tdeck_ui/UI/LXMF/MapTileStreamReader.h | 84 ++++++ .../build_scripts/test_map_screen_contract.py | 88 ++++++ .../test_map_tile_downloader_contract.py | 11 +- tests/native/test_map_tile_downloader.cpp | 6 +- tests/native/test_map_tile_downloader.py | 2 +- tests/native/test_map_tile_lookup_policy.cpp | 106 ++++++++ tests/native/test_map_tile_lookup_policy.py | 33 +++ tests/native/test_map_tile_stream_reader.cpp | 96 +++++++ tests/native/test_map_tile_stream_reader.py | 31 +++ 13 files changed, 722 insertions(+), 65 deletions(-) create mode 100644 lib/tdeck_ui/UI/LXMF/MapTileLookupPolicy.h create mode 100644 lib/tdeck_ui/UI/LXMF/MapTileStreamReader.h create mode 100644 tests/native/test_map_tile_lookup_policy.cpp create mode 100644 tests/native/test_map_tile_lookup_policy.py create mode 100644 tests/native/test_map_tile_stream_reader.cpp create mode 100644 tests/native/test_map_tile_stream_reader.py diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h index 0ac7815a..efef113f 100644 --- a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h @@ -129,6 +129,9 @@ public: bool takeResult(MapTileDownloadResult& result); bool isBusy() const { return active_ || queue_count_ != 0U; } + bool willStartTransportOnNextPump() const { + return active_ && stage_ == Stage::SELECTED; + } std::size_t queuedCount() const { return queue_count_; } std::size_t resultCount() const { return result_count_; } std::uint32_t droppedResultCount() const { return dropped_results_; } diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp index b44b0cb7..d8670694 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: MIT #include "MapScreen.h" +#include "MapTileLookupPolicy.h" +#include "MapTileStreamReader.h" #include "Hardware/TDeck/MapTileCa.h" #ifdef ARDUINO @@ -39,6 +41,77 @@ Hardware::TDeck::MapTileDownloadConfig makeDownloadConfig() { return config; } +class PackReadStream final : public Pyxis::MapTileReadStream { +public: + explicit PackReadStream(Hardware::TDeck::MapTilePack& pack) : pack_(pack) {} + Pyxis::MapTileStreamResult begin(const Hardware::TDeck::TileKey& key, + std::uint32_t& size) override { + const Hardware::TDeck::MapTilePackResult result = pack_.beginGet(key, size); + if (result == Hardware::TDeck::MapTilePackResult::OK) return Pyxis::MapTileStreamResult::OK; + if (result == Hardware::TDeck::MapTilePackResult::UNCOVERED || + result == Hardware::TDeck::MapTilePackResult::TILE_MISSING || + result == Hardware::TDeck::MapTilePackResult::NOT_INITIALIZED) { + return Pyxis::MapTileStreamResult::MISS; + } + if (result == Hardware::TDeck::MapTilePackResult::STORAGE_UNAVAILABLE) { + return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; + } + return Pyxis::MapTileStreamResult::IO_ERROR; + } + Pyxis::MapTileStreamResult read(std::uint8_t* output, std::size_t capacity, + std::size_t& count) override { + const Hardware::TDeck::MapTilePackResult result = + pack_.readGetChunk(output, capacity, count); + if (result == Hardware::TDeck::MapTilePackResult::OK) return Pyxis::MapTileStreamResult::OK; + if (result == Hardware::TDeck::MapTilePackResult::STORAGE_UNAVAILABLE) { + return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; + } + return Pyxis::MapTileStreamResult::IO_ERROR; + } + void end() override { pack_.endGet(); } +private: + Hardware::TDeck::MapTilePack& pack_; +}; + +class LiveReadStream final : public Pyxis::MapTileReadStream { +public: + explicit LiveReadStream(Hardware::TDeck::MapTileStore& store) : store_(store) {} + Pyxis::MapTileStreamResult begin(const Hardware::TDeck::TileKey& key, + std::uint32_t& size) override { + const Hardware::TDeck::TileStoreResult result = store_.beginGet(key, size); + if (result == Hardware::TDeck::TileStoreResult::OK) return Pyxis::MapTileStreamResult::OK; + if (result == Hardware::TDeck::TileStoreResult::MISS) return Pyxis::MapTileStreamResult::MISS; + if (result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE || + result == Hardware::TDeck::TileStoreResult::NOT_INITIALIZED) { + return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; + } + return Pyxis::MapTileStreamResult::IO_ERROR; + } + Pyxis::MapTileStreamResult read(std::uint8_t* output, std::size_t capacity, + std::size_t& count) override { + const Hardware::TDeck::TileStoreResult result = + store_.readGetChunk(output, capacity, count); + if (result == Hardware::TDeck::TileStoreResult::OK) return Pyxis::MapTileStreamResult::OK; + if (result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE) { + return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; + } + return Pyxis::MapTileStreamResult::IO_ERROR; + } + void end() override { store_.endGet(); } +private: + Hardware::TDeck::MapTileStore& store_; +}; + +class AtomicStopSource final : public Pyxis::MapTileStopSource { +public: + explicit AtomicStopSource(const std::atomic& stop) : stop_(stop) {} + bool stopRequested() const override { + return stop_.load(std::memory_order_acquire); + } +private: + const std::atomic& stop_; +}; + lv_obj_t* createToolbarButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, void* context, lv_coord_t width) { @@ -73,22 +146,24 @@ MapScreen::MapScreen(lv_obj_t* parent) presenter_(), storage_(), store_config_{STORE_ENTRY_CAPACITY, STORE_BYTE_QUOTA, MAX_COMPRESSED_TILE_BYTES}, - store_(storage_, store_config_), download_store_(store_), + store_(storage_, store_config_), pack_(storage_), download_store_(store_), download_transport_(), download_clock_(), download_policy_(), download_config_(makeDownloadConfig()), downloader_(download_store_, download_transport_, download_clock_, download_policy_, download_config_), - downloads_enabled_(false), screen_visible_(false), transport_close_epoch_(0U), + downloads_enabled_(false), screen_visible_(false), pack_refresh_epoch_(0U), + transport_close_epoch_(0U), download_failed_frame_epoch_(0U), decode_failed_keys_{}, decode_failed_generations_{}, compressed_staging_(nullptr), - state_mutex_(nullptr), worker_task_(nullptr), stop_requested_(false), + state_mutex_(nullptr), transport_start_mutex_(nullptr), worker_task_(nullptr), worker_exited_(true), worker_started_(false), store_initialized_(false), requests_released_(false), has_location_fix_(false), center_initialized_(false), current_location_{}, dragging_(false), last_drag_point_{0, 0}, back_callback_() { LVGL_LOCK(); state_mutex_ = xSemaphoreCreateMutex(); + transport_start_mutex_ = xSemaphoreCreateMutex(); // Reserve the mandatory SD/PNG staging buffer before optional decoded // cache entries. A partial cache must never prevent the worker starting. compressed_staging_ = static_cast(heap_caps_malloc( @@ -257,6 +332,8 @@ MapScreen::~MapScreen() { compressed_staging_ = nullptr; if (state_mutex_) vSemaphoreDelete(state_mutex_); state_mutex_ = nullptr; + if (transport_start_mutex_) vSemaphoreDelete(transport_start_mutex_); + transport_start_mutex_ = nullptr; } bool MapScreen::lockState(TickType_t ticks) { @@ -267,9 +344,26 @@ void MapScreen::unlockState() { xSemaphoreGive(state_mutex_); } +void MapScreen::synchronizeTransportStart() { + if (transport_start_mutex_ && + xSemaphoreTake(transport_start_mutex_, portMAX_DELAY) == pdTRUE) { + xSemaphoreGive(transport_start_mutex_); + } +} + +void MapScreen::setDownloadEnabled(bool enabled) { + const bool was_enabled = + downloads_enabled_.exchange(enabled, std::memory_order_acq_rel); + if (was_enabled && !enabled) { + transport_close_epoch_.fetch_add(1U, std::memory_order_acq_rel); + } + if (worker_task_) xTaskNotifyGive(worker_task_); + if (!enabled) synchronizeTransportStart(); +} + bool MapScreen::startWorker() { if (worker_started_) return true; - if (!state_mutex_ || !compressed_staging_) return false; + if (!state_mutex_ || !transport_start_mutex_ || !compressed_staging_) return false; stop_requested_.store(false, std::memory_order_release); worker_exited_.store(false, std::memory_order_release); const BaseType_t created = xTaskCreatePinnedToCore( @@ -310,14 +404,15 @@ void MapScreen::workerEntry(void* context) { void MapScreen::workerLoop() { Hardware::TDeck::TileStoreResult initialized = store_.initialize(); store_initialized_ = initialized == Hardware::TDeck::TileStoreResult::OK; + std::uint32_t handled_pack_refresh_epoch = + pack_refresh_epoch_.load(std::memory_order_acquire); + (void)pack_.initialize(); std::uint32_t handled_close_epoch = transport_close_epoch_.load(std::memory_order_acquire); while (!stop_requested_.load(std::memory_order_acquire)) { // The worker exclusively owns HTTP/TLS teardown. Keep a successful // session across frame/zoom boundaries only while the map is visible // and online acquisition remains explicitly enabled. - const bool downloads_enabled = - downloads_enabled_.load(std::memory_order_acquire); const std::uint32_t close_epoch = transport_close_epoch_.load(std::memory_order_acquire); if (close_epoch != handled_close_epoch) { @@ -325,13 +420,31 @@ void MapScreen::workerLoop() { download_transport_.disconnectIdle(); handled_close_epoch = close_epoch; } - const bool should_retain_download_transport = downloads_enabled && + const bool screen_visible = screen_visible_.load(std::memory_order_acquire); + const std::uint32_t pack_refresh_epoch = + pack_refresh_epoch_.load(std::memory_order_acquire); + if (pack_refresh_epoch != handled_pack_refresh_epoch) { + const bool had_selection = pack_.hasSelection(); + char previous_pack_id[Pyxis::MapPackManifest::PACK_ID_CAPACITY] = {}; + if (had_selection) { + std::memcpy(previous_pack_id, pack_.metadata().pack_id, + sizeof(previous_pack_id)); + } + (void)pack_.initialize(); + const bool has_selection = pack_.hasSelection(); + if (had_selection != has_selection || + (had_selection && has_selection && + std::strcmp(previous_pack_id, pack_.metadata().pack_id) != 0)) { + decoded_tile_cache_.clear(); + } + handled_pack_refresh_epoch = pack_refresh_epoch; + } Pyxis::MapTileRequest request{}; bool have_request = false; if (lockState(pdMS_TO_TICKS(20))) { - if (requests_released_ && should_retain_download_transport) { + if (requests_released_ && screen_visible) { have_request = presenter_.takeRequest(request); } unlockState(); @@ -360,9 +473,14 @@ void MapScreen::workerLoop() { Pyxis::MapTileLoadResult MapScreen::loadTile( const Pyxis::MapTileRequest& request, std::uint32_t transport_epoch) { const Pyxis::MapTileLoadResult cached = readTile(request); - if (cached != Pyxis::MapTileLoadResult::MISS && - cached != Pyxis::MapTileLoadResult::INVALID_PNG) return cached; - if (decodeFailedFor(request)) return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; + const bool decode_failed = decodeFailedFor(request); + if (!Pyxis::MapTileLookupPolicy::shouldStartOnline( + cached, downloads_enabled_.load(std::memory_order_acquire), + screen_visible_.load(std::memory_order_acquire), decode_failed, + transport_epoch, + transport_close_epoch_.load(std::memory_order_acquire))) { + return decode_failed ? Pyxis::MapTileLoadResult::DOWNLOAD_FAILED : cached; + } const Pyxis::MapTileLoadResult downloaded = downloadTile(request, transport_epoch); if (downloaded != Pyxis::MapTileLoadResult::READY) return downloaded; @@ -433,7 +551,25 @@ Pyxis::MapTileLoadResult MapScreen::downloadTile( (void)downloader_.cancelGeneration(request.frame_epoch); download_transport_.disconnectIdle(); } - (void)downloader_.pump(); + if (downloader_.willStartTransportOnNextPump()) { + if (xSemaphoreTake(transport_start_mutex_, portMAX_DELAY) == pdTRUE) { + const bool start_allowed = + !stop_requested_.load(std::memory_order_acquire) && + downloads_enabled_.load(std::memory_order_acquire) && + screen_visible_.load(std::memory_order_acquire) && + transport_close_epoch_.load(std::memory_order_acquire) == + transport_epoch; + if (start_allowed) { + (void)downloader_.pump(); + } else { + downloader_.setEnabled(false); + download_transport_.disconnectIdle(); + } + xSemaphoreGive(transport_start_mutex_); + } + } else { + (void)downloader_.pump(); + } if (downloader_.isBusy()) vTaskDelay(pdMS_TO_TICKS(1)); } @@ -464,44 +600,49 @@ Pyxis::MapTileLoadResult MapScreen::readTile( TILE_PIXEL_COUNT)) { return Pyxis::MapTileLoadResult::READY; } - if (!store_initialized_) { + + struct LocalReadContext { + MapScreen* screen; + const Pyxis::MapTileRequest* request; + } context = {this, &request}; + return Pyxis::MapTileLookupPolicy::readLocal( + &context, + [](void* opaque, Pyxis::MapTileLookupPolicy::LocalSource source) -> + Pyxis::MapTileLoadResult { + LocalReadContext* local = static_cast(opaque); + const CompressedTileSource mapped = + source == Pyxis::MapTileLookupPolicy::LocalSource::PACK + ? CompressedTileSource::PACK + : CompressedTileSource::LIVE_STORE; + return local->screen->readCompressedTile(*local->request, mapped); + }); +} + +Pyxis::MapTileLoadResult MapScreen::readCompressedTile( + const Pyxis::MapTileRequest& request, CompressedTileSource source) { + if (source == CompressedTileSource::LIVE_STORE && !store_initialized_) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; } - std::uint32_t size = 0U; - Hardware::TDeck::TileStoreResult result = - store_.beginGet(request.key, size); - if (result == Hardware::TDeck::TileStoreResult::MISS) { + PackReadStream pack_stream(pack_); + LiveReadStream live_stream(store_); + Pyxis::MapTileReadStream& stream = source == CompressedTileSource::PACK + ? static_cast(pack_stream) + : static_cast(live_stream); + AtomicStopSource stop(stop_requested_); + std::size_t total = 0U; + const Pyxis::MapTileStreamResult read = Pyxis::MapTileStreamReader::readExact( + stream, stop, request.key, compressed_staging_, MAX_COMPRESSED_TILE_BYTES, + READ_CHUNK_BYTES, total); + if (read == Pyxis::MapTileStreamResult::MISS) { return Pyxis::MapTileLoadResult::MISS; } - if (result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE || - result == Hardware::TDeck::TileStoreResult::NOT_INITIALIZED) { + if (read == Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; } - if (result != Hardware::TDeck::TileStoreResult::OK) { - return Pyxis::MapTileLoadResult::IO_ERROR; - } - if (size > MAX_COMPRESSED_TILE_BYTES) { - store_.endGet(); + if (read == Pyxis::MapTileStreamResult::TOO_LARGE) { return Pyxis::MapTileLoadResult::TOO_LARGE; } - std::size_t total = 0U; - while (total < size && - !stop_requested_.load(std::memory_order_acquire)) { - const std::size_t capacity = - (size - total) < READ_CHUNK_BYTES ? (size - total) : READ_CHUNK_BYTES; - std::size_t count = 0U; - result = store_.readGetChunk(compressed_staging_ + total, - capacity, count); - if (result != Hardware::TDeck::TileStoreResult::OK || count == 0U) { - store_.endGet(); - return result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE - ? Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE - : Pyxis::MapTileLoadResult::IO_ERROR; - } - total += count; - } - store_.endGet(); - if (total != size || stop_requested_.load(std::memory_order_acquire)) { + if (read != Pyxis::MapTileStreamResult::OK) { return Pyxis::MapTileLoadResult::IO_ERROR; } @@ -517,11 +658,15 @@ Pyxis::MapTileLoadResult MapScreen::readTile( &width, &height, &decode_state, compressed_staging_, total); if (decode_error != 0U || width != 256U || height != 256U) { lodepng_state_cleanup(&decode_state); - const Hardware::TDeck::TileStoreResult removed = store_.removeTile(request.key); - return (removed == Hardware::TDeck::TileStoreResult::OK || - removed == Hardware::TDeck::TileStoreResult::MISS) - ? Pyxis::MapTileLoadResult::INVALID_PNG - : Pyxis::MapTileLoadResult::IO_ERROR; + if (source == CompressedTileSource::LIVE_STORE) { + const Hardware::TDeck::TileStoreResult removed = + store_.removeTile(request.key); + return (removed == Hardware::TDeck::TileStoreResult::OK || + removed == Hardware::TDeck::TileStoreResult::MISS) + ? Pyxis::MapTileLoadResult::INVALID_PNG + : Pyxis::MapTileLoadResult::IO_ERROR; + } + return Pyxis::MapTileLoadResult::INVALID_PNG; } decode_state.info_raw.colortype = LCT_RGB; decode_state.info_raw.bitdepth = 8U; @@ -530,11 +675,15 @@ Pyxis::MapTileLoadResult MapScreen::readTile( lodepng_state_cleanup(&decode_state); if (decode_error != 0U || rgb == nullptr || width != 256U || height != 256U) { if (rgb) lv_mem_free(rgb); - const Hardware::TDeck::TileStoreResult removed = store_.removeTile(request.key); - return (removed == Hardware::TDeck::TileStoreResult::OK || - removed == Hardware::TDeck::TileStoreResult::MISS) - ? Pyxis::MapTileLoadResult::INVALID_PNG - : Pyxis::MapTileLoadResult::IO_ERROR; + if (source == CompressedTileSource::LIVE_STORE) { + const Hardware::TDeck::TileStoreResult removed = + store_.removeTile(request.key); + return (removed == Hardware::TDeck::TileStoreResult::OK || + removed == Hardware::TDeck::TileStoreResult::MISS) + ? Pyxis::MapTileLoadResult::INVALID_PNG + : Pyxis::MapTileLoadResult::IO_ERROR; + } + return Pyxis::MapTileLoadResult::INVALID_PNG; } if (request.slot_index >= TILE_COUNT || !tile_pixels_[request.slot_index]) { lv_mem_free(rgb); @@ -688,6 +837,7 @@ bool MapScreen::applyOneCompletion() { } void MapScreen::show() { + pack_refresh_epoch_.fetch_add(1U, std::memory_order_acq_rel); screen_visible_.store(true, std::memory_order_release); if (worker_task_) xTaskNotifyGive(worker_task_); if (lockState(pdMS_TO_TICKS(100))) { @@ -712,6 +862,7 @@ void MapScreen::hide() { transport_close_epoch_.fetch_add(1U, std::memory_order_acq_rel); } if (worker_task_) xTaskNotifyGive(worker_task_); + synchronizeTransportStart(); if (lockState(pdMS_TO_TICKS(100))) { presenter_.hide(); unlockState(); diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.h b/lib/tdeck_ui/UI/LXMF/MapScreen.h index 54d609b9..87547b6f 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreen.h +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.h @@ -19,6 +19,7 @@ #include "Hardware/TDeck/MapTileStore.h" #include "Hardware/TDeck/MapTileStoreSD.h" +#include "Hardware/TDeck/MapTilePack.h" #include "Hardware/TDeck/MapTileDownloader.h" #include "Hardware/TDeck/MapTileHttpArduino.h" @@ -49,14 +50,7 @@ public: // These methods never call LVGL and are invoked before LVGL_LOCK. void serviceIo(); void updateModel(const Pyxis::MapView::Request& request); - void setDownloadEnabled(bool enabled) { - const bool was_enabled = - downloads_enabled_.exchange(enabled, std::memory_order_acq_rel); - if (was_enabled && !enabled) { - transport_close_epoch_.fetch_add(1U, std::memory_order_acq_rel); - } - if (worker_task_) xTaskNotifyGive(worker_task_); - } + void setDownloadEnabled(bool enabled); // These methods only mutate the pre-created object pool and are invoked // while UIManager owns LVGL_LOCK. @@ -88,6 +82,7 @@ private: Hardware::TDeck::MapTileStoreSD storage_; Hardware::TDeck::TileStoreConfig store_config_; Hardware::TDeck::MapTileStore store_; + Hardware::TDeck::MapTilePack pack_; Hardware::TDeck::MapTileStoreDownloadAdapter download_store_; Hardware::TDeck::MapTileHttpArduino download_transport_; Hardware::TDeck::MapTileMillisClock download_clock_; @@ -96,12 +91,14 @@ private: Hardware::TDeck::MapTileDownloader downloader_; std::atomic downloads_enabled_; std::atomic screen_visible_; + std::atomic pack_refresh_epoch_; std::atomic transport_close_epoch_; std::uint32_t download_failed_frame_epoch_; Hardware::TDeck::TileKey decode_failed_keys_[TILE_COUNT]; std::uint32_t decode_failed_generations_[TILE_COUNT]; std::uint8_t* compressed_staging_; SemaphoreHandle_t state_mutex_; + SemaphoreHandle_t transport_start_mutex_; TaskHandle_t worker_task_; std::atomic stop_requested_; std::atomic worker_exited_; @@ -116,10 +113,16 @@ private: BackCallback back_callback_; static void workerEntry(void* context); + enum class CompressedTileSource : std::uint8_t { + PACK = 0, + LIVE_STORE + }; void workerLoop(); Pyxis::MapTileLoadResult loadTile(const Pyxis::MapTileRequest& request, std::uint32_t transport_epoch); Pyxis::MapTileLoadResult readTile(const Pyxis::MapTileRequest& request); + Pyxis::MapTileLoadResult readCompressedTile( + const Pyxis::MapTileRequest& request, CompressedTileSource source); Pyxis::MapTileLoadResult downloadTile(const Pyxis::MapTileRequest& request, std::uint32_t transport_epoch); bool decodeFailedFor(const Pyxis::MapTileRequest& request) const; @@ -128,6 +131,7 @@ private: void stopWorker(); bool lockState(TickType_t ticks = portMAX_DELAY); void unlockState(); + void synchronizeTransportStart(); void setPlaceholder(std::size_t index); void setStatusFor(Pyxis::MapTileLoadResult result); void pan(double dx, double dy); diff --git a/lib/tdeck_ui/UI/LXMF/MapTileLookupPolicy.h b/lib/tdeck_ui/UI/LXMF/MapTileLookupPolicy.h new file mode 100644 index 00000000..50f129fd --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapTileLookupPolicy.h @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_MAP_TILE_LOOKUP_POLICY_H +#define UI_LXMF_MAP_TILE_LOOKUP_POLICY_H + +#include +#include + +#include "MapScreenPresenter.h" + +namespace Pyxis { + +/** Portable policy for pack -> live cache -> optional network orchestration. */ +class MapTileLookupPolicy { +public: + enum class LocalSource : std::uint8_t { PACK = 0, LIVE_STORE }; + typedef MapTileLoadResult (*ReadLocalSource)(void*, LocalSource); + + static MapTileLoadResult readLocal(void* context, ReadLocalSource read) { + if (read == NULL) return MapTileLoadResult::IO_ERROR; + const MapTileLoadResult pack = read(context, LocalSource::PACK); + if (pack == MapTileLoadResult::READY) return pack; + const MapTileLoadResult live = read(context, LocalSource::LIVE_STORE); + return resolveLocal(pack, live); + } + + static MapTileLoadResult resolveLocal(MapTileLoadResult pack, + MapTileLoadResult live) { + if (pack == MapTileLoadResult::READY) return pack; + if (live != MapTileLoadResult::MISS) return live; + if (pack == MapTileLoadResult::IO_ERROR || + pack == MapTileLoadResult::STORAGE_UNAVAILABLE) return pack; + return MapTileLoadResult::MISS; + } + + static bool shouldStartOnline(MapTileLoadResult local, + bool enabled, + bool visible, + bool decode_failed, + std::uint32_t request_epoch, + std::uint32_t current_epoch) { + return enabled && visible && !decode_failed && + request_epoch == current_epoch && + (local == MapTileLoadResult::MISS || + local == MapTileLoadResult::INVALID_PNG); + } +}; + +} // namespace Pyxis + +#endif diff --git a/lib/tdeck_ui/UI/LXMF/MapTileStreamReader.h b/lib/tdeck_ui/UI/LXMF/MapTileStreamReader.h new file mode 100644 index 00000000..d9949e5f --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapTileStreamReader.h @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_MAP_TILE_STREAM_READER_H +#define UI_LXMF_MAP_TILE_STREAM_READER_H + +#include +#include + +#include "Hardware/TDeck/MapTileStore.h" + +namespace Pyxis { + +enum class MapTileStreamResult : std::uint8_t { + OK = 0, + MISS, + STORAGE_UNAVAILABLE, + TOO_LARGE, + IO_ERROR +}; + +class MapTileReadStream { +public: + virtual ~MapTileReadStream() {} + virtual MapTileStreamResult begin(const Hardware::TDeck::TileKey& key, + std::uint32_t& size) = 0; + virtual MapTileStreamResult read(std::uint8_t* output, + std::size_t capacity, + std::size_t& count) = 0; + virtual void end() = 0; +}; + +class MapTileStopSource { +public: + virtual ~MapTileStopSource() {} + virtual bool stopRequested() const = 0; +}; + +class MapTileStreamReader { +public: + static MapTileStreamResult readExact( + MapTileReadStream& stream, + MapTileStopSource& stop, + const Hardware::TDeck::TileKey& key, + std::uint8_t* output, + std::size_t maximum_size, + std::size_t chunk_size, + std::size_t& total) { + total = 0U; + if (output == NULL || maximum_size == 0U || chunk_size == 0U) { + return MapTileStreamResult::IO_ERROR; + } + std::uint32_t declared = 0U; + const MapTileStreamResult begun = stream.begin(key, declared); + if (begun != MapTileStreamResult::OK) return begun; + if (declared > maximum_size) { + stream.end(); + return MapTileStreamResult::TOO_LARGE; + } + while (total < declared) { + if (stop.stopRequested()) { + stream.end(); + return MapTileStreamResult::IO_ERROR; + } + const std::size_t remaining = static_cast(declared) - total; + const std::size_t capacity = remaining < chunk_size ? remaining : chunk_size; + std::size_t count = 0U; + const MapTileStreamResult read = stream.read(output + total, capacity, count); + if (read != MapTileStreamResult::OK || count == 0U || count > capacity) { + stream.end(); + return read == MapTileStreamResult::STORAGE_UNAVAILABLE + ? read : MapTileStreamResult::IO_ERROR; + } + total += count; + } + stream.end(); + if (stop.stopRequested()) return MapTileStreamResult::IO_ERROR; + return MapTileStreamResult::OK; + } +}; + +} // namespace Pyxis + +#endif diff --git a/tests/build_scripts/test_map_screen_contract.py b/tests/build_scripts/test_map_screen_contract.py index bafb2a90..203cf045 100644 --- a/tests/build_scripts/test_map_screen_contract.py +++ b/tests/build_scripts/test_map_screen_contract.py @@ -69,6 +69,94 @@ def test_worker_predecodes_and_render_path_has_no_io(): assert "MAX_COMPLETIONS_PER_TICK = 1" in text(UI / "MapScreen.h") +def test_selected_pack_is_worker_owned_read_only_and_precedes_live_cache(): + source = text(UI / "MapScreen.cpp") + header = text(UI / "MapScreen.h") + assert '#include "Hardware/TDeck/MapTilePack.h"' in header + assert "Hardware::TDeck::MapTilePack pack_;" in header + constructor = source[source.index("MapScreen::MapScreen"): + source.index("MapScreen::~MapScreen")] + assert "pack_(storage_)" in constructor + + worker = function_body(source, "void MapScreen::workerLoop()") + assert worker.index("store_.initialize()") < worker.index("pack_.initialize()") + + read_tile = function_body(source, "Pyxis::MapTileLoadResult MapScreen::readTile(") + assert read_tile.index("decoded_tile_cache_.get") < read_tile.index("PACK") + assert read_tile.index("PACK") < read_tile.index("LIVE_STORE") + + pack_read = function_body( + source, "Pyxis::MapTileLoadResult MapScreen::readCompressedTile(") + assert ("source == CompressedTileSource::LIVE_STORE && !store_initialized_" + in pack_read) + assert "MapTileStreamReader::readExact" in pack_read + assert "PackReadStream pack_stream(pack_)" in pack_read + assert "LiveReadStream live_stream(store_)" in pack_read + pack_adapter = source[source.index("class PackReadStream"): + source.index("class LiveReadStream")] + live_adapter = source[source.index("class LiveReadStream"): + source.index("class AtomicStopSource")] + assert "pack_.beginGet" in pack_adapter + assert "pack_.readGetChunk" in pack_adapter + assert "pack_.endGet" in pack_adapter + assert "remove" not in pack_adapter + assert "store_.beginGet" in live_adapter + assert "store_.readGetChunk" in live_adapter + assert "store_.endGet" in live_adapter + remove_token = "store_.removeTile(request.key)" + remove_offsets = [] + cursor = 0 + while True: + offset = pack_read.find(remove_token, cursor) + if offset < 0: + break + remove_offsets.append(offset) + cursor = offset + len(remove_token) + assert len(remove_offsets) == 2 + for offset in remove_offsets: + remove_block = pack_read[max(0, offset - 220):offset + 80] + assert "source == CompressedTileSource::LIVE_STORE" in remove_block + + # Covered-missing, uncovered, and corrupt immutable-pack tiles all continue + # to the mutable live cache before optional online acquisition. + assert "MapTilePackResult::UNCOVERED" in pack_adapter + assert "MapTilePackResult::TILE_MISSING" in pack_adapter + assert "MapTileLookupPolicy::readLocal" in read_tile + assert "static MapTileLoadResult resolveLocal" in text(UI / "MapTileLookupPolicy.h") + assert "MapTileLookupPolicy::shouldStartOnline" in source + + # Every activation publishes a durable pack refresh edge. The worker owns + # reinitialization and clears decoded tiles only when selection identity + # actually changes; failed replacement remains transactional in MapTilePack. + show = function_body(source, "void MapScreen::show()") + assert "pack_refresh_epoch_.fetch_add" in show + assert "std::atomic pack_refresh_epoch_;" in header + assert "pack_refresh_epoch != handled_pack_refresh_epoch" in worker + initial_epoch = worker.index("handled_pack_refresh_epoch") + initial_pack_init = worker.index("pack_.initialize()") + assert initial_epoch < initial_pack_init + assert worker.index("pack_.initialize()", worker.index("pack_refresh_epoch !=")) < worker.index("presenter_.takeRequest") + assert "decoded_tile_cache_.clear()" in worker + assert "std::strcmp(previous_pack_id, pack_.metadata().pack_id)" in worker + + download = function_body(source, "Pyxis::MapTileLoadResult MapScreen::downloadTile(") + assert "willStartTransportOnNextPump()" in download + assert "xSemaphoreTake(transport_start_mutex_, portMAX_DELAY)" in download + guarded_start = download[download.index("willStartTransportOnNextPump()"): + download.index("xSemaphoreGive(transport_start_mutex_)")] + assert "downloads_enabled_.load" in guarded_start + assert "screen_visible_.load" in guarded_start + assert "transport_close_epoch_.load" in guarded_start + assert "downloader_.pump()" in guarded_start + disable = function_body(source, "void MapScreen::setDownloadEnabled(") + hide = function_body(source, "void MapScreen::hide()") + assert "synchronizeTransportStart()" in disable + assert "synchronizeTransportStart()" in hide + + library = text(ROOT / "lib/tdeck_ui/library.json") + assert '"+"' in library + + def test_sd_adapter_never_mounts_or_formats(): source = text(HW / "MapTileStoreSD.cpp") for forbidden in ("SD.begin", "SD.format", "LittleFS"): diff --git a/tests/build_scripts/test_map_tile_downloader_contract.py b/tests/build_scripts/test_map_tile_downloader_contract.py index c4a57005..9e0d1db0 100644 --- a/tests/build_scripts/test_map_tile_downloader_contract.py +++ b/tests/build_scripts/test_map_tile_downloader_contract.py @@ -86,10 +86,13 @@ def test_downloader_is_explicitly_opt_in_and_wired_only_for_visible_misses(): worker = screen[screen.index("void MapScreen::workerLoop()"): screen.index("Pyxis::MapTileLoadResult MapScreen::loadTile")] assert "frame_drained" not in worker - assert "retain_download_transport" in worker assert "screen_visible_.load(std::memory_order_acquire)" in worker assert "transport_close_epoch_.load(std::memory_order_acquire)" in worker - assert "requests_released_ && should_retain_download_transport" in worker + assert "requests_released_ && screen_visible" in worker + take_request = worker[worker.index("presenter_.takeRequest") - 160: + worker.index("presenter_.takeRequest") + 80] + assert "downloads_enabled" not in take_request + assert "should_retain_download_transport" not in take_request assert "download_transport_.disconnectIdle()" in worker assert "screen_visible_.store(true, std::memory_order_release)" in screen assert "screen_visible_.exchange(false, std::memory_order_acq_rel)" in screen @@ -113,7 +116,9 @@ def test_recent_decoded_tiles_use_a_fixed_psram_lru_before_sd_decode(): cache = (ROOT / "lib/tdeck_ui/UI/LXMF/DecodedTileCache.h").read_text() assert "CAPACITY = 12U" in cache assert "decoded_tile_cache_.get" in screen - assert screen.index("decoded_tile_cache_.get") < screen.index("store_.beginGet") + read_tile = screen[screen.index("Pyxis::MapTileLoadResult MapScreen::readTile("): + screen.index("Pyxis::MapTileLoadResult MapScreen::readCompressedTile(")] + assert read_tile.index("decoded_tile_cache_.get") < read_tile.index("MapTileLookupPolicy::readLocal") assert "decoded_tile_cache_.put" in screen assert "MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT" in screen assert "decoded_cache_pixels_[Pyxis::DecodedTileCache::CAPACITY]" in header diff --git a/tests/native/test_map_tile_downloader.cpp b/tests/native/test_map_tile_downloader.cpp index 0fb12a04..897d01c1 100644 --- a/tests/native/test_map_tile_downloader.cpp +++ b/tests/native/test_map_tile_downloader.cpp @@ -136,6 +136,10 @@ void testSuccessExactChunksAndPublicContract() { beginTest(); FakeStore s; s.max CHECK(r.code==MapTileResultCode::SUCCESS && r.bytes==8192U && same(r.key,key()) && r.generation==11U); CHECK(s.chunks.size()==2U && s.chunks[0]==4096U && s.chunks[1]==4096U); CHECK(t.url=="https://tile.openstreetmap.org/3/1/2.png"); CHECK(t.agent=="Pyxis/1.2.3 (+https://github.com/torlando-tech/pyxis)"); CHECK(t.ca=="TEST CA"); CHECK(t.connect_timeout==321U && t.read_timeout==654U); CHECK(t.starts==1 && t.closes==1); } +void testTransportStartGateStage() { beginTest(); FakeStore s; FakeTransport t; t.body=bytes(1U); FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); + CHECK(d.enqueue(key(),1U)==MapTileEnqueueResult::ACCEPTED); CHECK(!d.willStartTransportOnNextPump()); + CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(d.willStartTransportOnNextPump()); CHECK(t.starts==0); + CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(!d.willStartTransportOnNextPump()); CHECK(t.starts==1); runUntilIdle(d,c); CHECK(take(d).code==MapTileResultCode::SUCCESS); } void testStatusAndContentTypeFailures() { beginTest(); const char* bad[]={"text/plain","image/pngx","image/ png",NULL}; for(int i=0;i<4;++i){ FakeStore s; FakeTransport t; t.type=bad[i]; FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); CHECK(d.enqueue(key(),1U)==MapTileEnqueueResult::ACCEPTED); runUntilIdle(d,c); CHECK(take(d).code==MapTileResultCode::CONTENT_TYPE_ERROR); CHECK(s.begins==0); } FakeStore s; FakeTransport t; t.status=204; FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); CHECK(d.enqueue(key(),1U)==MapTileEnqueueResult::ACCEPTED); runUntilIdle(d,c); CHECK(take(d).code==MapTileResultCode::HTTP_STATUS_ERROR); CHECK(s.begins==0); @@ -228,4 +232,4 @@ void testSdDisappearanceAndMailboxBound() { beginTest(); FakeStore s; FakeTransp void testStress() { beginTest(); FakeStore s; FakeTransport t; FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); for(std::uint32_t i=0;i<100000U;++i){ TileKey k=key(i&3U); const std::uint32_t g=i&7U; MapTileEnqueueResult r=d.enqueue(k,g); CHECK(r==MapTileEnqueueResult::ACCEPTED||r==MapTileEnqueueResult::DUPLICATE||r==MapTileEnqueueResult::QUEUE_FULL); if((i&3U)==0U)d.cancelGeneration(g); MapTileDownloadResult ignored; while(d.takeResult(ignored)){} } CHECK(d.queuedCount()<=MapTileDownloader::QUEUE_CAPACITY); } } -int main(){ testDisabledByDefault(); testCanonicalUrlAndBounds(); testDedupeAndQueueFullNoEviction(); testSuccessExactChunksAndPublicContract(); testStatusAndContentTypeFailures(); testLengthOverUnderAndChunkOverCap(); testTransportAndStoreFailuresAbort(); testTransportStartFailureHardClosesAndRetriesOnce(); testTransportStartRetryIsBounded(); testTransportStartTimeoutIsNotRetried(); testTransportRetryHonorsOverallDeadline(); testSecondTransportFailureHonorsOverallDeadline(); testTransportRetryCanBeCanceledBetweenAttempts(); testTransportRetryBudgetResetsForNextRequest(); testCancellationAtStagesAndGenerationIsolation(); testTimeoutRollbackAndSaturation(); testDestructorAbortsOwnedResources(); testRuntimeDisableCancelsAllWork(); testSdDisappearanceAndMailboxBound(); testStress(); std::cout<<"map tile downloader: "< None: env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=env) assert ran.returncode == 0, ran.stdout + ran.stderr - assert ran.stdout == "map tile downloader: 20 tests passed\n" + assert ran.stdout == "map tile downloader: 21 tests passed\n" diff --git a/tests/native/test_map_tile_lookup_policy.cpp b/tests/native/test_map_tile_lookup_policy.cpp new file mode 100644 index 00000000..665a1585 --- /dev/null +++ b/tests/native/test_map_tile_lookup_policy.cpp @@ -0,0 +1,106 @@ +#include "UI/LXMF/MapTileLookupPolicy.h" + +#include +#include + +using Pyxis::MapTileLoadResult; +using Pyxis::MapTileLookupPolicy; + +namespace { +std::size_t tests_run = 0U; +void fail(const char* expression, int line) { + std::cerr << "line " << line << ": " << expression << '\n'; + std::exit(1); +} +#define CHECK(expression) do { if (!(expression)) fail(#expression, __LINE__); } while (false) +void beginTest() { ++tests_run; } + +void testLocalPrecedenceTable() { + beginTest(); + CHECK(MapTileLookupPolicy::resolveLocal(MapTileLoadResult::READY, + MapTileLoadResult::IO_ERROR) == + MapTileLoadResult::READY); + const MapTileLoadResult fallthrough[] = { + MapTileLoadResult::MISS, MapTileLoadResult::INVALID_PNG, + MapTileLoadResult::TOO_LARGE + }; + for (std::size_t index = 0U; index < sizeof(fallthrough) / sizeof(fallthrough[0]); ++index) { + CHECK(MapTileLookupPolicy::resolveLocal(fallthrough[index], + MapTileLoadResult::READY) == + MapTileLoadResult::READY); + CHECK(MapTileLookupPolicy::resolveLocal(fallthrough[index], + MapTileLoadResult::MISS) == + MapTileLoadResult::MISS); + } + CHECK(MapTileLookupPolicy::resolveLocal(MapTileLoadResult::IO_ERROR, + MapTileLoadResult::MISS) == + MapTileLoadResult::IO_ERROR); + CHECK(MapTileLookupPolicy::resolveLocal(MapTileLoadResult::STORAGE_UNAVAILABLE, + MapTileLoadResult::MISS) == + MapTileLoadResult::STORAGE_UNAVAILABLE); + CHECK(MapTileLookupPolicy::resolveLocal(MapTileLoadResult::IO_ERROR, + MapTileLoadResult::READY) == + MapTileLoadResult::READY); + CHECK(MapTileLookupPolicy::resolveLocal(MapTileLoadResult::MISS, + MapTileLoadResult::INVALID_PNG) == + MapTileLoadResult::INVALID_PNG); +} + +void testOnlineGate() { + beginTest(); + CHECK(MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::MISS, + true, true, false, 7U, 7U)); + CHECK(MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::INVALID_PNG, + true, true, false, 7U, 7U)); + CHECK(!MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::READY, + true, true, false, 7U, 7U)); + CHECK(!MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::MISS, + false, true, false, 7U, 7U)); + CHECK(!MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::MISS, + true, false, false, 7U, 7U)); + CHECK(!MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::MISS, + true, true, true, 7U, 7U)); + CHECK(!MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::MISS, + true, true, false, 7U, 8U)); + CHECK(!MapTileLookupPolicy::shouldStartOnline(MapTileLoadResult::IO_ERROR, + true, true, false, 7U, 7U)); +} + +struct FakeLocalSources { + MapTileLoadResult pack; + MapTileLoadResult live; + int calls[2]; + std::size_t count; +}; + +MapTileLoadResult readFake(void* opaque, MapTileLookupPolicy::LocalSource source) { + FakeLocalSources* fake = static_cast(opaque); + fake->calls[fake->count++] = source == MapTileLookupPolicy::LocalSource::PACK ? 0 : 1; + return source == MapTileLookupPolicy::LocalSource::PACK ? fake->pack : fake->live; +} + +void testExecutableSourceOrderAndFallback() { + beginTest(); + FakeLocalSources ready = {MapTileLoadResult::READY, MapTileLoadResult::IO_ERROR, {0, 0}, 0U}; + CHECK(MapTileLookupPolicy::readLocal(&ready, readFake) == MapTileLoadResult::READY); + CHECK(ready.count == 1U && ready.calls[0] == 0); + const MapTileLoadResult fallthrough[] = { + MapTileLoadResult::MISS, MapTileLoadResult::INVALID_PNG, + MapTileLoadResult::TOO_LARGE, MapTileLoadResult::IO_ERROR + }; + for (std::size_t index = 0U; index < sizeof(fallthrough) / sizeof(fallthrough[0]); ++index) { + FakeLocalSources fake = {fallthrough[index], MapTileLoadResult::READY, {0, 0}, 0U}; + CHECK(MapTileLookupPolicy::readLocal(&fake, readFake) == MapTileLoadResult::READY); + CHECK(fake.count == 2U && fake.calls[0] == 0 && fake.calls[1] == 1); + } + CHECK(MapTileLookupPolicy::readLocal(NULL, NULL) == MapTileLoadResult::IO_ERROR); +} +} // namespace + +int main() { + testLocalPrecedenceTable(); + testOnlineGate(); + testExecutableSourceOrderAndFallback(); + std::cout << "map tile lookup policy: " << tests_run << " tests passed\n"; + return 0; +} diff --git a/tests/native/test_map_tile_lookup_policy.py b/tests/native/test_map_tile_lookup_policy.py new file mode 100644 index 00000000..64434eb7 --- /dev/null +++ b/tests/native/test_map_tile_lookup_policy.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path +import os +import subprocess + +import pytest + +from native_test import find_cxx + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "tests/native/test_map_tile_lookup_policy.cpp" + + +@pytest.mark.parametrize("sanitize", [False, True], ids=["strict-cxx11", "asan-ubsan"]) +def test_map_tile_lookup_policy(tmp_path: Path, sanitize: bool) -> None: + binary = tmp_path / "test_map_tile_lookup_policy" + command = [ + find_cxx(), "-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-Wconversion", "-Wsign-conversion", f"-I{ROOT / 'lib/tdeck_ui'}", + str(SOURCE), "-o", str(binary), + ] + if sanitize: + command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + env = os.environ.copy() + if sanitize: + env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1" + env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=env) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert ran.stdout == "map tile lookup policy: 3 tests passed\n" diff --git a/tests/native/test_map_tile_stream_reader.cpp b/tests/native/test_map_tile_stream_reader.cpp new file mode 100644 index 00000000..9d95863f --- /dev/null +++ b/tests/native/test_map_tile_stream_reader.cpp @@ -0,0 +1,96 @@ +#include "UI/LXMF/MapTileStreamReader.h" + +#include +#include +#include + +using Hardware::TDeck::TileKey; +using Pyxis::MapTileReadStream; +using Pyxis::MapTileStopSource; +using Pyxis::MapTileStreamReader; +using Pyxis::MapTileStreamResult; + +namespace { +std::size_t tests_run = 0U; +void fail(const char* expression, int line) { std::cerr << "line " << line << ": " << expression << '\n'; std::exit(1); } +#define CHECK(expression) do { if (!(expression)) fail(#expression, __LINE__); } while (false) +void beginTest() { ++tests_run; } + +class Stop final : public MapTileStopSource { +public: + Stop() : requested(false) {} + bool stopRequested() const override { return requested; } + bool requested; +}; + +class Stream final : public MapTileReadStream { +public: + Stream() : begin_result(MapTileStreamResult::OK), read_result(MapTileStreamResult::OK), + declared(3U), position(0U), begin_calls(0U), read_calls(0U), end_calls(0U), + zero_progress(false), oversized_count(false), stop(NULL) { + bytes[0] = 1U; bytes[1] = 2U; bytes[2] = 3U; + } + MapTileStreamResult begin(const TileKey&, std::uint32_t& size) override { + ++begin_calls; size = declared; return begin_result; + } + MapTileStreamResult read(std::uint8_t* output, std::size_t capacity, + std::size_t& count) override { + ++read_calls; + if (read_result != MapTileStreamResult::OK) { count = 0U; return read_result; } + if (zero_progress) { count = 0U; return MapTileStreamResult::OK; } + if (oversized_count) { count = capacity + 1U; return MapTileStreamResult::OK; } + const std::size_t remaining = declared - position; + count = remaining < capacity ? remaining : capacity; + if (count != 0U) std::memcpy(output, bytes + position, count); + position += count; + if (stop != NULL) stop->requested = true; + return MapTileStreamResult::OK; + } + void end() override { ++end_calls; } + MapTileStreamResult begin_result; + MapTileStreamResult read_result; + std::uint32_t declared; + std::size_t position; + std::size_t begin_calls; + std::size_t read_calls; + std::size_t end_calls; + bool zero_progress; + bool oversized_count; + Stop* stop; + std::uint8_t bytes[8]; +}; + +void testSuccessAndMiss() { + beginTest(); Stop stop; Stream stream; std::uint8_t output[8] = {}; std::size_t total = 99U; + CHECK(MapTileStreamReader::readExact(stream, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::OK); + CHECK(total == 3U && output[0] == 1U && output[2] == 3U); CHECK(stream.end_calls == 1U); + Stream miss; miss.begin_result = MapTileStreamResult::MISS; total = 99U; + CHECK(MapTileStreamReader::readExact(miss, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::MISS); + CHECK(total == 0U && miss.end_calls == 0U); +} + +void testEveryOpenedFailureCloses() { + beginTest(); std::uint8_t output[4] = {}; std::size_t total = 0U; Stop stop; + Stream large; large.declared = 5U; + CHECK(MapTileStreamReader::readExact(large, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::TOO_LARGE); CHECK(large.end_calls == 1U); + Stream error; error.read_result = MapTileStreamResult::IO_ERROR; + CHECK(MapTileStreamReader::readExact(error, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::IO_ERROR); CHECK(error.end_calls == 1U); + Stream unavailable; unavailable.read_result = MapTileStreamResult::STORAGE_UNAVAILABLE; + CHECK(MapTileStreamReader::readExact(unavailable, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::STORAGE_UNAVAILABLE); CHECK(unavailable.end_calls == 1U); + Stream zero; zero.zero_progress = true; + CHECK(MapTileStreamReader::readExact(zero, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::IO_ERROR); CHECK(zero.end_calls == 1U); + Stream excessive; excessive.oversized_count = true; + CHECK(MapTileStreamReader::readExact(excessive, stop, TileKey{1U,0U,0U}, output, sizeof(output), 2U, total) == MapTileStreamResult::IO_ERROR); CHECK(excessive.end_calls == 1U); +} + +void testStopMidReadCloses() { + beginTest(); Stop stop; Stream stream; stream.stop = &stop; std::uint8_t output[8] = {}; std::size_t total = 0U; + CHECK(MapTileStreamReader::readExact(stream, stop, TileKey{1U,0U,0U}, output, sizeof(output), 1U, total) == MapTileStreamResult::IO_ERROR); + CHECK(stream.read_calls == 1U && stream.end_calls == 1U); +} +} + +int main() { + testSuccessAndMiss(); testEveryOpenedFailureCloses(); testStopMidReadCloses(); + std::cout << "map tile stream reader: " << tests_run << " tests passed\n"; +} diff --git a/tests/native/test_map_tile_stream_reader.py b/tests/native/test_map_tile_stream_reader.py new file mode 100644 index 00000000..aea56cfc --- /dev/null +++ b/tests/native/test_map_tile_stream_reader.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path +import os +import subprocess + +import pytest + +from native_test import find_cxx + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "tests/native/test_map_tile_stream_reader.cpp" + + +@pytest.mark.parametrize("sanitize", [False, True], ids=["strict-cxx11", "asan-ubsan"]) +def test_map_tile_stream_reader(tmp_path: Path, sanitize: bool) -> None: + binary = tmp_path / "test_map_tile_stream_reader" + command = [find_cxx(), "-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-Wconversion", "-Wsign-conversion", f"-I{ROOT / 'lib/tdeck_ui'}", + str(SOURCE), "-o", str(binary)] + if sanitize: + command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + env = os.environ.copy() + if sanitize: + env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1" + env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=env) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert ran.stdout == "map tile stream reader: 3 tests passed\n"