diff --git a/CMakeLists.txt b/CMakeLists.txt index 00a65a0..5aadf95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,7 @@ add_custom_target(panopainter_validate_shaders add_library(pp_foundation STATIC src/foundation/binary_stream.cpp + src/foundation/event.cpp src/foundation/log.cpp src/foundation/parse.cpp src/foundation/task_queue.cpp diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index da6cf80..528ee1f 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -80,8 +80,8 @@ Known local toolchain state: `platform-build` automation wrapper for `pp_foundation`, `pp_assets`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`, `pp_ui_core`, `pano_cli`, and their current headless test binaries, - including foundation logging/task queue coverage, PPI header, paint stroke - sampling, and layout XML parse coverage. + including foundation event/logging/task queue coverage, PPI header, paint + stroke sampling, and layout XML parse coverage. - `panopainter_validate_shaders` validates the current combined GLSL shader files for one vertex stage marker, one fragment stage marker, valid marker order, and existing relative includes. diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 1fd3237..ac46958 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -304,9 +304,10 @@ Goal: split libraries while keeping current app behavior. Status: started. `pp_foundation` exists with binary stream utilities and boundary/overread tests. It also owns strict decimal `uint32` parsing used by `pano_cli`, with rejection tests for empty, signed, mixed, and overflowing -input. A structured logging facade, bounded FIFO task queue, and deterministic -`TraceRecorder` now record component/name/thread/frame/stroke metadata with -filtering, capacity, and invalid-end tests. `pp_assets` has started with +input. A synchronous event dispatcher, structured logging facade, bounded FIFO +task queue, and deterministic `TraceRecorder` now record +component/name/thread/frame/stroke metadata with filtering, capacity, and +invalid-end tests. `pp_assets` has started with PNG/JPEG signature detection plus PPI header recognition, with corrupt/truncated/unsupported tests. `pp_paint` has started with CPU reference math for the five current shader @@ -524,7 +525,7 @@ Last verified on 2026-06-01: ```powershell cmake --preset windows-msvc-default -cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests pano_cli PanoPainter +cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests pano_cli PanoPainter ctest --preset desktop-fast --build-config Debug powershell -ExecutionPolicy Bypass -File scripts\automation\test.ps1 -Preset desktop-fast -Configuration Debug powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli @@ -538,6 +539,7 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -P Results: - `pp_foundation_binary_stream_tests` passed. +- `pp_foundation_event_tests` passed. - `pp_foundation_log_tests` passed. - `pp_foundation_parse_tests` passed. - `pp_foundation_task_queue_tests` passed. diff --git a/scripts/automation/platform-build.ps1 b/scripts/automation/platform-build.ps1 index 9ce5a2f..8432358 100644 --- a/scripts/automation/platform-build.ps1 +++ b/scripts/automation/platform-build.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [string[]]$Presets = @("android-arm64"), - [string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_log_tests", "pp_foundation_parse_tests", "pp_foundation_task_queue_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_ppi_header_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") + [string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_event_tests", "pp_foundation_log_tests", "pp_foundation_parse_tests", "pp_foundation_task_queue_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_ppi_header_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") ) $ErrorActionPreference = "Stop" diff --git a/scripts/automation/platform-build.sh b/scripts/automation/platform-build.sh index 68d8c0c..a54e9d1 100644 --- a/scripts/automation/platform-build.sh +++ b/scripts/automation/platform-build.sh @@ -3,7 +3,7 @@ set -u preset="${1:-android-arm64}" shift || true -targets="${*:-pp_foundation pp_assets pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" +targets="${*:-pp_foundation pp_assets pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" start="$(date +%s)" cmake --preset "$preset" diff --git a/src/foundation/event.cpp b/src/foundation/event.cpp new file mode 100644 index 0000000..7dd2a57 --- /dev/null +++ b/src/foundation/event.cpp @@ -0,0 +1,97 @@ +#include "foundation/event.h" + +#include + +namespace pp::foundation { + +EventDispatcher::EventDispatcher(std::size_t max_subscriptions) noexcept + : max_subscriptions_(max_subscriptions) +{ + subscriptions_.reserve(std::min(max_subscriptions_, max_event_subscriptions)); +} + +std::size_t EventDispatcher::size() const noexcept +{ + return subscriptions_.size(); +} + +bool EventDispatcher::empty() const noexcept +{ + return subscriptions_.empty(); +} + +std::size_t EventDispatcher::max_subscriptions() const noexcept +{ + return max_subscriptions_; +} + +Result EventDispatcher::subscribe(std::uint32_t type, EventCallback callback, void* user_data) +{ + if (max_subscriptions_ == 0U || max_subscriptions_ > max_event_subscriptions) { + return Result::failure( + Status::out_of_range("event dispatcher capacity is outside the configured range")); + } + + if (type == 0U) { + return Result::failure(Status::invalid_argument("event type must not be zero")); + } + + if (callback == nullptr) { + return Result::failure(Status::invalid_argument("event callback must not be null")); + } + + if (subscriptions_.size() >= max_subscriptions_) { + return Result::failure(Status::out_of_range("event dispatcher is full")); + } + + const auto id = next_subscription_id_++; + subscriptions_.push_back(EventSubscription { + .id = id, + .type = type, + .callback = callback, + .user_data = user_data, + }); + + return Result::success(id); +} + +Status EventDispatcher::unsubscribe(std::uint64_t subscription_id) noexcept +{ + const auto found = std::find_if( + subscriptions_.begin(), + subscriptions_.end(), + [subscription_id](const EventSubscription& subscription) { + return subscription.id == subscription_id; + }); + + if (found == subscriptions_.end()) { + return Status::out_of_range("event subscription id was not found"); + } + + subscriptions_.erase(found); + return Status::success(); +} + +std::size_t EventDispatcher::publish(const Event& event) const noexcept +{ + if (event.type == 0U) { + return 0; + } + + std::size_t delivered = 0; + for (const auto& subscription : subscriptions_) { + if (subscription.type == event.type) { + subscription.callback(event, subscription.user_data); + ++delivered; + } + } + + return delivered; +} + +void EventDispatcher::clear() noexcept +{ + subscriptions_.clear(); +} + +} diff --git a/src/foundation/event.h b/src/foundation/event.h new file mode 100644 index 0000000..b253121 --- /dev/null +++ b/src/foundation/event.h @@ -0,0 +1,48 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include +#include + +namespace pp::foundation { + +constexpr std::size_t max_event_subscriptions = 65536; + +struct Event { + std::uint32_t type = 0; + std::uint64_t source_id = 0; + std::uint64_t frame_id = 0; + std::uint64_t payload_u64 = 0; +}; + +using EventCallback = void (*)(const Event& event, void* user_data) noexcept; + +struct EventSubscription { + std::uint64_t id = 0; + std::uint32_t type = 0; + EventCallback callback = nullptr; + void* user_data = nullptr; +}; + +class EventDispatcher { +public: + explicit EventDispatcher(std::size_t max_subscriptions = max_event_subscriptions) noexcept; + + [[nodiscard]] std::size_t size() const noexcept; + [[nodiscard]] bool empty() const noexcept; + [[nodiscard]] std::size_t max_subscriptions() const noexcept; + + [[nodiscard]] Result subscribe(std::uint32_t type, EventCallback callback, void* user_data); + [[nodiscard]] Status unsubscribe(std::uint64_t subscription_id) noexcept; + [[nodiscard]] std::size_t publish(const Event& event) const noexcept; + void clear() noexcept; + +private: + std::size_t max_subscriptions_ = max_event_subscriptions; + std::uint64_t next_subscription_id_ = 1; + std::vector subscriptions_; +}; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e2a61b2..8bc24d5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,16 @@ add_test(NAME pp_foundation_binary_stream_tests COMMAND pp_foundation_binary_str set_tests_properties(pp_foundation_binary_stream_tests PROPERTIES LABELS "foundation;desktop-fast") +add_executable(pp_foundation_event_tests + foundation/event_tests.cpp) +target_link_libraries(pp_foundation_event_tests PRIVATE + pp_foundation + pp_test_harness) + +add_test(NAME pp_foundation_event_tests COMMAND pp_foundation_event_tests) +set_tests_properties(pp_foundation_event_tests PROPERTIES + LABELS "foundation;desktop-fast") + add_executable(pp_foundation_log_tests foundation/log_tests.cpp) target_link_libraries(pp_foundation_log_tests PRIVATE diff --git a/tests/foundation/event_tests.cpp b/tests/foundation/event_tests.cpp new file mode 100644 index 0000000..8a91834 --- /dev/null +++ b/tests/foundation/event_tests.cpp @@ -0,0 +1,136 @@ +#include "foundation/event.h" +#include "test_harness.h" + +using pp::foundation::Event; +using pp::foundation::EventDispatcher; +using pp::foundation::StatusCode; +using pp::foundation::max_event_subscriptions; + +namespace { + +struct Receiver { + int count = 0; + std::uint64_t payload_sum = 0; + std::uint64_t last_source = 0; +}; + +void receive_event(const Event& event, void* user_data) noexcept +{ + auto* receiver = static_cast(user_data); + ++receiver->count; + receiver->payload_sum += event.payload_u64; + receiver->last_source = event.source_id; +} + +void subscribe_and_publish_matching_events(pp::tests::Harness& h) +{ + EventDispatcher dispatcher(4); + Receiver receiver; + + const auto subscription = dispatcher.subscribe(7, receive_event, &receiver); + PP_EXPECT(h, subscription.ok()); + PP_EXPECT(h, subscription.value() == 1U); + PP_EXPECT(h, dispatcher.size() == 1U); + PP_EXPECT(h, dispatcher.max_subscriptions() == 4U); + + const auto delivered = dispatcher.publish(Event { + .type = 7, + .source_id = 42, + .frame_id = 3, + .payload_u64 = 11, + }); + + PP_EXPECT(h, delivered == 1U); + PP_EXPECT(h, receiver.count == 1); + PP_EXPECT(h, receiver.payload_sum == 11U); + PP_EXPECT(h, receiver.last_source == 42U); +} + +void ignores_non_matching_or_zero_events(pp::tests::Harness& h) +{ + EventDispatcher dispatcher(4); + Receiver receiver; + + PP_EXPECT(h, dispatcher.subscribe(2, receive_event, &receiver).ok()); + PP_EXPECT(h, dispatcher.publish(Event { .type = 3, .payload_u64 = 1 }) == 0U); + PP_EXPECT(h, dispatcher.publish(Event { .type = 0, .payload_u64 = 1 }) == 0U); + PP_EXPECT(h, receiver.count == 0); +} + +void preserves_subscription_order_and_unsubscribes(pp::tests::Harness& h) +{ + EventDispatcher dispatcher(4); + Receiver first; + Receiver second; + + const auto first_subscription = dispatcher.subscribe(9, receive_event, &first); + const auto second_subscription = dispatcher.subscribe(9, receive_event, &second); + PP_EXPECT(h, first_subscription.ok()); + PP_EXPECT(h, second_subscription.ok()); + + PP_EXPECT(h, dispatcher.publish(Event { .type = 9, .payload_u64 = 5 }) == 2U); + PP_EXPECT(h, first.payload_sum == 5U); + PP_EXPECT(h, second.payload_sum == 5U); + + PP_EXPECT(h, dispatcher.unsubscribe(first_subscription.value()).ok()); + PP_EXPECT(h, dispatcher.publish(Event { .type = 9, .payload_u64 = 7 }) == 1U); + PP_EXPECT(h, first.payload_sum == 5U); + PP_EXPECT(h, second.payload_sum == 12U); + + const auto missing = dispatcher.unsubscribe(first_subscription.value()); + PP_EXPECT(h, !missing.ok()); + PP_EXPECT(h, missing.code == StatusCode::out_of_range); +} + +void rejects_invalid_subscriptions_and_capacity(pp::tests::Harness& h) +{ + EventDispatcher dispatcher(1); + EventDispatcher zero_capacity(0); + EventDispatcher excessive_capacity(max_event_subscriptions + 1U); + Receiver receiver; + + const auto zero_type = dispatcher.subscribe(0, receive_event, &receiver); + const auto null_callback = dispatcher.subscribe(1, nullptr, &receiver); + PP_EXPECT(h, !zero_type.ok()); + PP_EXPECT(h, zero_type.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !null_callback.ok()); + PP_EXPECT(h, null_callback.status().code == StatusCode::invalid_argument); + + PP_EXPECT(h, dispatcher.subscribe(1, receive_event, &receiver).ok()); + const auto full = dispatcher.subscribe(2, receive_event, &receiver); + PP_EXPECT(h, !full.ok()); + PP_EXPECT(h, full.status().code == StatusCode::out_of_range); + + const auto zero_capacity_result = zero_capacity.subscribe(1, receive_event, &receiver); + const auto excessive_capacity_result = excessive_capacity.subscribe(1, receive_event, &receiver); + PP_EXPECT(h, !zero_capacity_result.ok()); + PP_EXPECT(h, zero_capacity_result.status().code == StatusCode::out_of_range); + PP_EXPECT(h, !excessive_capacity_result.ok()); + PP_EXPECT(h, excessive_capacity_result.status().code == StatusCode::out_of_range); +} + +void clear_removes_all_subscriptions(pp::tests::Harness& h) +{ + EventDispatcher dispatcher(4); + Receiver receiver; + + PP_EXPECT(h, dispatcher.subscribe(1, receive_event, &receiver).ok()); + PP_EXPECT(h, dispatcher.subscribe(2, receive_event, &receiver).ok()); + dispatcher.clear(); + PP_EXPECT(h, dispatcher.empty()); + PP_EXPECT(h, dispatcher.publish(Event { .type = 1, .payload_u64 = 5 }) == 0U); + PP_EXPECT(h, receiver.count == 0); +} + +} + +int main() +{ + pp::tests::Harness harness; + harness.run("subscribe_and_publish_matching_events", subscribe_and_publish_matching_events); + harness.run("ignores_non_matching_or_zero_events", ignores_non_matching_or_zero_events); + harness.run("preserves_subscription_order_and_unsubscribes", preserves_subscription_order_and_unsubscribes); + harness.run("rejects_invalid_subscriptions_and_capacity", rejects_invalid_subscriptions_and_capacity); + harness.run("clear_removes_all_subscriptions", clear_removes_all_subscriptions); + return harness.finish(); +}