diff --git a/lib/core/src/app.h b/lib/core/src/app.h new file mode 100644 index 0000000..9f2f64d --- /dev/null +++ b/lib/core/src/app.h @@ -0,0 +1,37 @@ +#pragma once + +#include "key_event.h" + +namespace roro { + +class Canvas; // provided by the widget kit + +// A foreground, user-facing program (see CONTEXT.md). Exactly one App is on screen at a time. +class App { + public: + virtual ~App() = default; + + virtual void onEnter() {} + virtual void onExit() {} + + // Return true if the key was consumed. An unconsumed Back leaves the App. + virtual bool onKey(const KeyEvent& event) { + (void)event; + return false; + } + + virtual void draw(Canvas& canvas) = 0; + + void requestRedraw() { redraw_ = true; } + + bool consumeRedraw() { + bool r = redraw_; + redraw_ = false; + return r; + } + + private: + bool redraw_ = false; +}; + +} // namespace roro diff --git a/lib/core/src/app_manager.cpp b/lib/core/src/app_manager.cpp new file mode 100644 index 0000000..3cc6af4 --- /dev/null +++ b/lib/core/src/app_manager.cpp @@ -0,0 +1,59 @@ +#include "app_manager.h" + +#include + +namespace roro { + +AppManager::AppManager(App& launcher) : launcher_(launcher), foreground_(&launcher) {} + +void AppManager::registerApp(const AppInfo& info) { apps_.push_back(info); } + +std::vector AppManager::visibleApps() const { + std::vector visible; + for (auto& info : apps_) + if (!info.hidden) visible.push_back(&info); + return visible; +} + +void AppManager::begin() { + foreground_ = &launcher_; + launcher_.onEnter(); + redraw_ = true; +} + +bool AppManager::open(const char* id) { + for (auto& info : apps_) { + if (std::strcmp(info.id, id) == 0) { + switchTo(*info.app); + return true; + } + } + return false; +} + +void AppManager::home() { switchTo(launcher_); } + +void AppManager::handleKey(const KeyEvent& event) { + if (event.key == Key::Home) { + home(); + return; + } + bool consumed = foreground_->onKey(event); + if (!consumed && event.key == Key::Back) home(); +} + +bool AppManager::takeRedraw() { + bool r = redraw_ | foreground_->consumeRedraw(); + redraw_ = false; + return r; +} + +void AppManager::switchTo(App& app) { + if (&app == foreground_) return; + foreground_->onExit(); + foreground_ = &app; + foreground_->onEnter(); + redraw_ = true; +} + +} // namespace roro diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h new file mode 100644 index 0000000..537b993 --- /dev/null +++ b/lib/core/src/app_manager.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include "app.h" + +namespace roro { + +struct AppInfo { + const char* id; + const char* title; + bool hidden; // not listed in the Launcher, but can still be opened + App* app; +}; + +// Owns which App is in the foreground and routes keys. Home always returns to the Launcher; +// Back is offered to the App first and returns to the Launcher if the App doesn't consume it. +class AppManager { + public: + explicit AppManager(App& launcher); + + void registerApp(const AppInfo& info); + std::vector visibleApps() const; + + void begin(); + bool open(const char* id); + void home(); + + void handleKey(const KeyEvent& event); + + App& foreground() const { return *foreground_; } + + // True once after the screen needs redrawing (App switch, or the App asked for it). + bool takeRedraw(); + + private: + void switchTo(App& app); + + App& launcher_; + App* foreground_; + std::vector apps_; + bool redraw_ = true; +}; + +} // namespace roro diff --git a/lib/core/src/event.h b/lib/core/src/event.h new file mode 100644 index 0000000..1c2648f --- /dev/null +++ b/lib/core/src/event.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace roro { + +// Everything Services announce to the UI. Add new kinds before Count. +enum class EventType : uint8_t { + BatteryChanged, // a = percent, b = millivolts + Notification, // text = message, a = NotificationLevel + StorageThreshold, // a = usage percent, b = threshold crossed (80 / 90 / 100) + SettingChanged, // text = setting key + Count +}; + +// Small, fixed-size and copyable so it can cross tasks through a queue without allocating. +struct Event { + static constexpr size_t kTextCapacity = 48; + + EventType type; + int32_t a = 0; + int32_t b = 0; + char text[kTextCapacity] = {}; + + static Event withValues(EventType type, int32_t a, int32_t b = 0) { + Event e{type}; + e.a = a; + e.b = b; + return e; + } + + static Event withText(EventType type, const char* text, int32_t a = 0) { + Event e{type}; + e.a = a; + std::strncpy(e.text, text, kTextCapacity - 1); + return e; + } +}; + +} // namespace roro diff --git a/lib/core/src/event_bus.cpp b/lib/core/src/event_bus.cpp new file mode 100644 index 0000000..41184d1 --- /dev/null +++ b/lib/core/src/event_bus.cpp @@ -0,0 +1,46 @@ +#include "event_bus.h" + +namespace roro { + +EventBus::EventBus(size_t capacity) : queue_(capacity) {} + +bool EventBus::publish(const Event& event) { + std::lock_guard lock(mutex_); + if (count_ == queue_.size()) { + dropped_++; + return false; + } + queue_[(head_ + count_) % queue_.size()] = event; + count_++; + return true; +} + +void EventBus::subscribe(EventType type, Handler handler) { + handlers_[static_cast(type)].push_back(std::move(handler)); +} + +size_t EventBus::dispatch() { + size_t pending; + { + std::lock_guard lock(mutex_); + pending = count_; + } + for (size_t i = 0; i < pending; i++) { + Event event; + { + std::lock_guard lock(mutex_); + event = queue_[head_]; + head_ = (head_ + 1) % queue_.size(); + count_--; + } + for (auto& handler : handlers_[static_cast(event.type)]) handler(event); + } + return pending; +} + +uint32_t EventBus::dropped() const { + std::lock_guard lock(mutex_); + return dropped_; +} + +} // namespace roro diff --git a/lib/core/src/event_bus.h b/lib/core/src/event_bus.h new file mode 100644 index 0000000..ef8d775 --- /dev/null +++ b/lib/core/src/event_bus.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "event.h" + +namespace roro { + +// Services publish from any task; the UI task calls dispatch() to deliver events to subscribers. +// Subscribe only during setup, from the UI task. +class EventBus { + public: + using Handler = std::function; + + explicit EventBus(size_t capacity = 32); + + // Thread-safe. Returns false (and counts a drop) if the queue is full. + bool publish(const Event& event); + + void subscribe(EventType type, Handler handler); + + // Delivers the events queued before this call; events published meanwhile wait for the next one. + // Returns how many events were delivered. + size_t dispatch(); + + uint32_t dropped() const; + + private: + std::vector queue_; + size_t head_ = 0; + size_t count_ = 0; + uint32_t dropped_ = 0; + mutable std::mutex mutex_; + std::array, static_cast(EventType::Count)> handlers_; +}; + +} // namespace roro diff --git a/lib/core/src/key_event.h b/lib/core/src/key_event.h new file mode 100644 index 0000000..a6b69f6 --- /dev/null +++ b/lib/core/src/key_event.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace roro { + +// Logical keys, already translated from the physical keyboard (Fn combos, Esc position, etc.). +enum class Key : uint8_t { + Char, // a printable character in `ch` (Unicode code point, accents already composed) + Up, + Down, + Left, + Right, + Select, + Back, + Home, + Tab, + Delete, +}; + +struct KeyEvent { + Key key = Key::Char; + uint32_t ch = 0; + bool shift = false; + bool ctrl = false; + bool alt = false; + + static KeyEvent of(Key key) { + KeyEvent e; + e.key = key; + return e; + } + + static KeyEvent character(uint32_t codePoint) { + KeyEvent e; + e.ch = codePoint; + return e; + } +}; + +} // namespace roro diff --git a/lib/core/src/service.h b/lib/core/src/service.h new file mode 100644 index 0000000..85afa89 --- /dev/null +++ b/lib/core/src/service.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace roro { + +// A long-lived background capability (see CONTEXT.md). Services are ticked cooperatively from the +// main loop; one that needs real concurrency (e.g. the radio) may run its own task internally and +// report through the EventBus. +class Service { + public: + virtual ~Service() = default; + virtual const char* name() const = 0; + virtual void start() {} + virtual void stop() {} + virtual void tick(uint32_t nowMs) { (void)nowMs; } + virtual uint32_t tickIntervalMs() const { return 1000; } +}; + +} // namespace roro diff --git a/lib/core/src/service_manager.cpp b/lib/core/src/service_manager.cpp new file mode 100644 index 0000000..d5ab262 --- /dev/null +++ b/lib/core/src/service_manager.cpp @@ -0,0 +1,33 @@ +#include "service_manager.h" + +namespace roro { + +void ServiceManager::add(Service& service) { entries_.push_back({&service, 0, true}); } + +void ServiceManager::startAll(uint32_t nowMs) { + for (auto& e : entries_) { + e.service->start(); + e.lastTickMs = nowMs; + e.due = true; + } + running_ = true; +} + +void ServiceManager::stopAll() { + for (auto it = entries_.rbegin(); it != entries_.rend(); ++it) it->service->stop(); + running_ = false; +} + +void ServiceManager::tick(uint32_t nowMs) { + if (!running_) return; + for (auto& e : entries_) { + // Unsigned subtraction keeps working across the 49-day millis() wraparound. + if (e.due || nowMs - e.lastTickMs >= e.service->tickIntervalMs()) { + e.due = false; + e.lastTickMs = nowMs; + e.service->tick(nowMs); + } + } +} + +} // namespace roro diff --git a/lib/core/src/service_manager.h b/lib/core/src/service_manager.h new file mode 100644 index 0000000..5a666ac --- /dev/null +++ b/lib/core/src/service_manager.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "service.h" + +namespace roro { + +class ServiceManager { + public: + void add(Service& service); + + // Starts services in the order added; each gets its first tick on the next tick() call. + void startAll(uint32_t nowMs); + // Stops services in reverse order. + void stopAll(); + + // Ticks every service whose interval has elapsed. Missed intervals are not replayed. + void tick(uint32_t nowMs); + + private: + struct Entry { + Service* service; + uint32_t lastTickMs; + bool due; + }; + std::vector entries_; + bool running_ = false; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 4865f14..b347189 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,8 +1,13 @@ // roro9stack — boot stub: proves toolchain, display and keyboard. #include +#include "event_bus.h" +#include "service_manager.h" #include "version.h" +static roro::EventBus bus; +static roro::ServiceManager services; + void setup() { auto cfg = M5.config(); M5Cardputer.begin(cfg, true); // true = enable keyboard @@ -19,6 +24,7 @@ void setup() { d.drawString("Press keys...", 8, 56); Serial.begin(115200); + services.startAll(millis()); } static void printStatus() { @@ -34,6 +40,9 @@ void loop() { printStatus(); } + services.tick(millis()); + bus.dispatch(); + M5Cardputer.update(); if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) { auto state = M5Cardputer.Keyboard.keysState(); diff --git a/test/test_app_manager/test_app_manager.cpp b/test/test_app_manager/test_app_manager.cpp new file mode 100644 index 0000000..c13f6b7 --- /dev/null +++ b/test/test_app_manager/test_app_manager.cpp @@ -0,0 +1,163 @@ +#include + +#include +#include + +#include "app_manager.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +struct FakeApp : App { + explicit FakeApp(std::vector* log, const char* tag) : log_(log), tag_(tag) {} + void onEnter() override { log_->push_back(std::string("enter ") + tag_); } + void onExit() override { log_->push_back(std::string("exit ") + tag_); } + bool onKey(const KeyEvent& e) override { + keys.push_back(e); + return handles; + } + void draw(Canvas&) override {} + std::vector keys; + bool handles = false; + + private: + std::vector* log_; + const char* tag_; +}; + +struct Fixture { + std::vector log; + FakeApp launcher{&log, "launcher"}; + FakeApp notes{&log, "notes"}; + FakeApp demo{&log, "demo"}; + AppManager manager{launcher}; + + Fixture() { + manager.registerApp({"notes", "Notes", false, ¬es}); + manager.registerApp({"demo", "Demo", true, &demo}); + manager.begin(); + log.clear(); + } +}; + +void test_launcher_is_in_foreground_after_begin() { + std::vector log; + FakeApp launcher(&log, "launcher"); + AppManager m(launcher); + m.begin(); + TEST_ASSERT_EQUAL_PTR(&launcher, &m.foreground()); + TEST_ASSERT_EQUAL_STRING("enter launcher", log[0].c_str()); +} + +void test_open_switches_foreground_with_exit_then_enter() { + Fixture f; + TEST_ASSERT_TRUE(f.manager.open("notes")); + TEST_ASSERT_EQUAL_PTR(&f.notes, &f.manager.foreground()); + std::vector expected = {"exit launcher", "enter notes"}; + TEST_ASSERT_TRUE(f.log == expected); +} + +void test_open_unknown_app_keeps_current_foreground() { + Fixture f; + TEST_ASSERT_FALSE(f.manager.open("nope")); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + TEST_ASSERT_TRUE(f.log.empty()); +} + +void test_opening_the_foreground_app_again_does_nothing() { + Fixture f; + f.manager.open("notes"); + f.log.clear(); + TEST_ASSERT_TRUE(f.manager.open("notes")); + TEST_ASSERT_TRUE(f.log.empty()); +} + +void test_home_key_always_returns_to_launcher_without_reaching_the_app() { + Fixture f; + f.manager.open("notes"); + f.notes.handles = true; + + f.manager.handleKey(KeyEvent::of(Key::Home)); + + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + TEST_ASSERT_EQUAL(0, f.notes.keys.size()); +} + +void test_back_goes_to_the_app_first_and_it_can_keep_it() { + Fixture f; + f.manager.open("notes"); + f.notes.handles = true; // e.g. leaving a sub-screen inside the app + + f.manager.handleKey(KeyEvent::of(Key::Back)); + + TEST_ASSERT_EQUAL_PTR(&f.notes, &f.manager.foreground()); + TEST_ASSERT_EQUAL(1, f.notes.keys.size()); +} + +void test_unhandled_back_returns_to_launcher() { + Fixture f; + f.manager.open("notes"); + + f.manager.handleKey(KeyEvent::of(Key::Back)); + + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); +} + +void test_back_on_launcher_stays_on_launcher() { + Fixture f; + f.manager.handleKey(KeyEvent::of(Key::Back)); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + TEST_ASSERT_TRUE(f.log.empty()); +} + +void test_other_keys_go_to_the_foreground_app() { + Fixture f; + f.manager.open("notes"); + f.manager.handleKey(KeyEvent::character('a')); + TEST_ASSERT_EQUAL(1, f.notes.keys.size()); + TEST_ASSERT_EQUAL('a', f.notes.keys[0].ch); +} + +void test_visible_apps_exclude_hidden_ones_and_keep_registration_order() { + Fixture f; + auto visible = f.manager.visibleApps(); + TEST_ASSERT_EQUAL(1, visible.size()); + TEST_ASSERT_EQUAL_STRING("notes", visible[0]->id); + TEST_ASSERT_TRUE(f.manager.open("demo")); // hidden apps can still be opened +} + +void test_switching_apps_requests_a_redraw() { + Fixture f; + f.manager.takeRedraw(); + f.manager.open("notes"); + TEST_ASSERT_TRUE(f.manager.takeRedraw()); + TEST_ASSERT_FALSE(f.manager.takeRedraw()); +} + +void test_app_can_request_its_own_redraw() { + Fixture f; + f.manager.open("notes"); + f.manager.takeRedraw(); + f.notes.requestRedraw(); + TEST_ASSERT_TRUE(f.manager.takeRedraw()); + TEST_ASSERT_FALSE(f.manager.takeRedraw()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_launcher_is_in_foreground_after_begin); + RUN_TEST(test_open_switches_foreground_with_exit_then_enter); + RUN_TEST(test_open_unknown_app_keeps_current_foreground); + RUN_TEST(test_opening_the_foreground_app_again_does_nothing); + RUN_TEST(test_home_key_always_returns_to_launcher_without_reaching_the_app); + RUN_TEST(test_back_goes_to_the_app_first_and_it_can_keep_it); + RUN_TEST(test_unhandled_back_returns_to_launcher); + RUN_TEST(test_back_on_launcher_stays_on_launcher); + RUN_TEST(test_other_keys_go_to_the_foreground_app); + RUN_TEST(test_visible_apps_exclude_hidden_ones_and_keep_registration_order); + RUN_TEST(test_switching_apps_requests_a_redraw); + RUN_TEST(test_app_can_request_its_own_redraw); + return UNITY_END(); +} diff --git a/test/test_event_bus/test_event_bus.cpp b/test/test_event_bus/test_event_bus.cpp new file mode 100644 index 0000000..588a11f --- /dev/null +++ b/test/test_event_bus/test_event_bus.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include + +#include "event_bus.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_published_events_are_delivered_on_dispatch_not_before() { + EventBus bus(8); + int calls = 0; + bus.subscribe(EventType::BatteryChanged, [&](const Event&) { calls++; }); + + bus.publish(Event{EventType::BatteryChanged}); + TEST_ASSERT_EQUAL(0, calls); + + TEST_ASSERT_EQUAL(1, bus.dispatch()); + TEST_ASSERT_EQUAL(1, calls); +} + +void test_only_subscribers_of_the_event_type_are_called() { + EventBus bus(8); + int battery = 0, notification = 0; + bus.subscribe(EventType::BatteryChanged, [&](const Event&) { battery++; }); + bus.subscribe(EventType::Notification, [&](const Event&) { notification++; }); + + bus.publish(Event{EventType::Notification}); + bus.dispatch(); + + TEST_ASSERT_EQUAL(0, battery); + TEST_ASSERT_EQUAL(1, notification); +} + +void test_every_subscriber_of_a_type_is_called_in_subscription_order() { + EventBus bus(8); + std::vector order; + bus.subscribe(EventType::Notification, [&](const Event&) { order.push_back(1); }); + bus.subscribe(EventType::Notification, [&](const Event&) { order.push_back(2); }); + + bus.publish(Event{EventType::Notification}); + bus.dispatch(); + + TEST_ASSERT_EQUAL(2, order.size()); + TEST_ASSERT_EQUAL(1, order[0]); + TEST_ASSERT_EQUAL(2, order[1]); +} + +void test_events_keep_publish_order_and_payload() { + EventBus bus(8); + std::vector values; + bus.subscribe(EventType::BatteryChanged, [&](const Event& e) { values.push_back(e.a); }); + + bus.publish(Event::withValues(EventType::BatteryChanged, 80)); + bus.publish(Event::withValues(EventType::BatteryChanged, 79)); + bus.dispatch(); + + TEST_ASSERT_EQUAL(2, values.size()); + TEST_ASSERT_EQUAL(80, values[0]); + TEST_ASSERT_EQUAL(79, values[1]); +} + +void test_text_payload_is_copied_and_truncated_safely() { + EventBus bus(8); + std::string received; + bus.subscribe(EventType::Notification, [&](const Event& e) { received = e.text; }); + + std::string longText(200, 'x'); + bus.publish(Event::withText(EventType::Notification, longText.c_str())); + bus.dispatch(); + + TEST_ASSERT_EQUAL(Event::kTextCapacity - 1, received.size()); +} + +void test_full_queue_drops_newest_and_counts_drops() { + EventBus bus(2); + std::vector values; + bus.subscribe(EventType::BatteryChanged, [&](const Event& e) { values.push_back(e.a); }); + + TEST_ASSERT_TRUE(bus.publish(Event::withValues(EventType::BatteryChanged, 1))); + TEST_ASSERT_TRUE(bus.publish(Event::withValues(EventType::BatteryChanged, 2))); + TEST_ASSERT_FALSE(bus.publish(Event::withValues(EventType::BatteryChanged, 3))); + TEST_ASSERT_EQUAL(1, bus.dropped()); + + bus.dispatch(); + TEST_ASSERT_EQUAL(2, values.size()); + TEST_ASSERT_EQUAL(2, values[1]); +} + +void test_events_published_during_dispatch_wait_for_next_dispatch() { + EventBus bus(8); + int notifications = 0; + bus.subscribe(EventType::BatteryChanged, + [&](const Event&) { bus.publish(Event{EventType::Notification}); }); + bus.subscribe(EventType::Notification, [&](const Event&) { notifications++; }); + + bus.publish(Event{EventType::BatteryChanged}); + TEST_ASSERT_EQUAL(1, bus.dispatch()); + TEST_ASSERT_EQUAL(0, notifications); + + TEST_ASSERT_EQUAL(1, bus.dispatch()); + TEST_ASSERT_EQUAL(1, notifications); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_published_events_are_delivered_on_dispatch_not_before); + RUN_TEST(test_only_subscribers_of_the_event_type_are_called); + RUN_TEST(test_every_subscriber_of_a_type_is_called_in_subscription_order); + RUN_TEST(test_events_keep_publish_order_and_payload); + RUN_TEST(test_text_payload_is_copied_and_truncated_safely); + RUN_TEST(test_full_queue_drops_newest_and_counts_drops); + RUN_TEST(test_events_published_during_dispatch_wait_for_next_dispatch); + return UNITY_END(); +} diff --git a/test/test_service_manager/test_service_manager.cpp b/test/test_service_manager/test_service_manager.cpp new file mode 100644 index 0000000..6f31dad --- /dev/null +++ b/test/test_service_manager/test_service_manager.cpp @@ -0,0 +1,127 @@ +#include + +#include +#include + +#include "service_manager.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +struct FakeService : Service { + FakeService(const char* n, uint32_t interval, std::vector* log) + : n_(n), interval_(interval), log_(log) {} + const char* name() const override { return n_; } + uint32_t tickIntervalMs() const override { return interval_; } + void start() override { log_->push_back(std::string("start ") + n_); } + void stop() override { log_->push_back(std::string("stop ") + n_); } + void tick(uint32_t nowMs) override { + ticks.push_back(nowMs); + log_->push_back(std::string("tick ") + n_); + } + std::vector ticks; + + private: + const char* n_; + uint32_t interval_; + std::vector* log_; +}; + +void test_services_start_in_order_and_stop_in_reverse() { + std::vector log; + FakeService a("a", 100, &log), b("b", 100, &log); + ServiceManager m; + m.add(a); + m.add(b); + + m.startAll(0); + m.stopAll(); + + std::vector expected = {"start a", "start b", "stop b", "stop a"}; + TEST_ASSERT_TRUE(log == expected); +} + +void test_service_ticks_immediately_after_start_then_at_its_interval() { + std::vector log; + FakeService s("s", 100, &log); + ServiceManager m; + m.add(s); + m.startAll(0); + + for (uint32_t t = 0; t <= 250; t += 10) m.tick(t); + + TEST_ASSERT_EQUAL(3, s.ticks.size()); + TEST_ASSERT_EQUAL(0, s.ticks[0]); + TEST_ASSERT_EQUAL(100, s.ticks[1]); + TEST_ASSERT_EQUAL(200, s.ticks[2]); +} + +void test_each_service_keeps_its_own_interval() { + std::vector log; + FakeService fast("fast", 10, &log), slow("slow", 100, &log); + ServiceManager m; + m.add(fast); + m.add(slow); + m.startAll(0); + + for (uint32_t t = 0; t < 100; t += 10) m.tick(t); + + TEST_ASSERT_EQUAL(10, fast.ticks.size()); + TEST_ASSERT_EQUAL(1, slow.ticks.size()); +} + +void test_late_tick_does_not_cause_a_burst_of_catch_up_ticks() { + std::vector log; + FakeService s("s", 100, &log); + ServiceManager m; + m.add(s); + m.startAll(0); + + m.tick(0); + m.tick(1000); // UI stalled for a second + m.tick(1010); + + TEST_ASSERT_EQUAL(2, s.ticks.size()); +} + +void test_ticking_survives_millis_wraparound() { + std::vector log; + FakeService s("s", 100, &log); + ServiceManager m; + m.add(s); + const uint32_t nearWrap = 0xFFFFFFFFu - 50; + m.startAll(nearWrap); + + m.tick(nearWrap); + m.tick(nearWrap + 60); // wrapped, only 60 ms elapsed + m.tick(nearWrap + 100); // wrapped, 100 ms elapsed + + TEST_ASSERT_EQUAL(2, s.ticks.size()); +} + +void test_services_are_not_ticked_before_start_or_after_stop() { + std::vector log; + FakeService s("s", 10, &log); + ServiceManager m; + m.add(s); + + m.tick(0); + m.startAll(0); + m.stopAll(); + m.tick(100); + + TEST_ASSERT_EQUAL(0, s.ticks.size()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_services_start_in_order_and_stop_in_reverse); + RUN_TEST(test_service_ticks_immediately_after_start_then_at_its_interval); + RUN_TEST(test_each_service_keeps_its_own_interval); + RUN_TEST(test_late_tick_does_not_cause_a_burst_of_catch_up_ticks); + RUN_TEST(test_ticking_survives_millis_wraparound); + RUN_TEST(test_services_are_not_ticked_before_start_or_after_stop); + return UNITY_END(); +}