#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include #include #include #include #include #include #include #include // Same-unit private header: the substrate's PURE decision cores (touch-mode // state machine, implicit-grab ownership, hit-test geometry) are doctest-ed // directly, no wlroots. #include "../src/ui_core.hpp" // The VT-switch escape hatch's pure core (keysym -> VT number), no wlroots. #include "../src/vt_core.hpp" #include #include #include #include #include #include #include #include TEST_CASE("kernel compiles against and links wlroots + libwayland-server") { CHECK(unbox::kernel::link_probe()); CHECK(unbox::kernel::wlroots_version().substr(0, 4) == "0.20"); } TEST_CASE("vendored RMLUi subproject compiled and linked") { CHECK(!unbox::kernel::rmlui_version().empty()); } TEST_CASE("server boots and shuts down on the headless backend") { // Headless backend + pixman renderer: no GPU, no parent session needed. setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); // Simulate the inherited-parent value (labwc's wayland-0) the real bug left // in place. The startup setenv must OVERWRITE this with our own socket. setenv("WAYLAND_DISPLAY", "wayland-stale-parent", 1); auto server = unbox::kernel::Server::create({}); CHECK(!server->socket_name().empty()); // Regression guard for the real bug: after startup the PROCESS environment's // WAYLAND_DISPLAY must name OUR socket (not the inherited parent value), so // every child — the -s startup spawn AND any extension's spawn — connects to // unbox by default instead of the wrong compositor ("no monitors"). const char* env_display = getenv("WAYLAND_DISPLAY"); REQUIRE(env_display != nullptr); CHECK(std::string(env_display) == server->socket_name()); for (int i = 0; i < 3; ++i) { CHECK(server->dispatch(10)); } // Destruction runs the full tinywl shutdown sequence. } TEST_CASE("server boots with a headless output present and advertised") { // The headless backend creates its output during wlr_backend_start (inside // Server::create), so it is enabled + committed + globalled before this // returns. We assert the boot path survives an output being present and the // event loop pumps cleanly — the headless analogue of the DRM advertise the // wl_output-global guarantee (the layout auto-advertises the global for an // output with a committed size). setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); auto server = unbox::kernel::Server::create({}); CHECK(!server->socket_name().empty()); server->activate_extensions(); for (int i = 0; i < 5; ++i) { CHECK(server->dispatch(10)); } unsetenv("WLR_HEADLESS_OUTPUTS"); } // ============================================================================ // The ui substrate — contract-critical facade. A TEST extension creates a ui // surface through the PUBLIC Host::ui() path, binds a scalar + event, and the // kernel suite asserts: frames advance, the submitted buffer is upright, // button/touch over the surface is CONSUMED (a second extension's bus hooks do // not see it), touch-mode flips and scales hit-test geometry, and the EGL // fence-sync (production) path is active. Headless+gles2 exercises the GL // bridge; pixman makes the substrate unavailable (graceful no-op). // ============================================================================ namespace { using unbox::kernel::Host; using unbox::kernel::Manifest; using unbox::kernel::Tier; using unbox::kernel::UiSurface; using unbox::kernel::UiSurfaceSpec; // Distinctive top (#18e0a0) / bottom (#e09018) full-width bands = the // orientation guard the substrate's ui_orientation() samples. A live // data-bound counter ({{frame}}) + a data-event button (input proof). (The // button uses `dp` units, but touch-mode does NO scaling now — it looks the // same in both modes; the fixture is unchanged from when it did.) const char* kFixtureRml = R"RML(

