#include "doctest.h" #include #include #include #include #include #include #include #include #include #include #include extern ServiceManifest app_internal_loader_service_manifest; namespace { error_t fake_load(AppLocation, void** out_runtime) { *out_runtime = nullptr; return ERROR_NONE; } // Stashed by fake_run() on every call, for tests that need to verify exactly what argc/argv it // received (e.g. that app-module deep-copied the caller's argv) without a getter to query it // through. int last_received_argc = -1; std::vector last_received_argv; void stash_received_arguments(int argc, char* argv[]) { last_received_argc = argc; last_received_argv.clear(); for (int i = 0; i < argc; i++) { last_received_argv.emplace_back(argv[i]); } } // A minimal stand-in for a real app's main(): subscribes to its own app_event stream and exits // as soon as it's asked to close - exactly the contract every app instance (with its own // dedicated task for its whole lifetime) is expected to follow. If launched with a single // parameter (app_manager_start_for_result()), acts as a modal dialog instead: returns the // requested result (argv[0], parsed as an int) immediately (the app's own return value IS the // delivered APP_EVENT_RESULT.result - see app_scheduler.cpp's thread_main()). int32_t fake_run(void*, uint32_t app_instance_id, int argc, char* argv[]) { stash_received_arguments(argc, argv); if (argc == 1) { // Single-arg shortcut used by the start_for_result() result-delivery tests: returns // immediately with argv[0] parsed as the result code, instead of running the normal // event loop below. Tests that pass other argc (0, or >1 to check deep-copy) fall // through and run the loop as usual. return static_cast(strtol(argv[0], nullptr, 10)); } AppEventSubscription sub {}; sub.app_instance_id = app_instance_id; app_event_subscribe(&sub); while (true) { AppEvent event {}; if (app_event_await(&sub, &event, pdMS_TO_TICKS(5000)) != ERROR_NONE) { break; // safety net so a bug here can't hang the test suite } if (event.type == APP_EVENT_CLOSE) { app_manager_finish(app_instance_id); break; } } app_event_unsubscribe(&sub); return 0; } void fake_unload(void*) { } AppLoaderApi fake_loader_api = { .load = fake_load, .run = fake_run, .unload = fake_unload, }; void* create_loader_service(const ServiceManifest*) { return &fake_loader_api; } void destroy_loader_service(const ServiceManifest*, void*) { } ServiceManifest fake_loader_manifest = { .id = APP_LOADER_PATH_SERVICE_ID, .create_service = create_loader_service, .destroy_service = destroy_loader_service, .on_start = nullptr, .on_stop = nullptr, }; void ensure_fake_loader_registered() { static bool registered = false; if (!registered) { CHECK_EQ(service_manager_add(&fake_loader_manifest, /*auto_start=*/true), ERROR_NONE); registered = true; } } // app-module's real APP_LOCATION_MEMORY loader (source/app_internal_loader.cpp) - not a fake, // since it has no platform dependency and is exactly what a statically-linked app would go // through. void ensure_memory_loader_registered() { static bool registered = false; if (!registered) { CHECK_EQ(service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true), ERROR_NONE); registered = true; } } // Same subscribe-until-close contract as fake_run() above, but called directly as an AppMainFn - // this is what a real internal app's entry point looks like. int32_t fake_app_main(uint32_t app_instance_id, int argc, char* argv[]) { return fake_run(nullptr, app_instance_id, argc, argv); } // Wraps app_manager_get_topmost_instance_id() for terse assertions: 0 if no app is Active. AppInstanceId topmost_instance_id() { AppInstanceId id = 0; return app_manager_get_topmost_instance_id(&id) == ERROR_NONE ? id : 0; } bool wait_for_state(uint32_t instance_id, AppInstanceState target, uint32_t timeout_ms) { uint32_t waited = 0; while (waited < timeout_ms) { if (app_manager_get_state(instance_id) == target) { return true; } delay_millis(10); waited += 10; } return app_manager_get_state(instance_id) == target; } } // namespace TEST_CASE("app_manager_start activates an app instance, app_manager_stop terminates it") { ensure_fake_loader_registered(); AppManifest manifest { "test.app.a", "Test App A", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); uint32_t instance_id = 0; REQUIRE_EQ(app_manager_start("test.app.a", &instance_id), ERROR_NONE); CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); CHECK_EQ(app_manager_stop(instance_id), ERROR_NONE); CHECK_EQ(app_manager_get_state(instance_id), APP_INSTANCE_STATE_STOPPED); app_manager_remove("test.app.a"); } TEST_CASE("app_manager_start never touches another already-running app - every instance gets its own task") { ensure_fake_loader_registered(); AppManifest manifest_b { "test.app.b", "Test App B", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; AppManifest manifest_c { "test.app.c", "Test App C", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest_b), ERROR_NONE); REQUIRE_EQ(app_manager_add(&manifest_c), ERROR_NONE); uint32_t id_b = 0; REQUIRE_EQ(app_manager_start("test.app.b", &id_b), ERROR_NONE); CHECK(wait_for_state(id_b, APP_INSTANCE_STATE_ACTIVE, 1000)); uint32_t id_c = 0; REQUIRE_EQ(app_manager_start("test.app.c", &id_c), ERROR_NONE); CHECK(wait_for_state(id_c, APP_INSTANCE_STATE_ACTIVE, 1000)); // b is untouched by c starting - both stay Active at once, each with its own task. CHECK_EQ(app_manager_get_state(id_b), APP_INSTANCE_STATE_ACTIVE); app_manager_stop(id_b); app_manager_stop(id_c); app_manager_remove("test.app.b"); app_manager_remove("test.app.c"); } TEST_CASE("app_manager_start always creates a fresh instance, even for the same manifest id twice") { ensure_fake_loader_registered(); AppManifest manifest { "test.app.twice", "Test App Twice", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); uint32_t id_first = 0; REQUIRE_EQ(app_manager_start("test.app.twice", &id_first), ERROR_NONE); CHECK(wait_for_state(id_first, APP_INSTANCE_STATE_ACTIVE, 1000)); uint32_t id_second = 0; REQUIRE_EQ(app_manager_start("test.app.twice", &id_second), ERROR_NONE); CHECK(wait_for_state(id_second, APP_INSTANCE_STATE_ACTIVE, 1000)); CHECK_NE(id_first, id_second); CHECK_EQ(app_manager_get_state(id_first), APP_INSTANCE_STATE_ACTIVE); app_manager_stop(id_first); app_manager_stop(id_second); app_manager_remove("test.app.twice"); } TEST_CASE("app_manager_get_state returns STOPPED for an unknown instance id") { CHECK_EQ(app_manager_get_state(999999), APP_INSTANCE_STATE_STOPPED); } TEST_CASE("app_manager_start_with_parameters deep-copies argv before the app instance receives it") { ensure_fake_loader_registered(); AppManifest manifest { "test.app.args", "Test App Args", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); uint32_t instance_id = 0; { // Caller's argv is stack-local and goes out of scope immediately after this block - // proves app-module made its own copy rather than aliasing the caller's strings. std::string ssid = "MyNetwork"; std::string password = "hunter2"; const char* argv[] = { ssid.c_str(), password.c_str() }; REQUIRE_EQ(app_manager_start_with_parameters("test.app.args", 2, argv, &instance_id), ERROR_NONE); } CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); REQUIRE_EQ(last_received_argc, 2); REQUIRE_EQ(last_received_argv.size(), 2u); CHECK_EQ(last_received_argv[0], "MyNetwork"); CHECK_EQ(last_received_argv[1], "hunter2"); app_manager_stop(instance_id); app_manager_remove("test.app.args"); } TEST_CASE("app_manager_add rejects a duplicate id") { AppManifest manifest { "test.app.dup", "Test App Dup", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); CHECK_EQ(app_manager_add(&manifest), ERROR_INVALID_ARGUMENT); app_manager_remove("test.app.dup"); } TEST_CASE("app_manager_for_each_manifest visits every registered manifest, including newly added ones") { AppManifest manifest_x { "test.app.foreach.x", "X", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; AppManifest manifest_y { "test.app.foreach.y", "Y", APP_CATEGORY_SETTINGS, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest_x), ERROR_NONE); REQUIRE_EQ(app_manager_add(&manifest_y), ERROR_NONE); std::vector seen_ids; app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { static_cast*>(context)->emplace_back(manifest->id); }, &seen_ids); CHECK(std::ranges::find(seen_ids, "test.app.foreach.x") != seen_ids.end()); CHECK(std::ranges::find(seen_ids, "test.app.foreach.y") != seen_ids.end()); app_manager_remove("test.app.foreach.x"); app_manager_remove("test.app.foreach.y"); seen_ids.clear(); app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { static_cast*>(context)->emplace_back(manifest->id); }, &seen_ids); CHECK(std::ranges::find(seen_ids, "test.app.foreach.x") == seen_ids.end()); } TEST_CASE("app_manager_start fails for an unregistered manifest id") { uint32_t instance_id = 0; CHECK_EQ(app_manager_start("test.app.nonexistent", &instance_id), ERROR_NOT_FOUND); } TEST_CASE("app_manager_start runs an APP_LOCATION_MEMORY app via its function pointer, through the real internal loader") { ensure_memory_loader_registered(); AppManifest manifest { "test.app.memory", "Test App Memory", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(fake_app_main) } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); uint32_t instance_id = 0; REQUIRE_EQ(app_manager_start("test.app.memory", &instance_id), ERROR_NONE); CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); CHECK_EQ(app_manager_stop(instance_id), ERROR_NONE); CHECK_EQ(app_manager_get_state(instance_id), APP_INSTANCE_STATE_STOPPED); app_manager_remove("test.app.memory"); } TEST_CASE("app_manager_start_for_result delivers APP_EVENT_RESULT to the parent, which stays Active throughout") { ensure_fake_loader_registered(); AppManifest parent_manifest { "test.app.parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; AppManifest child_manifest { "test.app.child", "Child", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE); REQUIRE_EQ(app_manager_add(&child_manifest), ERROR_NONE); uint32_t parent_id = 0; REQUIRE_EQ(app_manager_start("test.app.parent", &parent_id), ERROR_NONE); CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000)); AppEventSubscription parent_sub {}; parent_sub.app_instance_id = parent_id; REQUIRE_EQ(app_event_subscribe(&parent_sub), ERROR_NONE); const char* argv[] = { "42" }; uint32_t child_id = 0; REQUIRE_EQ(app_manager_start_for_result("test.app.child", parent_id, 1, argv, &child_id), ERROR_NONE); // Launching a modal child never touches the parent's own task/state. CHECK_EQ(app_manager_get_state(parent_id), APP_INSTANCE_STATE_ACTIVE); AppEvent event {}; REQUIRE_EQ(app_event_await(&parent_sub, &event, pdMS_TO_TICKS(2000)), ERROR_NONE); CHECK_EQ(event.type, APP_EVENT_RESULT); CHECK_EQ(event.result.launch_id, child_id); CHECK_EQ(event.result.result, 42); app_event_unsubscribe(&parent_sub); app_manager_stop(child_id); app_manager_stop(parent_id); app_manager_remove("test.app.parent"); app_manager_remove("test.app.child"); } TEST_CASE("app_manager_start_for_result delivers the child's own return value as the result") { ensure_fake_loader_registered(); AppManifest parent_manifest { "test.app.parent2", "Parent2", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; AppManifest child_manifest { "test.app.child2", "Child2", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE); REQUIRE_EQ(app_manager_add(&child_manifest), ERROR_NONE); uint32_t parent_id = 0; REQUIRE_EQ(app_manager_start("test.app.parent2", &parent_id), ERROR_NONE); CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000)); AppEventSubscription parent_sub {}; parent_sub.app_instance_id = parent_id; REQUIRE_EQ(app_event_subscribe(&parent_sub), ERROR_NONE); uint32_t child_id = 0; // No parameters - fake_run falls through to its normal CLOSE loop instead of acting as a // dialog. REQUIRE_EQ(app_manager_start_for_result("test.app.child2", parent_id, 0, nullptr, &child_id), ERROR_NONE); CHECK(wait_for_state(child_id, APP_INSTANCE_STATE_ACTIVE, 1000)); app_manager_stop(child_id); // force-close AppEvent event {}; REQUIRE_EQ(app_event_await(&parent_sub, &event, pdMS_TO_TICKS(2000)), ERROR_NONE); CHECK_EQ(event.type, APP_EVENT_RESULT); CHECK_EQ(event.result.launch_id, child_id); CHECK_EQ(event.result.result, 0); // fake_run's CLOSE loop always returns 0 app_event_unsubscribe(&parent_sub); app_manager_stop(parent_id); app_manager_remove("test.app.parent2"); app_manager_remove("test.app.child2"); } TEST_CASE("app_manager_get_topmost_instance_id returns NOT_FOUND when nothing is active, then tracks who's on top") { ensure_fake_loader_registered(); AppInstanceId id = 999999; CHECK_EQ(app_manager_get_topmost_instance_id(&id), ERROR_NOT_FOUND); AppManifest manifest_a { "test.app.top_a", "A", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; AppManifest manifest_b { "test.app.top_b", "B", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest_a), ERROR_NONE); REQUIRE_EQ(app_manager_add(&manifest_b), ERROR_NONE); uint32_t id_a = 0; REQUIRE_EQ(app_manager_start("test.app.top_a", &id_a), ERROR_NONE); CHECK(wait_for_state(id_a, APP_INSTANCE_STATE_ACTIVE, 1000)); CHECK_EQ(topmost_instance_id(), id_a); // a stays Active - b just has a higher (more recently allocated) instance id, so it becomes // topmost without a superseding/saving. uint32_t id_b = 0; REQUIRE_EQ(app_manager_start("test.app.top_b", &id_b), ERROR_NONE); CHECK(wait_for_state(id_b, APP_INSTANCE_STATE_ACTIVE, 1000)); CHECK_EQ(topmost_instance_id(), id_b); char app_id_buffer[64]; REQUIRE_EQ(app_manager_get_topmost_app_id(app_id_buffer, sizeof(app_id_buffer)), ERROR_NONE); CHECK_EQ(std::string(app_id_buffer), "test.app.top_b"); // A modal child stays Active alongside its parent while shown - the child (started more // recently) must be reported as topmost, not the parent. No parameters, so fake_run() takes // its persistent CLOSE loop branch instead of instantly resolving like a real dialog would - // needed here so there's a reliable window to observe it as topmost. uint32_t id_c = 0; REQUIRE_EQ(app_manager_start_for_result("test.app.top_a", id_b, 0, nullptr, &id_c), ERROR_NONE); CHECK(wait_for_state(id_c, APP_INSTANCE_STATE_ACTIVE, 1000)); CHECK_EQ(topmost_instance_id(), id_c); app_manager_stop(id_c); CHECK_EQ(topmost_instance_id(), id_b); app_manager_stop(id_a); app_manager_stop(id_b); app_manager_remove("test.app.top_a"); app_manager_remove("test.app.top_b"); } TEST_CASE("app_manager_get_topmost_app_id returns BUFFER_OVERFLOW for a too-small buffer, NOT_FOUND when nothing is active") { ensure_fake_loader_registered(); char buffer[4]; CHECK_EQ(app_manager_get_topmost_app_id(buffer, sizeof(buffer)), ERROR_NOT_FOUND); CHECK_EQ(app_manager_get_topmost_app_id(buffer, 0), ERROR_BUFFER_OVERFLOW); AppManifest manifest { "test.app.top_overflow", "Overflow", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); uint32_t id = 0; REQUIRE_EQ(app_manager_start("test.app.top_overflow", &id), ERROR_NONE); CHECK(wait_for_state(id, APP_INSTANCE_STATE_ACTIVE, 1000)); // "test.app.top_overflow" doesn't fit in a 4-byte buffer. CHECK_EQ(app_manager_get_topmost_app_id(buffer, sizeof(buffer)), ERROR_BUFFER_OVERFLOW); app_manager_stop(id); app_manager_remove("test.app.top_overflow"); }