package webui import ( "testing" "kjol/wasmruntime" ) // A 1024x768 viewport with a 100x40 trigger in the middle, and a 200x100 panel. var ( vp = wasmruntime.Size{Width: 1024, Height: 768} trigger = wasmruntime.Rect{X: 400, Y: 300, Width: 100, Height: 40} panel = wasmruntime.Rect{X: 0, Y: 0, Width: 200, Height: 100} ) func opts(placement string) PositionOptions { o := DefaultPositionOptions() o.Placement = placement return o } func TestPlacements(t *testing.T) { // trigger: left=400 right=500 centerX=450, top=300 bottom=340 centerY=320 // panel: 200x100. offset 4. cases := []struct { placement string top, left float64 }{ {PlacementBottomStart, 344, 400}, // below, left edges aligned {PlacementBottom, 344, 350}, // below, centred: 450 - 100 {PlacementBottomEnd, 344, 300}, // below, right edges aligned: 500 - 200 {PlacementTopStart, 196, 400}, // above: 300 - 100 - 4 {PlacementTop, 196, 350}, {PlacementTopEnd, 196, 300}, {PlacementRightStart, 300, 504}, // right of: 500 + 4 {PlacementRight, 270, 504}, // right, centred: 320 - 50 {PlacementRightEnd, 240, 504}, // right, bottom-aligned: 340 - 100 {PlacementLeftStart, 300, 196}, // left of: 400 - 200 - 4 {PlacementLeft, 270, 196}, {PlacementLeftEnd, 240, 196}, } for _, c := range cases { got := ComputePosition(trigger, panel, vp, opts(c.placement)) if got.Top != c.top || got.Left != c.left { t.Errorf("%s: got top=%v left=%v, want top=%v left=%v", c.placement, got.Top, got.Left, c.top, c.left) } if got.Placement != c.placement { t.Errorf("%s: resolved placement changed to %s with room to spare", c.placement, got.Placement) } } } func TestFlipWhenItDoesNotFit(t *testing.T) { // Trigger near the bottom: a bottom-placed panel would run off, so flip up. low := wasmruntime.Rect{X: 400, Y: 700, Width: 100, Height: 40} got := ComputePosition(low, panel, vp, opts(PlacementBottomStart)) if got.Placement != PlacementTopStart { t.Fatalf("placement = %s, want top-start (should have flipped)", got.Placement) } if got.Top != 596 { // 700 - 100 - 4 t.Errorf("top = %v, want 596", got.Top) } // The alignment suffix must survive the flip. if p := ComputePosition(low, panel, vp, opts(PlacementBottomEnd)).Placement; p != PlacementTopEnd { t.Errorf("bottom-end flipped to %s, want top-end", p) } // Trigger near the right edge: right-placed flips to left. right := wasmruntime.Rect{X: 950, Y: 300, Width: 60, Height: 40} if p := ComputePosition(right, panel, vp, opts(PlacementRightStart)).Placement; p != PlacementLeftStart { t.Errorf("placement = %s, want left-start", p) } } // If neither side fits, keep the requested one rather than flipping into an // equally bad spot. func TestNoFlipWhenNeitherSideFits(t *testing.T) { tall := wasmruntime.Rect{X: 0, Y: 0, Width: 200, Height: 700} mid := wasmruntime.Rect{X: 400, Y: 350, Width: 100, Height: 40} got := ComputePosition(mid, tall, vp, opts(PlacementBottomStart)) if got.Placement != PlacementBottomStart { t.Errorf("placement = %s, want bottom-start (no better side available)", got.Placement) } } // The bug we are deliberately fixing: the TSX clamped BOTH axes, so a panel that // did not fit below got pushed up over the trigger it belonged to. Shift must // leave the main axis alone. func TestShiftDoesNotCoverTheTrigger(t *testing.T) { tall := wasmruntime.Rect{X: 0, Y: 0, Width: 200, Height: 700} low := wasmruntime.Rect{X: 400, Y: 700, Width: 100, Height: 40} got := ComputePosition(low, tall, vp, opts(PlacementBottomStart)) if got.Top < low.Bottom() { t.Errorf("panel top %v is above the trigger's bottom %v — it is covering its own trigger", got.Top, low.Bottom()) } } func TestShiftClampsTheCrossAxis(t *testing.T) { // Trigger hard against the right edge; a bottom-start panel would overflow. edge := wasmruntime.Rect{X: 980, Y: 300, Width: 40, Height: 40} got := ComputePosition(edge, panel, vp, opts(PlacementBottomStart)) wantLeft := vp.Width - panel.Width - 8 // 1024 - 200 - padding if got.Left != wantLeft { t.Errorf("left = %v, want %v (clamped to the viewport)", got.Left, wantLeft) } // And against the left edge. edge = wasmruntime.Rect{X: 2, Y: 300, Width: 40, Height: 40} if got := ComputePosition(edge, panel, vp, opts(PlacementBottomEnd)); got.Left != 8 { t.Errorf("left = %v, want 8 (clamped to the padding)", got.Left) } } // The second flaw we fix: after a shift, the arrow must still point at the trigger. func TestArrowTracksTheTriggerAfterShift(t *testing.T) { o := opts(PlacementBottom) o.ArrowSize = 8 // Unshifted, centred: the arrow sits at the panel's middle. got := ComputePosition(trigger, panel, vp, o) if got.ArrowOffset != 100 { // panel is 200 wide t.Errorf("centred arrow offset = %v, want 100", got.ArrowOffset) } // Hard against the right edge, the panel gets shifted left but the trigger did // not move — so the arrow must slide right to keep pointing at it. edge := wasmruntime.Rect{X: 980, Y: 300, Width: 40, Height: 40} // centre x = 1000 got = ComputePosition(edge, panel, vp, o) wantOffset := edge.CenterX() - got.Left // 1000 - 816 = 184 if got.ArrowOffset != wantOffset { t.Errorf("arrow offset = %v, want %v (pointing at the trigger centre)", got.ArrowOffset, wantOffset) } if got.ArrowOffset <= 100 { t.Error("arrow did not move toward the trigger after the panel was shifted") } } // The arrow must never leave the panel, however far the trigger is. func TestArrowIsClampedToThePanel(t *testing.T) { o := opts(PlacementBottomStart) o.ArrowSize = 8 o.ArrowPadding = 4 far := wasmruntime.Rect{X: 1000, Y: 300, Width: 20, Height: 40} got := ComputePosition(far, panel, vp, o) lo, hi := 8.0, panel.Width-8 // padding + half the arrow, at both ends if got.ArrowOffset < lo || got.ArrowOffset > hi { t.Errorf("arrow offset %v escaped the panel [%v, %v]", got.ArrowOffset, lo, hi) } } // The third fix: tell the caller how much room there is, so a long menu can scroll // instead of running off the screen. func TestAvailableSpace(t *testing.T) { got := ComputePosition(trigger, panel, vp, opts(PlacementBottomStart)) want := vp.Height - trigger.Bottom() - 4 - 8 // viewport - trigger bottom - offset - padding if got.MaxHeight != want { t.Errorf("MaxHeight = %v, want %v", got.MaxHeight, want) } if got.MaxWidth != 0 { t.Errorf("MaxWidth = %v, want 0 (unconstrained on the cross axis)", got.MaxWidth) } got = ComputePosition(trigger, panel, vp, opts(PlacementRightStart)) if got.MaxWidth != vp.Width-trigger.Right()-12 { t.Errorf("MaxWidth = %v, want %v", got.MaxWidth, vp.Width-trigger.Right()-12) } if got.MaxHeight != 0 { t.Errorf("MaxHeight = %v, want 0", got.MaxHeight) } } func TestOppositeSide(t *testing.T) { for placement, want := range map[string]string{ PlacementTopStart: "bottom", PlacementBottom: "top", PlacementLeftEnd: "right", PlacementRight: "left", } { if got := OppositeSide(placement); got != want { t.Errorf("OppositeSide(%s) = %s, want %s", placement, got, want) } } } func TestUnknownPlacementFallsBackToBottom(t *testing.T) { got := ComputePosition(trigger, panel, vp, opts("nonsense")) if got.Placement != "bottom-center" && got.Placement != "bottom" { t.Errorf("placement = %s, want a bottom variant", got.Placement) } if got.Top != 344 { t.Errorf("top = %v, want 344 (positioned below)", got.Top) } }