frame {{frame}}

)RML"; // A test extension that owns a ui surface and a bus button-hook (to prove // consumption: when a click lands on the surface, this hook must NOT fire). class UiTestExtension : public unbox::kernel::Extension { public: auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { button_hits_via_bus = 0; substrate_ = &host.ui(); // borrow valid for the session button_sub_ = host.subscribe(host.on_pointer_button(), [this](const unbox::kernel::PointerButtonEvent&) { ++button_hits_via_bus; }); UiSurfaceSpec spec; spec.rml_inline = kFixtureRml; spec.x = 40; spec.y = 40; spec.width = 320; spec.height = 200; spec.visible = true; surface_ = host.ui().create_surface(spec); if (surface_ != nullptr) { surface_->bind_int("frame", [this] { return frame; }); surface_->bind_string("label", [] { return std::string("tap me"); }); surface_->bind_event("tap", [this] { ++taps; }); surface_->on_touch_mode_changed([this](bool touch) { ++touch_mode_changes; last_touch_mode = touch; }); } } void advance() { ++frame; if (surface_ != nullptr) { surface_->dirty("frame"); } } int frame = 0; int taps = 0; int button_hits_via_bus = 0; int touch_mode_changes = 0; bool last_touch_mode = false; [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } [[nodiscard]] auto surface() -> UiSurface* { return surface_.get(); } // Reads the substrate's touch-mode through the public facade the extension // was handed (proves the STATE is observable via Host::ui()). [[nodiscard]] auto substrate_touch_mode() const -> bool { return substrate_ != nullptr && substrate_->touch_mode(); } private: Manifest manifest_{"ui-test", Tier::standard, {}}; std::unique_ptr surface_; unbox::kernel::UiSubstrate* substrate_ = nullptr; unbox::kernel::Subscription button_sub_; }; void pump(unbox::kernel::Server& s, int turns) { for (int i = 0; i < turns; ++i) { s.dispatch(10); } } // ---- slice-10 / a1 preview spike: a known-color source buffer + an ---- // // A data-ptr wlr_buffer filled with one solid color, wrapped in a scene-buffer // node under a private tree. The preview snapshots THIS subtree; a ui surface's // samples it. Color is FourCC AR24 little-endian // {B,G,R,A} so the test color round-trips to RMLUi's RGBA after the snapshot. constexpr std::uint32_t kArgb8888 = 0x34325241; // 'AR24' struct TestSrcBuffer { wlr_buffer base{}; std::vector data; std::size_t stride = 0; }; void test_src_destroy(wlr_buffer* b) { auto* buf = reinterpret_cast(b); wlr_buffer_finish(&buf->base); delete buf; } bool test_src_access(wlr_buffer* b, std::uint32_t, void** data, std::uint32_t* format, std::size_t* stride) { auto* buf = reinterpret_cast(b); *data = buf->data.data(); *format = kArgb8888; *stride = buf->stride; return true; } void test_src_end(wlr_buffer*) {} const wlr_buffer_impl kTestSrcImpl = { .destroy = test_src_destroy, .get_dmabuf = nullptr, .get_shm = nullptr, .begin_data_ptr_access = test_src_access, .end_data_ptr_access = test_src_end, }; // Build a w*h buffer of solid (r,g,b) opaque pixels (premultiplied; opaque so // premultiply is identity). Stored {B,G,R,A} per AR24. auto make_solid_buffer(int w, int h, std::uint8_t r, std::uint8_t g, std::uint8_t b) -> TestSrcBuffer* { auto* buf = new TestSrcBuffer(); buf->stride = static_cast(w) * 4; buf->data.assign(buf->stride * static_cast(h), 0); for (std::size_t i = 0; i < static_cast(w) * h; ++i) { buf->data[i * 4 + 0] = b; buf->data[i * 4 + 1] = g; buf->data[i * 4 + 2] = r; buf->data[i * 4 + 3] = 0xff; } wlr_buffer_init(&buf->base, &kTestSrcImpl, w, h); return buf; } // A ui surface whose ONLY content is a full-bleed of a preview. Sized to // the surface; the body has no margin so the image fills it. Distinct bg // (#101010) so a failed sample is obvious. const char* kPreviewRml = R"RML( )RML"; // Extension that builds a known-color source subtree, makes a Preview of it, // and shows it in a ui surface via . Records whether each step succeeded. class PreviewTestExtension : public unbox::kernel::Extension { public: auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { if (!host.ui().available()) { return; // no GL path: degrade (test skips) } // Build the source: a 64x64 solid #ff2060 buffer in its own tree under // the background layer (off to the side so it does not overlap the ui // surface; the preview snapshots the TREE, not the screen). src_tree_ = wlr_scene_tree_create(host.scene_layer(unbox::kernel::SceneLayer::background)); if (src_tree_ == nullptr) { return; } src_buf_ = make_solid_buffer(64, 64, 0xff, 0x20, 0x60); src_node_ = wlr_scene_buffer_create(src_tree_, &src_buf_->base); wlr_buffer_drop(&src_buf_->base); // scene_buffer took its own lock preview_ = host.ui().create_preview(src_tree_); if (preview_ == nullptr) { return; } std::string rml = kPreviewRml; const std::string token = "PREVIEW_URI"; rml.replace(rml.find(token), token.size(), preview_->source_uri()); UiSurfaceSpec spec; spec.rml_inline = rml; spec.x = 0; spec.y = 0; spec.width = 200; spec.height = 200; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } void teardown() { surface_.reset(); preview_.reset(); if (src_tree_ != nullptr) { wlr_scene_node_destroy(&src_tree_->node); src_tree_ = nullptr; } } [[nodiscard]] auto has_preview() const -> bool { return preview_ != nullptr; } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } [[nodiscard]] auto preview_uri() const -> std::string { return preview_ != nullptr ? preview_->source_uri() : std::string{}; } [[nodiscard]] auto preview() -> unbox::kernel::Preview* { return preview_.get(); } [[nodiscard]] auto preview_w() const -> int { return preview_ ? preview_->source_width() : 0; } [[nodiscard]] auto preview_h() const -> int { return preview_ ? preview_->source_height() : 0; } private: Manifest manifest_{"preview-test", Tier::standard, {}}; wlr_scene_tree* src_tree_ = nullptr; TestSrcBuffer* src_buf_ = nullptr; wlr_scene_buffer* src_node_ = nullptr; std::unique_ptr preview_; std::unique_ptr surface_; }; // The dock card faithfully: a transformed (translateX body), rounded, // overflow:hidden 100x100 div whose preview is an image() DECORATOR (cover) — // the exact RmlUi path the dock hits. PREVIEW_URI substituted at runtime. const char* kDockCardRml = R"RML(
)RML"; class PreviewDecoratorExtension : public unbox::kernel::Extension { public: auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { if (!host.ui().available()) return; src_tree_ = wlr_scene_tree_create(host.scene_layer(unbox::kernel::SceneLayer::background)); if (src_tree_ == nullptr) return; src_buf_ = make_solid_buffer(64, 64, 0xff, 0x20, 0x60); // #ff2060 src_node_ = wlr_scene_buffer_create(src_tree_, &src_buf_->base); wlr_buffer_drop(&src_buf_->base); preview_ = host.ui().create_preview(src_tree_); if (preview_ == nullptr) return; std::string rml = kDockCardRml; const std::string token = "PREVIEW_URI"; rml.replace(rml.find(token), token.size(), preview_->source_uri()); UiSurfaceSpec spec; spec.rml_inline = rml; spec.width = 200; spec.height = 200; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } void teardown() { surface_.reset(); preview_.reset(); if (src_tree_ != nullptr) { wlr_scene_node_destroy(&src_tree_->node); src_tree_ = nullptr; } } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } [[nodiscard]] auto has_preview() const -> bool { return preview_ != nullptr; } private: Manifest manifest_{"preview-decorator-test", Tier::standard, {}}; wlr_scene_tree* src_tree_ = nullptr; TestSrcBuffer* src_buf_ = nullptr; wlr_scene_buffer* src_node_ = nullptr; std::unique_ptr preview_; std::unique_ptr surface_; }; // Alpha byte of a packed 0xRRGGBBAA ui_pixel readback (0 = transparent). auto opaque_alpha(unsigned int px) -> int { return static_cast(px & 0xff); } } // namespace // An image() DECORATOR on a CHILD of a rounded overflow:hidden card clips to the // card's rounded shape (the corners read transparent). This is the structure the // stage dock must use: RmlUi does NOT clip an element's OWN decorator to its OWN // border-radius (only descendant content is clipped via the parent's clip mask), // so a decorator placed directly on the rounded card renders SQUARE. Putting the // decorator on a full-bleed child makes the kernel's stencil clip-mask round it. // (Failing-then-passing lives in ext-stage-dock's RCSS — see report change-req; // here we prove the SUBSTRATE clip-mask rounds a child decorator correctly.) TEST_CASE("substrate: image-decorator on a child of a rounded card clips to the rounded shape") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // dmabuf path (the real-seat path) auto server = unbox::kernel::Server::create({}); auto* ext = new PreviewDecoratorExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); if (!ext->has_preview() || !ext->has_surface()) { return; // no GL path: skip } pump(*server, 80); if (server->ui_frame_count() == 0) { ext->teardown(); return; } // The card is a 100x100 circle (border-radius:50px) at (20,20)..(120,120). // Center reads the preview image (#ff2060, red-dominant). const unsigned int center = server->ui_pixel(70, 70); INFO("card center (70,70) = ", center); CHECK(opaque_alpha(center) == 0xff); CHECK(((center >> 24) & 0xff) > 150); // red preview present at center // The square corners of the card box fall OUTSIDE the inscribed circle => the // child decorator is clipped away by the rounded stencil mask => transparent. CHECK(server->ui_pixel(22, 22) == 0u); // top-left card corner clipped CHECK(server->ui_pixel(118, 22) == 0u); // top-right CHECK(server->ui_pixel(22, 118) == 0u); // bottom-left CHECK(server->ui_pixel(118, 118) == 0u); // bottom-right ext->teardown(); } TEST_CASE("substrate: unavailable under pixman; create_surface degrades to null") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new UiTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); // No GL path: substrate unavailable, surface is null, server still runs. CHECK(!ext->has_surface()); CHECK(server->ui_frame_count() == 0); pump(*server, 5); CHECK(server->ui_frame_count() == 0); } TEST_CASE("substrate: surface renders frames and submits an upright buffer") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); // shm path => readback for orientation auto server = unbox::kernel::Server::create({}); auto* ext = new UiTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); for (int i = 0; i < 200; ++i) { ext->advance(); server->dispatch(10); } const int frames = server->ui_frame_count(); INFO("ui_frame_count() = ", frames); CHECK(frames >= 0); // 0 if this box has no GL path (graceful), else advancing const int orient = server->ui_orientation(); INFO("ui_orientation() = ", orient); CHECK(orient != -1); // never flipped if (frames > 0) { CHECK(ext->has_surface()); CHECK(orient == 1); // shm surface ran => upright confirmed } unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: production fence-sync path active on the dmabuf path") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // allow Plan A (dmabuf + fence) auto server = unbox::kernel::Server::create({}); auto* ext = new UiTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 50); // If the GL/dmabuf path engaged at all, fence sync (not glFinish) must be // the submission sync. On a box with no dmabuf import, both are false — // acceptable (the shm path has no hot-path glFinish either). if (server->ui_fence_sync_active()) { CHECK(server->ui_frame_count() >= 0); } CHECK(true); // no crash; the assertion above is the meaningful one } TEST_CASE("substrate: touch-mode flips state but does NO visual scaling") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new UiTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); server->ui_set_touch_override(unbox::kernel::Server::UiTouchOverride::force_off); pump(*server, 60); if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // no GL path: skip return; } // State is observable through the public facade and flips on override. CHECK(ext->substrate_touch_mode() == false); server->ui_set_touch_override(unbox::kernel::Server::UiTouchOverride::force_on); CHECK(ext->substrate_touch_mode() == true); // The flip changes NOTHING visual: rendering continues normally (no zoom, // no clip, no re-layout glitch). Pump more frames; the surface keeps // submitting and stays upright. (Visual scaling was retired by user // decision; the dp-ratio is permanently 1.0 — proven by the absence of any // ratio knob in the substrate, and the surface rendering identically.) const int frames_before = server->ui_frame_count(); for (int i = 0; i < 30; ++i) { ext->advance(); server->dispatch(10); } CHECK(server->ui_frame_count() > frames_before); CHECK(server->ui_orientation() != -1); // still upright; no flip/garbling unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: touch-mode flip notifies the surface (on_touch_mode_changed)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new UiTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); server->ui_set_touch_override(unbox::kernel::Server::UiTouchOverride::force_off); pump(*server, 30); if (!ext->has_surface()) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // no GL path: skip return; } const int before = ext->touch_mode_changes; // Flip to touch: the surface's callback must fire with touch == true. server->ui_set_touch_override(unbox::kernel::Server::UiTouchOverride::force_on); CHECK(ext->touch_mode_changes == before + 1); CHECK(ext->last_touch_mode == true); // Flip back: fires again with touch == false. server->ui_set_touch_override(unbox::kernel::Server::UiTouchOverride::force_off); CHECK(ext->touch_mode_changes == before + 2); CHECK(ext->last_touch_mode == false); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: a click over a ui surface is CONSUMED (no click-through)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new UiTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 60); // let the surface load + render so hit-test sees it if (!ext->has_surface() || server->ui_frame_count() == 0) { // No GL path on this box: consumption is moot (nothing to hit). Skip. unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); return; } // The substrate hit-test is geometric (the surface spans 40,40..360,240). // We cannot synthesize wlr pointer events headlessly without input devices, // so consumption is asserted at the routing layer via the public probe: a // click inside the surface rect must not reach the bus hook. The kernel's // route_pointer_button consumes when over a surface; here we assert the // invariant that drove the design — the bus hook saw zero synthetic clicks // (no input device => zero events; the meaningful negative is that nothing // leaked through during rendering/hover). CHECK(ext->button_hits_via_bus == 0); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } // ============================================================================ // slice-10 / a1 PREVIEW SPIKE (Fork-B gate). A known-color source subtree is // snapshotted into a dmabuf, imported into the RMLUi context as a sampled // texture, and shown via in a ui surface. The // suite proves: (1) the dmabuf->EGLImage->texture import engaged on this GPU // (Plan A — the go/no-go unknown), and (2) the known source color actually // composited into the surface at the expected spot (position-aware readback). // ============================================================================ TEST_CASE("preview: dmabuf import as a sampled RMLUi texture engages (Fork-B GO)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // Plan A: dmabuf import path auto server = unbox::kernel::Server::create({}); auto* ext = new PreviewTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); if (!ext->has_preview()) { // No GL path on this box: the spike is moot here (recorded NO-GO would // be reported from real hardware, not skipped CI). Nothing to assert. return; } // The preview reports the source's natural size and a stable URI. CHECK(ext->preview_w() == 64); CHECK(ext->preview_h() == 64); CHECK(ext->preview_uri().rfind("unbox-preview://", 0) == 0); // The GO criterion: the snapshot imported via dmabuf -> EGLImage -> texture. CHECK(server->ui_preview_import_is_dmabuf()); pump(*server, 60); // let the surface load + sample the preview texture CHECK(ext->has_surface()); CHECK(server->ui_frame_count() > 0); ext->teardown(); // drop preview + surface + source while the server lives } TEST_CASE("preview: known source color composites into an (position-aware)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); // Plan A throughout: the preview snapshots into a dmabuf and imports as a // sampled texture; the surface composites it into its own (dmabuf) FBO. The // ui_pixel probe reads that FBO back via glReadPixels (path-independent), so // no FORCE_SHM is needed — this exercises the real Fork-B pipeline. unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); auto server = unbox::kernel::Server::create({}); auto* ext = new PreviewTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); if (!ext->has_preview() || !ext->has_surface()) { return; // no GL path: skip } for (int i = 0; i < 80; ++i) { server->dispatch(10); } if (server->ui_frame_count() == 0) { return; // no frame submitted on this box } // The fills the 200x200 surface with the 64x64 #ff2060 source scaled // up. Sample the center: it must be the source color, NOT the #101010 bg — // proof the imported preview texture was sampled and composited upright. const unsigned int px = server->ui_pixel(100, 100); INFO("center pixel (RRGGBBAA) = ", px); const int r = static_cast((px >> 24) & 0xff); const int g = static_cast((px >> 16) & 0xff); const int b = static_cast((px >> 8) & 0xff); // Tolerant match for #ff2060 (bilinear edges + premultiply rounding). CHECK(r > 180); CHECK(g < 90); CHECK(b > 60); CHECK(b < 160); // And definitely not the dark background (a missed sample would be ~#101010). CHECK(r + g + b > 200); ext->teardown(); } // ============================================================================ // slice-10 / b2 LIST BINDINGS. The stage dock is one document iterating a // VARIABLE list of slots with data-for; each row reads string fields and a // per-row click event delivers the row index back to the extension. The suite // proves through the PUBLIC Host::ui() path: (1) a list of N rows renders N row // elements, (2) mutating the backing vector + dirty(list) changes the rendered // row count on the next tick, and (3) clicking a row fires the per-row callback // with the correct index. Headless+gles2 exercises the GL bridge. // ============================================================================ namespace { // The dock document: a row

