From f4e4b3490374c88aa72ca5cc330fd10759cb7ce8 Mon Sep 17 00:00:00 2001 From: Calic Date: Mon, 15 Jun 2026 03:04:45 +0200 Subject: [PATCH] test: v0.4.0 focus/drag/resize assertions Adds 46 new W.3 assertions covering: focus(id) reorders within open_order; focus(hud) no-op; raise alias; lower(id) to bottom; get_focused tier priority + nil-when-empty; click-to-focus auto-routing; drag updates bounds_override; drag screen-clamp on all four edges; resize updates bounds_override; min_size + max_size clamps; dispatch-time drag-start + resize-start consume the click; chromeless drag-start excluded; bounds_override survives drag-end and is respected by point_in_any_panel. Bumps panel-test 0.3.0 -> 0.4.0 + panel-dep 0.4.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 39 ++++-- init.lua | 367 ++++++++++++++++++++++++++++++++++++++++++++++++++- manifest.lib | 6 +- 3 files changed, 398 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 50baad4..2881dee 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ # lib-core.panel-test -Test-module for `lib-core.panel`. v0.3.0 expands to 79 assertions: +Test-module for `lib-core.panel`. v0.4.0 expands to 128 assertions: - v0.1.x: registry lifecycle, theme validation, error handling, pause-gate, context-menu dispatch. - v0.2.0 (W.0): multi-active model, 11 layout-slots, custom-fn layout, hit-test reverse-open-order, bw-compat shim. - v0.3.0 (W.2): z-tiers (hud/normal/top), persistent windows, input_block routing (none/self/all), default-input_block per tier, - panel.point_in_any_panel public API. + panel.point_in_any_panel public API, chromeless guards, top-tier + modal swallow. +- v0.4.0 (W.3): focus API (focus/raise/lower/get_focused), + click-to-focus auto-routing, drag-by-title-bar with 32px + screen-clamp, resize-by-SE-corner with min/max_size clamps, + bounds_override pattern persistence, chromeless drag guard. -**Version:** 0.3.0 +**Version:** 0.4.0 **Lib-ID:** lib-core.panel-test -**Requires:** lib-core.panel v0.3.0 -**Tags:** test, panel, ui, window-manager +**Requires:** lib-core.panel v0.4.0 +**Tags:** test, panel, ui, window-manager, focus, drag, resize ## Running @@ -23,9 +28,9 @@ SPOREL_LIBS_DIR=~/Projects/Sporel/sporel-libs SPOREL_CI=1 SPOREL_LOG_STDERR=1 \ ./build/Sporel.exe --test-libs=lib-core.panel-test 2>&1 | tail -15 ``` -Expected: `pass=79 fail=0`. +Expected: `pass=128 fail=0`. -## Assertion Summary (v0.3.0) +## Assertion Summary (v0.4.0) **v0.1.x baseline (10 assertions):** 1. register + open / close round-trip @@ -45,7 +50,7 @@ layout-slot resolution (left-half, right-half, center, custom fn), loud-error on unknown slot, hit-test reverse-open-order, is_pausing across all open windows. -**v0.3.0 W.2 z-tiers + persistent + input-block (25 assertions):** +**v0.3.0 W.2 z-tiers + persistent + input-block (28 assertions):** - persistent flag auto-opens at register-time + is in open_order - z_tier render-order is hud → normal → top - within z_tier, open-order preserved @@ -58,3 +63,21 @@ across all open windows. - Loud-Error on unknown input_block - default input_block per tier (hud=none, normal=self, top=all) - point_in_any_panel public API (inside / outside / none open) +- chromeless suppresses phantom close-button hit-test +- top-tier modal swallows outside-clicks before lower tiers can claim + +**v0.4.0 W.3 focus + drag + resize (46 assertions):** +- focus(id) reorders within open_order; focus(hud) is no-op +- raise alias = focus +- lower(id) moves to bottom of open_order +- get_focused() prioritizes top-tier over normal; nil when nothing open +- click on window auto-focuses (non-hud only) +- drag updates bounds_override (simulate-backdoor) +- drag screen-clamp keeps 32px of title-bar visible on all edges +- resize updates bounds_override (SE-corner; simulate-backdoor) +- resize min_size + max_size constraints clamp correctly +- dispatch-time drag-start fires on real title-bar click +- dispatch-time resize-start fires on real SE-corner click +- chromeless widget does NOT drag-start on top-area click +- bounds_override survives drag-end (persists in window record) +- point_in_any_panel respects bounds_override diff --git a/init.lua b/init.lua index bfd6ab4..9c2c347 100644 --- a/init.lua +++ b/init.lua @@ -1,11 +1,16 @@ --- lib-core.panel-test v0.3.0 — Panel test suite +-- lib-core.panel-test v0.4.0 — Panel test suite -- Exercises: registry lifecycle, theme, errors, pause-gate, context-menu -- dispatch, multi-active model, layout-slots, hit-test reverse-order, -- z_tiers, persistent windows, input_block routing, --- panel.point_in_any_panel. +-- panel.point_in_any_panel, focus/raise/lower API, click-to-focus, +-- drag-by-title-bar (with screen-clamp), resize-by-SE-corner (with min/max +-- size constraints). -- Render paths are NOT tested here: engine.render.* is blocked in test-mode -- (assert_in_render_phase guard in engine_lua_render.c). Input dispatch is --- exercised via panel._dispatch_event_for_test(event). +-- exercised via panel._dispatch_event_for_test(event). Drag/resize state +-- is exercised via panel._test_simulate_drag_{start,move,end} and +-- panel._test_simulate_resize_{start,move,end} backdoors (engine.input +-- is not poll-able from a test-lib context). local panel = require("lib-core.panel") local T = engine.test @@ -602,6 +607,362 @@ function M.run_tests(_ctx) T.assert(not normal_clicked_m, "modal swallow: normal-tier widget does NOT receive click while top-tier all-modal is open") panel._test_reset_all() + + -- ================================================================== + -- W.3 v0.4.0 — Focus + Drag + Resize + -- ================================================================== + + -- ------------------------------------------------------------------ + -- W.3 #1: focus(id) reorders within open_order + -- ------------------------------------------------------------------ + panel.register("w_fA", make_widget({ title = "A" })) + panel.register("w_fB", make_widget({ title = "B" })) + panel.open("w_fA") + panel.open("w_fB") + T.equals(panel._test_get_focused_id(), "w_fB", + "W.3 B focused initially (last-opened)") + panel.focus("w_fA") + T.equals(panel._test_get_focused_id(), "w_fA", + "W.3 focus(A) reorders so A is now focused") + local fo = panel._test_get_open_ids() + T.equals(fo[#fo], "w_fA", + "W.3 focus moves widget to end of open_order") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #2: focus on hud-tier widget is a no-op + -- ------------------------------------------------------------------ + panel.register("w_hud_f", make_widget({ title = "Hud" }), + { z_tier = "hud", layout = "top" }) + panel.register("w_norm_f", make_widget({ title = "Norm" }), + { z_tier = "normal", layout = "bottom" }) + panel.open("w_hud_f") + panel.open("w_norm_f") + -- normal-tier opened last → focused + T.equals(panel._test_get_focused_id(), "w_norm_f", + "W.3 normal-tier focused after open") + panel.focus("w_hud_f") -- should be no-op + T.equals(panel._test_get_focused_id(), "w_norm_f", + "W.3 focus(hud-tier widget) is a no-op (hud never focusable)") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #3: raise alias = focus + -- ------------------------------------------------------------------ + panel.register("w_rA", make_widget({ title = "A" })) + panel.register("w_rB", make_widget({ title = "B" })) + panel.open("w_rA") + panel.open("w_rB") + panel.raise("w_rA") + T.equals(panel._test_get_focused_id(), "w_rA", + "W.3 raise(id) is alias for focus(id)") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #4: lower(id) moves to bottom of open_order + -- ------------------------------------------------------------------ + panel.register("w_lA", make_widget({ title = "A" })) + panel.register("w_lB", make_widget({ title = "B" })) + panel.open("w_lA") + panel.open("w_lB") + panel.lower("w_lB") -- B was focused; lower → bottom + local lo = panel._test_get_open_ids() + T.equals(lo[1], "w_lB", + "W.3 lower(B) moves B to bottom of open_order") + T.equals(panel._test_get_focused_id(), "w_lA", + "W.3 after lower(B), A is focused (last in order)") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #5: get_focused() prioritizes top-tier over normal + -- ------------------------------------------------------------------ + panel.register("w_gn", make_widget({ title = "N" }), + { z_tier = "normal", layout = "left" }) + panel.register("w_gt", make_widget({ title = "T" }), + { z_tier = "top", layout = "center" }) + panel.open("w_gt") + panel.open("w_gn") + -- gn opened last; but top-tier gt should win for get_focused + T.equals(panel.get_focused(), "w_gt", + "W.3 get_focused() returns top-tier widget even when normal-tier is last-opened") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #6: get_focused() returns last-opened when no top-tier + -- ------------------------------------------------------------------ + panel.register("w_g1", make_widget({ title = "1" })) + panel.register("w_g2", make_widget({ title = "2" })) + panel.open("w_g1") + panel.open("w_g2") + T.equals(panel.get_focused(), "w_g2", + "W.3 get_focused() returns last-opened when no top-tier window") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #7: get_focused() returns nil when nothing open + -- ------------------------------------------------------------------ + T.assert(panel.get_focused() == nil, + "W.3 get_focused() returns nil when no windows open") + + -- ------------------------------------------------------------------ + -- W.3 #8: click on window auto-focuses + -- ------------------------------------------------------------------ + panel.register("w_acA", make_widget({ title = "A" }), { layout = "left-half" }) + panel.register("w_acB", make_widget({ title = "B" }), { layout = "right-half" }) + panel.open("w_acA") + panel.open("w_acB") + T.equals(panel._test_get_focused_id(), "w_acB", + "W.3 B initial-focused") + -- Click in A's left-half → auto-focus A + local sw_ac, sh_ac = panel._test_get_screen_size() + panel._dispatch_event_for_test({ + kind = "click", button = "left", + x = sw_ac * 0.25, y = sh_ac * 0.5, + }) + T.equals(panel._test_get_focused_id(), "w_acA", + "W.3 click on A auto-focuses A") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #9: focus(hud-tier) does NOT promote hud above normal-tier + -- ------------------------------------------------------------------ + -- focus() is the modder-facing API for "bring to top within tier". + -- The contract is: hud-tier widgets are NEVER promoted via focus() + -- (they are cosmetic overlays). Verify by opening normal, then hud + -- (hud is naturally last-opened by virtue of open ordering), then + -- explicitly call focus on each and verify the open_order tail. + panel.register("w_hf_norm", make_widget({ title = "N" }), + { z_tier = "normal", layout = "right-half" }) + panel.register("w_hf_hud", make_widget({ title = "H" }), + { z_tier = "hud", input_block = "self", layout = "left-half" }) + panel.open("w_hf_hud") -- opens first + panel.open("w_hf_norm") -- opens last → last in open_order + T.equals(panel._test_get_focused_id(), "w_hf_norm", + "W.3 normal opened last → last in open_order") + -- focus(hud-tier) is a no-op: order is preserved. + panel.focus("w_hf_hud") + T.equals(panel._test_get_focused_id(), "w_hf_norm", + "W.3 focus(hud-tier widget) is a no-op (order unchanged)") + -- Click on hud-tier should also be a no-op for focus (auto-focus + -- internally calls M.focus, which is no-op for hud). + local sw_hf, sh_hf = panel._test_get_screen_size() + panel._dispatch_event_for_test({ + kind = "click", button = "left", + x = sw_hf * 0.25, y = sh_hf * 0.5, + }) + T.equals(panel._test_get_focused_id(), "w_hf_norm", + "W.3 click on hud-tier does NOT change focus (auto-focus no-op for hud)") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #10: drag updates bounds_override + -- ------------------------------------------------------------------ + panel.register("w_d", make_widget({ title = "D" }), { + draggable = true, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_d") + -- Start drag at title-bar click point (110, 105) — 10px inside top-left + panel._test_simulate_drag_start("w_d", 110, 105) + T.equals(panel._test_get_dragging_id(), "w_d", + "W.3 drag-start sets dragging_id") + -- Move mouse +50 +30 → window moves from (100,100) to (150,130) + panel._test_simulate_drag_move(160, 135) + local bd = panel._test_get_window_bounds("w_d") + T.equals(bd.x, 150, "W.3 drag updates x (+50)") + T.equals(bd.y, 130, "W.3 drag updates y (+30)") + T.equals(bd.w, 200, "W.3 drag preserves w") + T.equals(bd.h, 150, "W.3 drag preserves h") + panel._test_simulate_drag_end() + T.assert(panel._test_get_dragging_id() == nil, + "W.3 drag-end clears dragging_id") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #11: drag screen-clamp keeps title-bar visible (32px minimum) + -- ------------------------------------------------------------------ + panel.register("w_dc", make_widget({ title = "DC" }), { + draggable = true, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_dc") + panel._test_simulate_drag_start("w_dc", 110, 110) + -- Try to drag way off-screen left + up + panel._test_simulate_drag_move(-5000, -5000) + local bdc = panel._test_get_window_bounds("w_dc") + local sw_dc, sh_dc = panel._test_get_screen_size() + -- x clamp: at most (-w + 32) = -200 + 32 = -168 + T.assert(bdc.x >= -200 + 32 - 0.01, + "W.3 drag x-clamp keeps 32px visible on right edge when pushed left") + -- y clamp: at most 0 (title-bar top must stay on-screen) + T.assert(bdc.y >= 0 - 0.01, + "W.3 drag y-clamp prevents title-bar from leaving top of screen") + -- Try drag way off-screen right + down + panel._test_simulate_drag_move(99999, 99999) + bdc = panel._test_get_window_bounds("w_dc") + -- x clamp: at most (sw - 32) + T.assert(bdc.x <= sw_dc - 32 + 0.01, + "W.3 drag x-clamp keeps 32px visible on left edge when pushed right") + T.assert(bdc.y <= sh_dc - 32 + 0.01, + "W.3 drag y-clamp keeps title-bar reachable when pushed down") + panel._test_simulate_drag_end() + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #12: resize updates bounds (SE-corner) + -- ------------------------------------------------------------------ + panel.register("w_r", make_widget({ title = "R" }), { + resizable = true, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_r") + -- Start resize at SE-corner (~ 300, 250); use the actual coords + panel._test_simulate_resize_start("w_r", 300, 250) + T.equals(panel._test_get_resizing_id(), "w_r", + "W.3 resize-start sets resizing_id") + -- Drag SE-corner +50 +40 → w becomes 250, h becomes 190 + panel._test_simulate_resize_move(350, 290) + local br = panel._test_get_window_bounds("w_r") + T.equals(br.w, 250, "W.3 resize updates w (+50)") + T.equals(br.h, 190, "W.3 resize updates h (+40)") + T.equals(br.x, 100, "W.3 resize preserves x") + T.equals(br.y, 100, "W.3 resize preserves y") + panel._test_simulate_resize_end() + T.assert(panel._test_get_resizing_id() == nil, + "W.3 resize-end clears resizing_id") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #13: resize respects min_size constraint + -- ------------------------------------------------------------------ + panel.register("w_rmin", make_widget({ title = "Rmin" }), { + resizable = true, + min_size = { w = 180, h = 120 }, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_rmin") + panel._test_simulate_resize_start("w_rmin", 300, 250) + -- Try to shrink way past min — should clamp to min_size + panel._test_simulate_resize_move(0, 0) + local brmin = panel._test_get_window_bounds("w_rmin") + T.equals(brmin.w, 180, "W.3 resize clamps to min_size.w") + T.equals(brmin.h, 120, "W.3 resize clamps to min_size.h") + panel._test_simulate_resize_end() + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #14: resize respects max_size constraint + -- ------------------------------------------------------------------ + panel.register("w_rmax", make_widget({ title = "Rmax" }), { + resizable = true, + max_size = { w = 250, h = 180 }, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_rmax") + panel._test_simulate_resize_start("w_rmax", 300, 250) + -- Try to grow way past max — should clamp + panel._test_simulate_resize_move(99999, 99999) + local brmax = panel._test_get_window_bounds("w_rmax") + T.equals(brmax.w, 250, "W.3 resize clamps to max_size.w") + T.equals(brmax.h, 180, "W.3 resize clamps to max_size.h") + panel._test_simulate_resize_end() + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #15: drag-start via _dispatch_event on title-bar click + -- ------------------------------------------------------------------ + -- This exercises the real dispatch-time drag-start detection rather + -- than the simulate-backdoor. + local handler_fired_d = false + panel.register("w_dd", make_widget({ + title = "DD", + handle_input = function(_ctx, _ev) handler_fired_d = true end, + }), { + draggable = true, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_dd") + -- Click in the title-bar area (top of window) → drag-start, handler NOT invoked + panel._dispatch_event_for_test({ + kind = "click", button = "left", + x = 150, y = 108, -- well inside title-bar (font_size_title=18 + padding=8*2 = 34 px) + }) + T.equals(panel._test_get_dragging_id(), "w_dd", + "W.3 dispatch-time drag-start sets dragging_id") + T.assert(not handler_fired_d, + "W.3 dispatch-time drag-start consumes the click (handler NOT invoked)") + panel._test_simulate_drag_end() + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #16: resize-start via _dispatch_event on SE-corner click + -- ------------------------------------------------------------------ + local handler_fired_r = false + panel.register("w_rr", make_widget({ + title = "RR", + handle_input = function(_ctx, _ev) handler_fired_r = true end, + }), { + resizable = true, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_rr") + -- Click in SE-corner (12x12 zone at right-bottom) + panel._dispatch_event_for_test({ + kind = "click", button = "left", + x = 295, y = 245, -- inside 12px SE handle (bottom-right = 300, 250) + }) + T.equals(panel._test_get_resizing_id(), "w_rr", + "W.3 dispatch-time resize-start sets resizing_id") + T.assert(not handler_fired_r, + "W.3 dispatch-time resize-start consumes the click (handler NOT invoked)") + panel._test_simulate_resize_end() + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #17: chromeless widget does NOT drag-start on top-area click + -- ------------------------------------------------------------------ + -- chromeless has no defined title-bar. Drag must NOT engage. + local hf_chrome_d = false + panel.register("w_chrome_d", make_widget({ + title = "CD", + handle_input = function(_ctx, _ev) hf_chrome_d = true end, + }), { + chromeless = true, + draggable = true, -- modder set both; chromeless takes precedence + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_chrome_d") + -- Click in what WOULD be the title-bar area on a chromed widget + panel._dispatch_event_for_test({ + kind = "click", button = "left", x = 150, y = 108, + }) + T.assert(panel._test_get_dragging_id() == nil, + "W.3 chromeless widget does NOT drag-start (no title-bar)") + T.assert(hf_chrome_d, + "W.3 chromeless widget's handler receives the click (no drag intercept)") + panel._test_reset_all() + + -- ------------------------------------------------------------------ + -- W.3 #18: bounds_override survives the layout-fn (drag persists) + -- ------------------------------------------------------------------ + -- Once bounds_override is set, the layout-fn no longer drives bounds. + panel.register("w_bo", make_widget({ title = "BO" }), { + draggable = true, + layout = function(_sw, _sh) return {x=100, y=100, w=200, h=150} end, + }) + panel.open("w_bo") + panel._test_simulate_drag_start("w_bo", 110, 110) + panel._test_simulate_drag_move(310, 310) + panel._test_simulate_drag_end() + local bbo = panel._test_get_window_bounds("w_bo") + T.equals(bbo.x, 300, "W.3 bounds_override survives after drag-end (x)") + T.equals(bbo.y, 300, "W.3 bounds_override survives after drag-end (y)") + -- Verify point_in_any_panel uses override too + T.assert(panel.point_in_any_panel(350, 350), + "W.3 point_in_any_panel respects bounds_override") + T.assert(not panel.point_in_any_panel(150, 150), + "W.3 point_in_any_panel does NOT report old layout-fn position") + panel._test_reset_all() end return M diff --git a/manifest.lib b/manifest.lib index 67ec5be..ceeaea3 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1,9 +1,9 @@ { "id": "lib-core.panel-test", "role": "test", - "version": "0.3.0", + "version": "0.4.0", "api_min": "0.1", "deps": [ - {"id": "lib-core.panel", "version": "0.3.0"} + {"id": "lib-core.panel", "version": "0.4.0"} ] -} +} \ No newline at end of file