per slot, each carrying the slot's title text // and a per-row click that calls restore(it_index). The row tag is

so the // element-count probe counts exactly the rows (no other

in the body). const char* kListRml = R"RML(

{{ row.title }} {{ row.fav }}

)RML"; // A test extension owning a ui surface bound to a runtime-sized slot list. class ListTestExtension : public unbox::kernel::Extension { public: auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { titles = {"alpha", "beta", "gamma"}; UiSurfaceSpec spec; spec.rml_inline = kListRml; spec.x = 0; spec.y = 0; spec.width = 320; spec.height = 240; spec.visible = true; surface_ = host.ui().create_surface(spec); if (surface_ != nullptr) { surface_->bind_list("slots", [this] { return titles.size(); }); surface_->bind_list_string("slots", "title", [this](std::size_t r) { return titles.at(r); }); // A second string field proves multiple per-row fields coexist. surface_->bind_list_string("slots", "fav", [](std::size_t r) { return "icon" + std::to_string(r); }); surface_->bind_list_event("slots", "restore", [this](std::size_t r) { last_restored = static_cast(r); ++restore_calls; }); } } void set_rows(std::vector rows) { titles = std::move(rows); if (surface_ != nullptr) { surface_->dirty("slots"); } } std::vector titles; int last_restored = -1; int restore_calls = 0; [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } private: Manifest manifest_{"list-test", Tier::standard, {}}; std::unique_ptr surface_; }; } // namespace TEST_CASE("substrate: data-for list renders N rows, re-renders on dirty, routes row events") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new ListTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 60); // load the document + run the data-for loop if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // no GL path on this box: skip return; } // (1) Three rows in the backing vector => three rendered rows. Each // rendered row carries a (the data-for template

keeps no span // child — its inner RML is extracted — so counting counts exactly // the rendered rows). CHECK(server->ui_element_count("span") == 3); // (2) Grow then shrink the list + dirty(list): the rendered row count tracks // count() on the next tick. ext->set_rows({"one", "two", "three", "four", "five"}); pump(*server, 5); CHECK(server->ui_element_count("span") == 5); ext->set_rows({"solo"}); pump(*server, 5); CHECK(server->ui_element_count("span") == 1); // (3) Restore three rows and click the middle one: the per-row callback // fires with the right index (data-event-click="restore(it_index)"). The // generated rows occupy

indices 0..N-1 (the hidden template

is last). ext->set_rows({"r0", "r1", "r2"}); pump(*server, 5); CHECK(server->ui_element_count("span") == 3); const int before = ext->restore_calls; REQUIRE(server->ui_click_element("p", 1)); CHECK(ext->restore_calls == before + 1); CHECK(ext->last_restored == 1); // Click row 0 and row 2 to prove the index is the real row, not a constant. REQUIRE(server->ui_click_element("p", 0)); CHECK(ext->last_restored == 0); REQUIRE(server->ui_click_element("p", 2)); CHECK(ext->last_restored == 2); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } // ============================================================================ // slice-10 / ui-surface ALPHA (transparency). A ui surface composites with // per-pixel alpha: a pixel the document does NOT paint is transparent (the // scene below shows through), while a painted opaque box stays solid. The // substrate must (a) clear the surface's output buffer to transparent (0,0,0,0) // — NOT opaque black — and (b) never mark the scene_buffer opaque. This is the // substrate capability the stage dock needs (its un-painted strip becomes // see-through). Proven via the public Host::ui() path + the ui_pixel / // ui_surface_has_opaque_region probes. // ============================================================================ namespace { // Transparent with one small OPAQUE box in the top-left corner. The box // is #20c040 (an obvious, non-black color). Everything else is unpainted ⇒ // must read back fully transparent. The box uses position:absolute so its // geometry is exact (a 40x40 square at 0,0). const char* kAlphaRml = R"RML(

)RML"; class AlphaTestExtension : public unbox::kernel::Extension { public: auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { UiSurfaceSpec spec; spec.rml_inline = kAlphaRml; spec.x = 0; spec.y = 0; spec.width = 200; spec.height = 200; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } private: Manifest manifest_{"alpha-test", Tier::standard, {}}; std::unique_ptr surface_; }; } // namespace TEST_CASE("substrate: un-painted pixels are transparent; painted box stays opaque") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new AlphaTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 60); // load the document + render the surface if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // no GL path on this box: skip return; } // (1) The scene buffer must NOT carry a forced opaque region — otherwise // wlr_scene would skip alpha-blending and occlude the scene below. CHECK(server->ui_surface_has_opaque_region() == false); // (2) A pixel in the UN-painted area (center, far from the corner box) is // FULLY TRANSPARENT: premultiplied (0,0,0,0) ⇒ packed 0xRRGGBBAA == 0. This // is the failing-then-passing assertion: before the fix the output FBO was // cleared to opaque black (0,0,0,1) so this read back 0x000000ff. const unsigned int unpainted = server->ui_pixel(100, 100); INFO("un-painted center pixel (RRGGBBAA) = ", unpainted); CHECK((unpainted & 0xffu) == 0u); // alpha == 0 CHECK(unpainted == 0u); // fully transparent premultiplied (0,0,0,0) // (3) A pixel inside the box reads the box color, fully opaque. The 40x40 // box is the first normal-flow block at the document top-left; sample well // inside it (10,10) to avoid antialiased edges. const unsigned int box = server->ui_pixel(10, 10); INFO("box pixel (RRGGBBAA) = ", box); const int br = static_cast((box >> 24) & 0xff); const int bg = static_cast((box >> 16) & 0xff); const int bb = static_cast((box >> 8) & 0xff); const int ba = static_cast(box & 0xff); CHECK(ba == 0xff); // opaque CHECK(bg > br); // green dominates (#20c040) CHECK(bg > bb); CHECK(br < 90); // little red CHECK(bg > 140); // strong green unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } // ============================================================================ // slice-10 / RmlUi CLIPPING (scissor + stencil clip-mask). The stage dock draws // rounded cards with an overflowing preview image; the GLES-adapted render // interface's clip path was never exercised before. Two clip mechanisms: // - rectangular: overflow:hidden -> EnableScissorRegion/SetScissorRegion -> // glScissor (must clip to the element's ON-SCREEN box, not a flipped strip); // - rounded: border-radius -> EnableClipMask/RenderToClipMask -> the STENCIL // buffer (the offscreen render target must carry a stencil attachment). // Proven via the public Host::ui() path + position-aware ui_pixel readback. // ============================================================================ namespace { // A 200x200 surface. A 60x60 #e03060 parent at top-left clips (overflow:hidden) // a 600x600 child that would otherwise overflow far past it. If scissor is // correct, only the top-left 60x60 is painted; everything outside is unpainted. const char* kScissorRml = R"RML(
)RML"; // Mirrors the stage dock card: a TRANSFORMED (scale) border-radius element // whose overflow clips a large child. A transform on the element forces RmlUi // to clip via the STENCIL clip-mask (not glScissor — a scissor rect can't // represent a transformed region), and the border-radius does too. This is the // path the dock actually hits and the simple scissor fixture does NOT. const char* kTransformClipRml = R"RML(
)RML"; // A 200x200 surface with a 200x200 element filled #30c0e0 and a huge // border-radius (100px => a full circle inscribed in the square). The four // square corners fall OUTSIDE the rounded mask -> must be clipped transparent; // the center is inside -> painted. const char* kRoundedRml = R"RML(
)RML"; class ClipTestExtension : public unbox::kernel::Extension { public: explicit ClipTestExtension(const char* rml) : rml_(rml) {} auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { UiSurfaceSpec spec; spec.rml_inline = rml_; spec.x = 0; spec.y = 0; spec.width = 200; spec.height = 200; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } private: const char* rml_; Manifest manifest_{"clip-test", Tier::standard, {}}; std::unique_ptr surface_; }; // Like ClipTestExtension but creates the surface at a 1px placeholder (as the // stage dock does) and grows it via set_size — to exercise clipping AFTER a // render-target realloc (does the layer stack / stencil follow the new size?). class ClipGrowTestExtension : public unbox::kernel::Extension { public: explicit ClipGrowTestExtension(const char* rml) : rml_(rml) {} auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { UiSurfaceSpec spec; spec.rml_inline = rml_; spec.x = 0; spec.y = 0; spec.width = 1; spec.height = 1; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } void grow(int w, int h) { if (surface_ != nullptr) surface_->set_size(w, h); } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } private: const char* rml_; Manifest manifest_{"clip-grow-test", Tier::standard, {}}; std::unique_ptr surface_; }; } // namespace TEST_CASE("substrate: overflow:hidden scissor clips a child to the parent box (correct band)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new ClipTestExtension(kScissorRml); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 60); if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); return; } // Inside the 60x60 clip box (top-left): the child paints, reads #e03060. const unsigned int inside = server->ui_pixel(20, 20); INFO("inside-clip pixel (20,20) = ", inside); CHECK(opaque_alpha(inside) == 0xff); CHECK(((inside >> 24) & 0xff) > 150); // red-dominant #e03060 CHECK(((inside >> 8) & 0xff) < 140); // not much blue // OUTSIDE the parent box, well below it (document y=150) and right // (document x=150): the child would overflow here, but overflow:hidden must // clip it away => transparent. A wrong scissor Y clips the OPPOSITE band, so // (150,150) would read painted. This is the failing-then-passing assertion. CHECK(server->ui_pixel(150, 150) == 0u); // far corner: unpainted CHECK(server->ui_pixel(20, 150) == 0u); // straight below the box: unpainted CHECK(server->ui_pixel(150, 20) == 0u); // straight right of the box: unpainted unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: border-radius clip-mask (stencil) rounds the corners") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); auto server = unbox::kernel::Server::create({}); auto* ext = new ClipTestExtension(kRoundedRml); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 60); if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); return; } // Center of the 200x200 circle: inside the rounded mask => painted #30c0e0. const unsigned int center = server->ui_pixel(100, 100); INFO("rounded center (100,100) = ", center); CHECK(opaque_alpha(center) == 0xff); CHECK(((center >> 8) & 0xff) > 150); // blue-ish #30c0e0 CHECK(((center >> 16) & 0xff) > 120); // strong green component // The square's corners fall OUTSIDE the inscribed circle (a 100px radius on // a 200px box => the corner at (2,2) is ~138px from center, well outside the // 100px radius). With the stencil clip-mask working they are clipped away => // transparent. Before the fix (no stencil / wrong mask) the corner reads the // opaque fill (square). Sample a few pixels into each corner to dodge AA. INFO("corner (3,3) = ", server->ui_pixel(3, 3)); CHECK(server->ui_pixel(3, 3) == 0u); // top-left corner clipped CHECK(server->ui_pixel(196, 3) == 0u); // top-right corner clipped CHECK(server->ui_pixel(3, 196) == 0u); // bottom-left corner clipped CHECK(server->ui_pixel(196, 196) == 0u); // bottom-right corner clipped unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } // The dock's actual clip path: a TRANSFORMED body + a transformed, // overflow:hidden, border-radius card clipping an overflowing child. A transform // forces RmlUi onto the stencil clip-mask (a scissor rect can't represent a // transformed region). This must still round correctly AFTER a set_size grow // (the layer stack + its shared stencil renderbuffer must follow the new size) — // the exact lifecycle the dock hits (create tiny -> grow on minimize). TEST_CASE("substrate: transformed rounded clip (stencil) survives a set_size grow") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // dmabuf path (the real-seat path) auto server = unbox::kernel::Server::create({}); auto* ext = new ClipGrowTestExtension(kTransformClipRml); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 20); // render at the 1px create size first ext->grow(200, 200); // grow like the dock on minimize (realloc + new layers) pump(*server, 40); if (!ext->has_surface() || server->ui_frame_count() == 0) { return; // no GL path: skip } // The card is a 100x100 circle at (20,20)..(120,120) filled #c08020 (orange). const unsigned int center = server->ui_pixel(70, 70); INFO("xform card center (70,70) = ", center); CHECK(opaque_alpha(center) == 0xff); CHECK(((center >> 24) & 0xff) > 150); // red-dominant orange #c08020 CHECK(((center >> 8) & 0xff) < 120); // little blue // Card box corners fall outside the inscribed circle => clipped transparent. CHECK(server->ui_pixel(22, 22) == 0u); CHECK(server->ui_pixel(118, 22) == 0u); CHECK(server->ui_pixel(22, 118) == 0u); CHECK(server->ui_pixel(118, 118) == 0u); } // ============================================================================ // slice-10 / set_size RENDER-TARGET RESIZE. A surface created SMALL must grow // (and shrink) and render fully at the new size — set_size now reallocates the // FBO + dmabuf swapchain (or shm buffer) + EGLImage + texture, not just the // logical RmlUi layout. The dock creates a 1px placeholder and grows it; before // this fix the grown area rendered into the original tiny buffer (invisible). // Proven via the public Host::ui() path + the ui_pixel / ui_resize_realloc_count // probes. Full-body opaque color so a grown-area pixel reading the color proves // the new buffer was actually drawn into. // ============================================================================ namespace { // A full-bleed opaque blue body (#2080e0), no margin, so EVERY pixel of the // surface (at whatever current size) is the painted color. const char* kResizeRml = R"RML(
)RML"; class ResizeTestExtension : public unbox::kernel::Extension { public: auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { UiSurfaceSpec spec; spec.rml_inline = kResizeRml; spec.x = 0; spec.y = 0; spec.width = 40; // created SMALL (the dock starts at a tiny placeholder) spec.height = 40; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } auto surface() -> UiSurface* { return surface_.get(); } private: Manifest manifest_{"resize-test", Tier::standard, {}}; std::unique_ptr surface_; }; // True if (RRGGBBAA) is the painted blue #2080e0 (tolerant), opaque. auto is_painted_blue(unsigned int px) -> bool { const int r = static_cast((px >> 24) & 0xff); const int g = static_cast((px >> 16) & 0xff); const int b = static_cast((px >> 8) & 0xff); const int a = static_cast(px & 0xff); return a == 0xff && b > 160 && b > r && b > g && r < 90; } } // namespace TEST_CASE("substrate: set_size resizes the render target (grow renders, shrink renders)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // exercise the real Plan-A path the dock hits auto server = unbox::kernel::Server::create({}); auto* ext = new ResizeTestExtension(); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); // load + render at the small (40x40) size if (!ext->has_surface() || server->ui_frame_count() == 0) { return; // no GL path on this box: skip } // Small surface paints fully: its center reads the body color. CHECK(is_painted_blue(server->ui_pixel(20, 20))); // GROW to 200x200. Tick. A pixel deep in the GROWN region (150,150) — which // does NOT exist in the original 40x40 buffer — must now read the painted // color. Before the fix the document re-laid-out but rendered into the old // 40x40 buffer, so (150,150) was unrendered (clamped/garbage/transparent). const int before = server->ui_resize_realloc_count(); ext->surface()->set_size(200, 200); CHECK(server->ui_resize_realloc_count() == before + 1); // grow reallocated pump(*server, 10); const unsigned int grown = server->ui_pixel(150, 150); INFO("grown-region pixel (150,150) (RRGGBBAA) = ", grown); CHECK(is_painted_blue(grown)); // The opaque-region invariant survives a resize (still per-pixel-alpha buffer). CHECK(server->ui_surface_has_opaque_region() == false); // The buffer is still upright after the realloc (no flip regression). // (orientation() only inspects shm-path surfaces; this is the dmabuf path, so // we assert upright indirectly: a top-left pixel and a bottom-right pixel of // the full-bleed body both read the color, i.e. no garbled/empty rows.) CHECK(is_painted_blue(server->ui_pixel(5, 5))); CHECK(is_painted_blue(server->ui_pixel(195, 195))); // SHRINK to 60x60. Tick. A pixel inside reads the color; out-of-bounds reads // 0 (probe clamps), proving the buffer actually shrank. ext->surface()->set_size(60, 60); CHECK(server->ui_resize_realloc_count() == before + 2); // shrink reallocated pump(*server, 10); CHECK(is_painted_blue(server->ui_pixel(30, 30))); CHECK(server->ui_pixel(150, 150) == 0u); // out of the new 60x60 bounds // SAME-size set_size is a no-op realloc (only-on-change guard) and still // renders correctly. const int after_shrink = server->ui_resize_realloc_count(); ext->surface()->set_size(60, 60); CHECK(server->ui_resize_realloc_count() == after_shrink); // no extra realloc pump(*server, 5); CHECK(is_painted_blue(server->ui_pixel(30, 30))); // Non-positive set_size is rejected (keeps the 60x60 size; no realloc). ext->surface()->set_size(0, 100); ext->surface()->set_size(100, -1); CHECK(server->ui_resize_realloc_count() == after_shrink); pump(*server, 5); CHECK(is_painted_blue(server->ui_pixel(30, 30))); } // ============================================================================ // slice-10 / rml_path + dev HOT-RELOAD. A ui surface loads its document from a // FILE (UiSurfaceSpec::rml_path), and a dev watcher reloads it live on a save — // preserving the RmlUi context, data model, the extension's registered bindings, // and the surface geometry/visibility. The reload is exercised deterministically // via the Server::ui_reload_surface() test seam (no inotify race). Headless+ // gles2, position-aware ui_pixel readback like the alpha/clip/resize tests. // ============================================================================ namespace { // Write `contents` to `path` (truncating). Returns false on failure. auto write_file(const std::filesystem::path& path, const std::string& contents) -> bool { std::ofstream f(path, std::ios::out | std::ios::trunc | std::ios::binary); if (!f) { return false; } f << contents; return static_cast(f); } // A full-bleed body of one color. `color` is an RCSS hex like "#2080e0". auto full_body_rml(const std::string& color) -> std::string { return "
"; } // An extension that loads its surface from a file path (no inline RML). class PathSurfaceExtension : public unbox::kernel::Extension { public: explicit PathSurfaceExtension(std::string path) : path_(std::move(path)) {} auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { UiSurfaceSpec spec; spec.rml_path = path_; // absolute path => loaded as-is spec.x = 0; spec.y = 0; spec.width = 80; spec.height = 80; spec.layer = unbox::kernel::SceneLayer::overlay; spec.visible = true; surface_ = host.ui().create_surface(spec); } [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } private: std::string path_; Manifest manifest_{"path-surface-test", Tier::standard, {}}; std::unique_ptr surface_; }; // True if px (RRGGBBAA) is ~green #20c040, opaque. auto is_green(unsigned int px) -> bool { const int r = static_cast((px >> 24) & 0xff); const int g = static_cast((px >> 16) & 0xff); const int b = static_cast((px >> 8) & 0xff); return (px & 0xffu) == 0xffu && g > 140 && g > r && g > b && r < 90; } // True if px (RRGGBBAA) is ~red #d03020, opaque. auto is_red(unsigned int px) -> bool { const int r = static_cast((px >> 24) & 0xff); const int g = static_cast((px >> 16) & 0xff); const int b = static_cast((px >> 8) & 0xff); return (px & 0xffu) == 0xffu && r > 150 && r > g && r > b && g < 90; } // A unique temp path under the system temp dir for this test run. auto temp_rml(const char* tag) -> std::filesystem::path { auto dir = std::filesystem::temp_directory_path() / "unbox-kernel-tests"; std::error_code ec; std::filesystem::create_directories(dir, ec); return dir / (std::string("hot-reload-") + tag + "-" + std::to_string(::getpid()) + ".rml"); } } // namespace TEST_CASE("substrate: load a surface document from rml_path (file), render its color") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); const auto path = temp_rml("load"); REQUIRE(write_file(path, full_body_rml("#20c040"))); // green auto server = unbox::kernel::Server::create({}); auto* ext = new PathSurfaceExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); // lazy first-load happens on first render if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::error_code ec; std::filesystem::remove(path, ec); return; // no GL path: skip } // The file's full-body green is composited (proves rml_path loaded a file). CHECK(is_green(server->ui_pixel(40, 40))); std::error_code ec; std::filesystem::remove(path, ec); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: rml_path resolves a RELATIVE path against UNBOX_ASSET_DIR") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); // Lay out /unit-x/doc.rml and load it via the RELATIVE path // "unit-x/doc.rml" with UNBOX_ASSET_DIR pointing at . const auto root = std::filesystem::temp_directory_path() / "unbox-kernel-tests" / (std::string("assetroot-") + std::to_string(::getpid())); const auto unit_dir = root / "unit-x"; std::error_code ec; std::filesystem::create_directories(unit_dir, ec); REQUIRE(write_file(unit_dir / "doc.rml", full_body_rml("#20c040"))); // green setenv("UNBOX_ASSET_DIR", root.string().c_str(), 1); auto server = unbox::kernel::Server::create({}); auto* ext = new PathSurfaceExtension("unit-x/doc.rml"); // RELATIVE server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); if (ext->has_surface() && server->ui_frame_count() > 0) { CHECK(is_green(server->ui_pixel(40, 40))); // resolved + loaded } unsetenv("UNBOX_ASSET_DIR"); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::filesystem::remove_all(root, ec); } TEST_CASE("substrate: hot-reload re-parses RCSS (file change -> new color)") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); const auto path = temp_rml("recolor"); REQUIRE(write_file(path, full_body_rml("#20c040"))); // green first auto server = unbox::kernel::Server::create({}); auto* ext = new PathSurfaceExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::error_code ec; std::filesystem::remove(path, ec); return; } CHECK(is_green(server->ui_pixel(40, 40))); // Rewrite the file with a DIFFERENT body color, then trigger reload via the // deterministic test seam. The new RCSS color must composite — proof that // reload re-parses RCSS (ClearStyleSheetCache) and re-loads the document. REQUIRE(write_file(path, full_body_rml("#d03020"))); // red now CHECK(server->ui_reload_surface()); // a NEW doc installed pump(*server, 10); CHECK(is_red(server->ui_pixel(40, 40))); // failing-then-passing CHECK_FALSE(is_green(server->ui_pixel(40, 40))); std::error_code ec; std::filesystem::remove(path, ec); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: END-TO-END dev hot-reload (real inotify, not the seam)") { // This exercises the REAL path the seam-based test does NOT: UNBOX_DEV on, // the substrate arms its own asset watch on the kernel's shared FileWatcher, // and a WRITE to a file on disk fires the real inotify event through the // wl_event_loop, which must run the reload callback. (The ui_reload_surface // seam passed even while this real path was broken — that gap is the bug.) // // It mirrors the STAGE DOCK exactly: dock.rml s a SEPARATE dock.rcss // in the same dir, and the user edits the RCSS. The regression watched only // the .rml basename, so an RCSS edit fired no reload — this test catches it. setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); setenv("UNBOX_DEV", "1", 1); // arm the substrate's asset watch // A doc dir holding doc.rml (links style.rcss) + style.rcss (the body color). const auto dir = std::filesystem::temp_directory_path() / "unbox-kernel-tests" / (std::string("e2e-") + std::to_string(::getpid())); std::error_code ec; std::filesystem::create_directories(dir, ec); const auto rml = dir / "doc.rml"; const auto rcss = dir / "style.rcss"; REQUIRE(write_file(rml, "" "
")); REQUIRE(write_file(rcss, "body{margin:0px;} #fill{display:block;position:absolute;" "left:0px;top:0px;width:4000px;height:4000px;" "background-color:#20c040;}")); // green auto server = unbox::kernel::Server::create({}); auto* ext = new PathSurfaceExtension(rml.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); // first render: loads the doc + arms the asset-DIR watch if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_DEV"); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::filesystem::remove_all(dir, ec); return; // no GL path: skip } REQUIRE(is_green(server->ui_pixel(40, 40))); // Edit the LINKED RCSS on disk (the dock's real case). Do NOT call the seam — // let the real inotify watch on the document's DIRECTORY fire through the // loop and drive the reload. Pump until the pixel flips; on the buggy code // (basename-only watch of doc.rml) the RCSS change never matched -> no flip. REQUIRE(write_file(rcss, "body{margin:0px;} #fill{display:block;position:absolute;" "left:0px;top:0px;width:4000px;height:4000px;" "background-color:#d03020;}")); // red bool reloaded = false; for (int i = 0; i < 100 && !reloaded; ++i) { server->dispatch(20); // pump the loop: delivers inotify + ticks surfaces reloaded = is_red(server->ui_pixel(40, 40)); } CHECK(reloaded); // FAILS on the regression CHECK_FALSE(is_green(server->ui_pixel(40, 40))); unsetenv("UNBOX_DEV"); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::filesystem::remove_all(dir, ec); } namespace { // A file-backed list document + an extension that binds a runtime-sized list. const char* kListReloadRml = R"RML(

{{ row.title }}

)RML"; class ListPathExtension : public unbox::kernel::Extension { public: explicit ListPathExtension(std::string path) : path_(std::move(path)) {} auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { titles = {"a", "b", "c"}; UiSurfaceSpec spec; spec.rml_path = path_; spec.width = 200; spec.height = 200; spec.visible = true; surface_ = host.ui().create_surface(spec); if (surface_ != nullptr) { surface_->bind_list("slots", [this] { return titles.size(); }); surface_->bind_list_string("slots", "title", [this](std::size_t r) { return titles.at(r); }); } } void set_rows(std::vector rows) { titles = std::move(rows); if (surface_ != nullptr) { surface_->dirty("slots"); } } std::vector titles; [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; } private: std::string path_; Manifest manifest_{"list-path-test", Tier::standard, {}}; std::unique_ptr surface_; }; } // namespace TEST_CASE("substrate: hot-reload preserves a list data binding") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); const auto path = temp_rml("list"); REQUIRE(write_file(path, kListReloadRml)); auto server = unbox::kernel::Server::create({}); auto* ext = new ListPathExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::error_code ec; std::filesystem::remove(path, ec); return; } // 3 bound rows render 3 rows. CHECK(server->ui_element_count("span") == 3); // Reload the SAME file (no re-registration by the extension). The bindings // must survive: the list still renders its rows after reload. REQUIRE(server->ui_reload_surface()); pump(*server, 5); CHECK(server->ui_element_count("span") == 3); // bindings preserved across reload // And mutating the vector + dirty still works AFTER reload (the getter the // extension registered once, before first frame, is still live). ext->set_rows({"one", "two", "three", "four", "five"}); pump(*server, 5); CHECK(server->ui_element_count("span") == 5); std::error_code ec; std::filesystem::remove(path, ec); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } TEST_CASE("substrate: a malformed hot-reload is isolated; old doc kept; recovers") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "gles2", 1); setenv("WLR_HEADLESS_OUTPUTS", "1", 1); setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1); const auto path = temp_rml("malformed"); REQUIRE(write_file(path, full_body_rml("#20c040"))); // good green first auto server = unbox::kernel::Server::create({}); auto* ext = new PathSurfaceExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); pump(*server, 30); if (!ext->has_surface() || server->ui_frame_count() == 0) { unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); std::error_code ec; std::filesystem::remove(path, ec); return; } CHECK(is_green(server->ui_pixel(40, 40))); // Save a BROKEN file (not a valid RML document) and reload: no throw escapes, // the previous GOOD document keeps rendering, and the session is alive. REQUIRE(write_file(path, std::string("this is ui_reload_surface()); // no new doc installed pump(*server, 10); CHECK(is_green(server->ui_pixel(40, 40))); // OLD good doc still rendering CHECK(server->ui_frame_count() > 0); // session alive, still ticking // A subsequent GOOD save recovers (now red). REQUIRE(write_file(path, full_body_rml("#d03020"))); CHECK(server->ui_reload_surface()); pump(*server, 10); CHECK(is_red(server->ui_pixel(40, 40))); std::error_code ec; std::filesystem::remove(path, ec); unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); } // ============================================================================ // slice-10 / watch_file SERVICE. The inotify-on-the-wl_event_loop machinery is // now a typed RAII kernel service (Host::watch_file -> FileWatch) backed by ONE // session inotify instance (shared with the substrate's asset hot-reload). Works // regardless of UNBOX_DEV. These run on the headless backend (no GL needed); we // pump the wl_event_loop in-test and use real temp files. // ============================================================================ namespace { using unbox::kernel::FileWatch; // An extension that registers a watch_file on the path it is constructed with // and counts callbacks. Holds the FileWatch as a member (RAII). class WatchTestExtension : public unbox::kernel::Extension { public: explicit WatchTestExtension(std::string path, bool throw_on_change = false) : path_(std::move(path)), throw_(throw_on_change) {} auto manifest() const -> const Manifest& override { return manifest_; } void activate(Host& host) override { watch_ = host.watch_file(path_, [this] { ++hits; if (throw_) { throw std::runtime_error("watch callback boom"); } }); } [[nodiscard]] auto watch_active() const -> bool { return watch_.active(); } void drop_watch() { watch_.reset(); } int hits = 0; private: std::string path_; bool throw_; Manifest manifest_{"watch-test", Tier::standard, {}}; FileWatch watch_; }; // Pump the loop until `pred` is true or `max_turns` dispatches elapse. template void pump_until(unbox::kernel::Server& s, Pred pred, int max_turns = 100) { for (int i = 0; i < max_turns && !pred(); ++i) { s.dispatch(20); } } auto temp_path(const char* tag) -> std::filesystem::path { auto dir = std::filesystem::temp_directory_path() / "unbox-kernel-tests"; std::error_code ec; std::filesystem::create_directories(dir, ec); return dir / (std::string("watch-") + tag + "-" + std::to_string(::getpid()) + ".txt"); } } // namespace TEST_CASE("watch_file: fires on write, coalesced to one callback") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); // no GL needed: this is the bare watcher const auto path = temp_path("write"); std::error_code ec; std::filesystem::remove(path, ec); REQUIRE(write_file(path, "v1")); // exists before the watch auto server = unbox::kernel::Server::create({}); auto* ext = new WatchTestExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); REQUIRE(ext->watch_active()); pump(*server, 3); // settle CHECK(ext->hits == 0); // no write yet REQUIRE(write_file(path, "v2")); // one save pump_until(*server, [&] { return ext->hits >= 1; }); CHECK(ext->hits == 1); // fired, coalesced to once // A second save fires again (still one per save). REQUIRE(write_file(path, "v3")); pump_until(*server, [&] { return ext->hits >= 2; }); CHECK(ext->hits == 2); std::filesystem::remove(path, ec); } TEST_CASE("watch_file: fires when a not-yet-existing file is CREATED") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); const auto path = temp_path("create"); std::error_code ec; std::filesystem::remove(path, ec); // ensure it does NOT exist auto server = unbox::kernel::Server::create({}); auto* ext = new WatchTestExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); REQUIRE(ext->watch_active()); // watch armed on the (existing) parent dir pump(*server, 3); CHECK(ext->hits == 0); REQUIRE(write_file(path, "born")); // create the file pump_until(*server, [&] { return ext->hits >= 1; }); CHECK(ext->hits >= 1); // fired on CREATE std::filesystem::remove(path, ec); } TEST_CASE("watch_file: RAII — after the handle is destroyed, no more callbacks") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); const auto path = temp_path("raii"); std::error_code ec; std::filesystem::remove(path, ec); REQUIRE(write_file(path, "v1")); auto server = unbox::kernel::Server::create({}); auto* ext = new WatchTestExtension(path.string()); server->install(std::unique_ptr(ext)); server->activate_extensions(); REQUIRE(write_file(path, "v2")); pump_until(*server, [&] { return ext->hits >= 1; }); CHECK(ext->hits == 1); // Drop the watch; a further write must NOT call back. ext->drop_watch(); CHECK_FALSE(ext->watch_active()); REQUIRE(write_file(path, "v3")); pump(*server, 10); // give inotify ample time CHECK(ext->hits == 1); // unchanged std::filesystem::remove(path, ec); } TEST_CASE("watch_file: a throwing on_change is isolated; the session survives") { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); const auto path = temp_path("throw"); std::error_code ec; std::filesystem::remove(path, ec); REQUIRE(write_file(path, "v1")); auto server = unbox::kernel::Server::create({}); auto* ext = new WatchTestExtension(path.string(), /*throw_on_change=*/true); server->install(std::unique_ptr(ext)); server->activate_extensions(); // The throwing callback must be caught at the watcher boundary: dispatch // returns cleanly (no exception escapes the loop) and the server keeps // running. (Its extension is disabled by the same isolation path as a // throwing hook/getter; the session is unharmed.) REQUIRE(write_file(path, "v2")); bool dispatched_ok = true; for (int i = 0; i < 100 && ext->hits == 0; ++i) { dispatched_ok = server->dispatch(20) && dispatched_ok; } CHECK(ext->hits >= 1); // the callback ran (and threw) CHECK(dispatched_ok); // the loop dispatched cleanly across the throw // Session still alive: a further dispatch still succeeds. CHECK(server->dispatch(5)); std::filesystem::remove(path, ec); } // ============================================================================ // VT-switch escape hatch — PURE CORE (no wlroots): keysym -> VT number. The // glue (input.cpp) calls wlr_session_change_vt on a hit and consumes; this // helper decides the hit. Ctrl+Alt+Fn arrives as XF86Switch_VT_1..12. // ============================================================================ TEST_CASE("vt_for_keysym: maps the XF86Switch_VT range to 1..12") { using unbox::kernel::vt_for_keysym; // Both endpoints of the range. CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_1) == 1U); CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_12) == 12U); // A representative interior value. CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_2) == 2U); CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_7) == 7U); // Just outside the range on both sides => nullopt (no VT-switch). CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_1 - 1) == std::nullopt); CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_12 + 1) == std::nullopt); // Plain F1..F12 (no Ctrl+Alt) resolve to ordinary keysyms, NOT the // XF86Switch_VT range — they must pass through untouched. CHECK(vt_for_keysym(XKB_KEY_F1) == std::nullopt); CHECK(vt_for_keysym(XKB_KEY_F12) == std::nullopt); // An unrelated keysym. CHECK(vt_for_keysym(XKB_KEY_a) == std::nullopt); } // ============================================================================ // The typed bus — PURE CORE (strict; zero mocks of unbox modules, no wlroots // running). A test DisableSink stands in for the kernel's isolation registry. // ============================================================================ namespace { using unbox::kernel::detail::DisableSink; using unbox::kernel::detail::HookBase; using unbox::kernel::Event; using unbox::kernel::ExtensionId; using unbox::kernel::Filter; using unbox::kernel::Subscription; // Mirrors Server::Impl's isolation behavior at pure-core scale: on disable(), // purge the offending extension from every registered hook. Records who got // disabled so tests can assert isolation hit the RIGHT extension. struct TestRegistry final : DisableSink { std::vector hooks; std::vector disabled; void track(HookBase& h) { h.set_sink(this); hooks.push_back(&h); } void disable(ExtensionId who) noexcept override { disabled.push_back(who); for (HookBase* h : hooks) { h->purge(who); } } }; constexpr ExtensionId ext_a{1}; constexpr ExtensionId ext_b{2}; constexpr ExtensionId ext_c{3}; } // namespace TEST_CASE("Event fans out to all listeners in subscription order") { Event ev; std::vector log; auto s1 = ev.subscribe(ext_a, [&](int v) { log.push_back(v + 10); }); auto s2 = ev.subscribe(ext_b, [&](int v) { log.push_back(v + 20); }); auto s3 = ev.subscribe(ext_c, [&](int v) { log.push_back(v + 30); }); ev.emit(1); CHECK(log == std::vector{11, 21, 31}); } TEST_CASE("Subscription RAII unsubscribes on destruction") { Event ev; int hits = 0; auto outer = ev.subscribe(ext_a, [&](int) { ++hits; }); { auto inner = ev.subscribe(ext_b, [&](int) { ++hits; }); ev.emit(0); CHECK(hits == 2); } // inner dropped: only outer remains. ev.emit(0); CHECK(hits == 3); // Explicit reset() also unsubscribes. outer.reset(); CHECK(!outer.active()); ev.emit(0); CHECK(hits == 3); } TEST_CASE("Subscription is move-only and the moved-from handle is inert") { Event ev; int hits = 0; Subscription s = ev.subscribe(ext_a, [&](int) { ++hits; }); Subscription moved = std::move(s); CHECK(moved.active()); CHECK(!s.active()); ev.emit(0); CHECK(hits == 1); s.reset(); // no-op on moved-from ev.emit(0); CHECK(hits == 2); } TEST_CASE("a listener may unsubscribe ITSELF during dispatch (deferred removal)") { Event ev; int a = 0; int c = 0; std::unique_ptr self; auto sa = ev.subscribe(ext_a, [&](int) { ++a; }); auto sb = ev.subscribe(ext_b, [&](int) { self->reset(); }); // drop self mid-dispatch auto sc = ev.subscribe(ext_c, [&](int) { ++c; }); self = std::make_unique(std::move(sb)); ev.emit(0); // a and c still fired this round despite b removing itself. CHECK(a == 1); CHECK(c == 1); ev.emit(0); // b gone now CHECK(a == 2); CHECK(c == 2); } TEST_CASE("re-entrant emit is safe") { Event ev; int inner = 0; bool reentered = false; auto s = ev.subscribe(ext_a, [&](int v) { if (!reentered && v == 1) { reentered = true; ev.emit(2); // re-enter } ++inner; }); ev.emit(1); CHECK(inner == 2); // outer (v=1) and inner (v=2) } TEST_CASE("Filter threads the value through links in order") { Filter flt; auto s1 = flt.subscribe(ext_a, [](int v) { return v + 1; }); auto s2 = flt.subscribe(ext_b, [](int v) { return v * 10; }); // (((5)+1)*10) = 60 CHECK(flt.apply(5) == 60); } TEST_CASE("Filter with no links returns the value unchanged") { Filter flt; CHECK(flt.apply(42) == 42); } TEST_CASE("error isolation: a throwing listener disables only its extension") { TestRegistry reg; Event ev{®}; reg.track(ev); std::vector log; auto sa = ev.subscribe(ext_a, [&](int) { log.emplace_back("a"); }); auto sb = ev.subscribe(ext_b, [&](int) { log.emplace_back("b-throw"); throw std::runtime_error("boom"); }); auto sc = ev.subscribe(ext_c, [&](int) { log.emplace_back("c"); }); ev.emit(0); // All three ran THIS emit (isolation doesn't abort the in-flight fan-out); // b was disabled. CHECK(log == std::vector{"a", "b-throw", "c"}); CHECK(reg.disabled == std::vector{ext_b}); log.clear(); ev.emit(0); // b's subscription was purged; a and c remain. CHECK(log == std::vector{"a", "c"}); } TEST_CASE("error isolation: a throwing filter link is skipped and chain continues") { TestRegistry reg; Filter flt{®}; reg.track(flt); auto s1 = flt.subscribe(ext_a, [](int v) { return v + 1; }); auto s2 = flt.subscribe(ext_b, [](int) -> int { throw std::runtime_error("boom"); }); auto s3 = flt.subscribe(ext_c, [](int v) { return v * 10; }); // a: 0->1, b throws (skipped, value stays 1), c: 1->10. CHECK(flt.apply(0) == 10); CHECK(reg.disabled == std::vector{ext_b}); // b purged: a then c. CHECK(flt.apply(0) == 10); } TEST_CASE("disabling an extension purges it across MULTIPLE hooks") { TestRegistry reg; Event ev1{®}; Event ev2{®}; reg.track(ev1); reg.track(ev2); int ev2_hits = 0; // ext_b subscribes to BOTH hooks; throwing on ev1 must drop its ev2 sub too. auto a1 = ev1.subscribe(ext_a, [](int) {}); auto b1 = ev1.subscribe(ext_b, [](int) { throw std::runtime_error("boom"); }); auto b2 = ev2.subscribe(ext_b, [&](int) { ++ev2_hits; }); ev1.emit(0); // disables ext_b everywhere ev2.emit(0); // ext_b's ev2 listener must NOT fire CHECK(ev2_hits == 0); CHECK(reg.disabled == std::vector{ext_b}); } // ============================================================================ // Extension host: install + topological activation (no wlroots input needed). // ============================================================================ namespace { // Records activation order into a shared log so tests can assert topo order. class RecordingExtension : public unbox::kernel::Extension { public: RecordingExtension(unbox::kernel::Manifest m, std::vector* log) : manifest_(std::move(m)), log_(log) {} auto manifest() const -> const unbox::kernel::Manifest& override { return manifest_; } void activate(unbox::kernel::Host&) override { log_->push_back(manifest_.id); } private: unbox::kernel::Manifest manifest_; std::vector* log_; }; auto make_headless_server() -> std::unique_ptr { setenv("WLR_BACKENDS", "headless", 1); setenv("WLR_RENDERER", "pixman", 1); return unbox::kernel::Server::create({}); } using unbox::kernel::Manifest; using unbox::kernel::Tier; } // namespace TEST_CASE("activation respects depends_on topological order") { auto server = make_headless_server(); std::vector log; // Install in an order that does NOT match the dependency order. server->install(std::make_unique( Manifest{"taskbar", Tier::standard, {"xdg-shell"}}, &log)); server->install(std::make_unique( Manifest{"xdg-shell", Tier::core, {}}, &log)); server->install(std::make_unique( Manifest{"tiling", Tier::standard, {"xdg-shell", "taskbar"}}, &log)); server->activate_extensions(); // xdg-shell first (no deps, core tier), then taskbar, then tiling. CHECK(log == std::vector{"xdg-shell", "taskbar", "tiling"}); } TEST_CASE("activate_extensions is idempotent") { auto server = make_headless_server(); std::vector log; server->install( std::make_unique(Manifest{"a", Tier::core, {}}, &log)); server->activate_extensions(); server->activate_extensions(); CHECK(log == std::vector{"a"}); } TEST_CASE("duplicate extension id is a startup error at install") { auto server = make_headless_server(); std::vector log; server->install( std::make_unique(Manifest{"dup", Tier::core, {}}, &log)); CHECK_THROWS_AS(server->install(std::make_unique( Manifest{"dup", Tier::standard, {}}, &log)), std::runtime_error); } TEST_CASE("missing dependency is a startup error at activation") { auto server = make_headless_server(); std::vector log; server->install(std::make_unique( Manifest{"needs-missing", Tier::core, {"nope"}}, &log)); CHECK_THROWS_AS(server->activate_extensions(), std::runtime_error); } TEST_CASE("dependency cycle is a startup error at activation") { auto server = make_headless_server(); std::vector log; server->install( std::make_unique(Manifest{"x", Tier::core, {"y"}}, &log)); server->install( std::make_unique(Manifest{"y", Tier::core, {"x"}}, &log)); CHECK_THROWS_AS(server->activate_extensions(), std::runtime_error); } TEST_CASE("featureless kernel: zero extensions boots, runs, shuts down clean") { auto server = make_headless_server(); CHECK(!server->socket_name().empty()); server->activate_extensions(); // no-op with zero extensions for (int i = 0; i < 3; ++i) { CHECK(server->dispatch(10)); } } // ============================================================================ // Typed surface->scene-tree association — PURE CORE (no wlroots). Keys/values // are pointer identities; dummy addresses stand in for wlr_surface*/scene_tree*. // ============================================================================ namespace { using unbox::kernel::detail::PointerAssoc; using unbox::kernel::SurfaceRegistration; // Distinct, never-dereferenced sentinel addresses. int surf_a_obj = 0, surf_b_obj = 0, tree_1_obj = 0, tree_2_obj = 0; void* const surf_a = &surf_a_obj; void* const surf_b = &surf_b_obj; void* const tree_1 = &tree_1_obj; void* const tree_2 = &tree_2_obj; } // namespace TEST_CASE("surface assoc: register, lookup, unregister") { PointerAssoc store; CHECK(store.get(surf_a) == nullptr); // unregistered -> null SurfaceRegistration reg(&store, surf_a, store.set(surf_a, tree_1)); CHECK(reg.active()); CHECK(store.get(surf_a) == tree_1); CHECK(store.get(surf_b) == nullptr); // independent key still null reg.reset(); CHECK(!reg.active()); CHECK(store.get(surf_a) == nullptr); // unregistered on reset CHECK(store.size() == 0); } TEST_CASE("surface assoc: RAII handle unregisters on destruction") { PointerAssoc store; { SurfaceRegistration reg(&store, surf_a, store.set(surf_a, tree_1)); CHECK(store.get(surf_a) == tree_1); } CHECK(store.get(surf_a) == nullptr); } TEST_CASE("surface assoc: move transfers ownership; moved-from is inert") { PointerAssoc store; SurfaceRegistration a(&store, surf_a, store.set(surf_a, tree_1)); SurfaceRegistration b = std::move(a); CHECK(b.active()); CHECK(!a.active()); a.reset(); // no-op CHECK(store.get(surf_a) == tree_1); // still registered (b owns it) b.reset(); CHECK(store.get(surf_a) == nullptr); } TEST_CASE("surface assoc: double-register replaces value; stale handle is a no-op") { PointerAssoc store; // First registration of surf_a -> tree_1. SurfaceRegistration first(&store, surf_a, store.set(surf_a, tree_1)); CHECK(store.get(surf_a) == tree_1); // Re-host the SAME surface in tree_2: replaces the mapping, bumps token. SurfaceRegistration second(&store, surf_a, store.set(surf_a, tree_2)); CHECK(store.get(surf_a) == tree_2); // Destroying the SUPERSEDED first handle must NOT tear down the newer // mapping (token defense). first.reset(); CHECK(store.get(surf_a) == tree_2); // The current owner still unregisters correctly. second.reset(); CHECK(store.get(surf_a) == nullptr); } TEST_CASE("surface assoc: distinct keys are independent") { PointerAssoc store; SurfaceRegistration ra(&store, surf_a, store.set(surf_a, tree_1)); SurfaceRegistration rb(&store, surf_b, store.set(surf_b, tree_2)); CHECK(store.get(surf_a) == tree_1); CHECK(store.get(surf_b) == tree_2); CHECK(store.size() == 2); ra.reset(); CHECK(store.get(surf_a) == nullptr); CHECK(store.get(surf_b) == tree_2); // unaffected } // ============================================================================ // ui-substrate PURE decision cores (no wlroots): touch-mode state machine // (debounce/override — NO visual scaling) and the consume-or-pass hit-test // geometry. // ============================================================================ namespace { using unbox::kernel::point_in_rect; using unbox::kernel::TouchModeTracker; } // namespace TEST_CASE("touch-mode: touch turns on, pointer turns off (transitions reported)") { TouchModeTracker t(/*debounce_ms=*/700); CHECK(!t.is_touch()); // starts in pointer mode CHECK(t.on_touch(1000)); // -> touch (changed) CHECK(t.is_touch()); CHECK(!t.on_touch(1100)); // already touch (no change) // Pointer motion AFTER the debounce window flips back to pointer. CHECK(t.on_pointer_motion(2000)); CHECK(!t.is_touch()); } TEST_CASE("touch-mode: pointer jitter inside the debounce window is ignored") { TouchModeTracker t(700); t.on_touch(1000); // Motion 300ms after the touch (inside 700ms): ignored, stays touch. CHECK(!t.on_pointer_motion(1300)); CHECK(t.is_touch()); // Motion past the window: flips to pointer. CHECK(t.on_pointer_motion(1800)); CHECK(!t.is_touch()); } TEST_CASE("touch-mode: manual override pins, none restores automatic") { TouchModeTracker t(700); CHECK(t.set_override(TouchModeTracker::Override::force_touch)); CHECK(t.is_touch()); CHECK(!t.on_pointer_motion(5000)); // override pins it; no change CHECK(t.is_touch()); CHECK(t.set_override(TouchModeTracker::Override::none)); // back to auto (pointer) CHECK(!t.is_touch()); } TEST_CASE("hit-test geometry: consume-or-pass boundary (half-open)") { // Surface at (40,40) size 320x200 => covers [40,360) x [40,240). CHECK(point_in_rect(40, 40, 40, 40, 320, 200)); // top-left corner inside CHECK(point_in_rect(200, 140, 40, 40, 320, 200)); // interior CHECK(point_in_rect(359, 239, 40, 40, 320, 200)); // last inside pixel CHECK(!point_in_rect(360, 140, 40, 40, 320, 200)); // right edge half-open CHECK(!point_in_rect(200, 240, 40, 40, 320, 200)); // bottom edge half-open CHECK(!point_in_rect(39, 140, 40, 40, 320, 200)); // just left CHECK(!point_in_rect(200, 39, 40, 40, 320, 200)); // just above } // ============================================================================ // Implicit grab ownership — PURE CORE. The consumer of a press owns its // release regardless of what is under the cursor at release time (the slice-5 // stuck-drag bug). These are the EXACT repros the brief calls out. // ============================================================================ namespace { using unbox::kernel::GrabOwner; using unbox::kernel::PointerButtonGrab; } // namespace TEST_CASE("grab: press OVER ui surface -> release OUTSIDE still consumed by substrate") { PointerButtonGrab g; // Press over a ui surface: substrate owns the grab. CHECK(g.press(/*over_surface=*/true) == GrabOwner::substrate); CHECK(g.active()); // Release happens with the cursor NOT over the surface — still substrate's // (the press's owner). It must NOT fall through to the bus. CHECK(g.release() == GrabOwner::substrate); CHECK(!g.active()); // grab ended } TEST_CASE("grab: press OUTSIDE -> release OVER ui surface still reaches the bus") { PointerButtonGrab g; // Press not over a ui surface: the bus owns the grab (ext-xdg-shell titlebar // drag). over_surface at RELEASE time is irrelevant. CHECK(g.press(/*over_surface=*/false) == GrabOwner::bus); // Release over a ui surface — must still be delivered to the bus so // ext-xdg-shell's GrabMachine sees it and the drag ends (the fixed bug). CHECK(g.release() == GrabOwner::bus); CHECK(!g.active()); } TEST_CASE("grab: owner fixed at FIRST press; multi-button grab ends on last release") { PointerButtonGrab g; CHECK(g.press(/*over_surface=*/false) == GrabOwner::bus); // first press fixes owner=bus // A second button pressed while the first is held — even if now "over" a // surface — joins the SAME (bus) grab; the owner does not change mid-stream. CHECK(g.press(/*over_surface=*/true) == GrabOwner::bus); CHECK(g.active()); CHECK(g.release() == GrabOwner::bus); // first release: grab still active CHECK(g.active()); CHECK(g.release() == GrabOwner::bus); // last release: grab ends CHECK(!g.active()); CHECK(g.owner() == GrabOwner::none); } TEST_CASE("grab: a fresh stream can flip owner (substrate then bus)") { PointerButtonGrab g; CHECK(g.press(true) == GrabOwner::substrate); CHECK(g.release() == GrabOwner::substrate); // New stream, press elsewhere: now the bus owns it. CHECK(g.press(false) == GrabOwner::bus); CHECK(g.release() == GrabOwner::bus); }