Skip to content

Commit ef392f9

Browse files
committed
fix(clayui): harden widget state handling
1 parent 94ef24f commit ef392f9

7 files changed

Lines changed: 232 additions & 28 deletions

File tree

src/Clay.Test/ClayUIBasicWidgetTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ public void Button_Clicked_ReturnsTrue()
4646
Assert.True(clicked);
4747
}
4848

49+
[Fact]
50+
public void Button_Clicked_UpdatesQueryState()
51+
{
52+
_fixture.Click(
53+
() => ClayUI.Button("QueryBtn"),
54+
mousePos: new Vector2(40, 10));
55+
56+
Assert.True(ClayUI.WasHovered("QueryBtn"));
57+
Assert.True(ClayUI.WasClicked("QueryBtn"));
58+
}
59+
4960
[Fact]
5061
public void Button_ClickOutside_ReturnsFalse()
5162
{
@@ -277,6 +288,12 @@ public void ProgressBar_CustomRange()
277288
_fixture.RunFrame(() => ClayUI.ProgressBar(50f, min: 0f, max: 100f));
278289
}
279290

291+
[Fact]
292+
public void ProgressBar_ZeroRange_DoesNotCrash()
293+
{
294+
_fixture.RunFrame(() => ClayUI.ProgressBar(10f, min: 10f, max: 10f));
295+
}
296+
280297
// ============ Image ============
281298

282299
[Fact]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Collections;
2+
using System.Reflection;
3+
using Clay;
4+
5+
namespace Clay.Test;
6+
7+
public class ClayUIColorPickerTests : IDisposable
8+
{
9+
private readonly ClayUIFixture _fixture;
10+
11+
public ClayUIColorPickerTests()
12+
{
13+
_fixture = new ClayUIFixture();
14+
}
15+
16+
public void Dispose() => _fixture.Dispose();
17+
18+
[Fact]
19+
public void ColorPicker_StateKey_RemainsStable_WhenWidgetOrderChanges()
20+
{
21+
_fixture.RunFrame(() =>
22+
{
23+
ClayUI.Button("BeforePicker");
24+
_ = ClayUI.ColorPicker("Picker", Color.Red);
25+
});
26+
27+
_fixture.RunFrame(() =>
28+
{
29+
_ = ClayUI.ColorPicker("Picker", Color.Red);
30+
});
31+
32+
Assert.Equal(1, GetColorPickerStateCount());
33+
}
34+
35+
private static int GetColorPickerStateCount()
36+
{
37+
var handle = ClayUI.GetContext();
38+
var uiContextProp = typeof(ClayUIContextHandle).GetProperty("UIContext", BindingFlags.Instance | BindingFlags.NonPublic)!;
39+
var uiContext = uiContextProp.GetValue(handle)!;
40+
var statesField = uiContext.GetType().GetField("ColorPickerStates", BindingFlags.Instance | BindingFlags.NonPublic)!;
41+
var states = (IDictionary)statesField.GetValue(uiContext)!;
42+
return states.Count;
43+
}
44+
}

src/Clay.Test/ClayUIFixture.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ public ReadOnlySpan<RenderCommand> RunFrame(
2222
Vector2 mousePos = default,
2323
bool mouseDown = false,
2424
Vector2 scrollDelta = default,
25-
float deltaTime = 1f / 60f)
25+
float deltaTime = 1f / 60f,
26+
bool rightMouseDown = false)
2627
{
27-
ClayUI.BeginFrame(new Dimensions(800, 600), mouseDown, mousePos, scrollDelta, deltaTime);
28+
ClayUI.BeginFrame(new Dimensions(800, 600), mouseDown, mousePos, scrollDelta, deltaTime, rightMouseDown);
2829

2930
// Root container so widgets have a parent with known size
3031
using (ClayApi.Element(new ElementDeclaration
@@ -52,13 +53,14 @@ public ReadOnlySpan<RenderCommand> RunTwoFrames(
5253
Action buildUi,
5354
Vector2 mousePos = default,
5455
bool mouseDown = false,
55-
Vector2 scrollDelta = default)
56+
Vector2 scrollDelta = default,
57+
bool rightMouseDown = false)
5658
{
5759
// Frame 1: establish bounding boxes (no interaction)
5860
RunFrame(buildUi, mousePos, mouseDown: false);
5961

6062
// Frame 2: with actual mouse state for interaction
61-
return RunFrame(buildUi, mousePos, mouseDown, scrollDelta);
63+
return RunFrame(buildUi, mousePos, mouseDown, scrollDelta, rightMouseDown: rightMouseDown);
6264
}
6365

6466
/// <summary>
@@ -78,6 +80,16 @@ public ReadOnlySpan<RenderCommand> Click(Action buildUi, Vector2 mousePos)
7880
return RunFrame(buildUi, mousePos, mouseDown: false);
7981
}
8082

83+
/// <summary>
84+
/// Simulates a secondary-button click: establish bounds, press right button, release.
85+
/// </summary>
86+
public ReadOnlySpan<RenderCommand> RightClick(Action buildUi, Vector2 mousePos)
87+
{
88+
RunFrame(buildUi, mousePos, mouseDown: false, rightMouseDown: false);
89+
RunFrame(buildUi, mousePos, mouseDown: false, rightMouseDown: true);
90+
return RunFrame(buildUi, mousePos, mouseDown: false, rightMouseDown: false);
91+
}
92+
8193
public void Dispose()
8294
{
8395
ClayUI.ClearState();

src/Clay.Test/ClayUIPopupTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,56 @@ public void BeginContextMenu_WhenNotTriggered_ReturnsFalse()
234234
Assert.False(opened);
235235
}
236236

237+
[Fact]
238+
public void BeginContextMenu_LeftClick_DoesNotOpen()
239+
{
240+
var triggerId = ClayApi.Id("trigger_left");
241+
bool opened = true;
242+
243+
_fixture.Click(() =>
244+
{
245+
using (ClayApi.Element(new ElementDeclaration
246+
{
247+
Id = triggerId,
248+
Layout = new LayoutConfig { Sizing = Sizing.FixedSize(100, 100) },
249+
BackgroundColor = Color.Gray
250+
})) { }
251+
252+
opened = ClayUI.BeginContextMenu("ctx_left", triggerId);
253+
if (opened)
254+
ClayUI.MenuItem("Should stay closed");
255+
ClayUI.EndContextMenu();
256+
}, mousePos: new Vector2(20, 20));
257+
258+
Assert.False(opened);
259+
Assert.False(ClayUI.IsPopupOpen("ctx_left"));
260+
}
261+
262+
[Fact]
263+
public void BeginContextMenu_RightClick_OpensAndClosesCleanly()
264+
{
265+
var triggerId = ClayApi.Id("trigger_right");
266+
bool opened = false;
267+
268+
_fixture.RightClick(() =>
269+
{
270+
using (ClayApi.Element(new ElementDeclaration
271+
{
272+
Id = triggerId,
273+
Layout = new LayoutConfig { Sizing = Sizing.FixedSize(100, 100) },
274+
BackgroundColor = Color.Gray
275+
})) { }
276+
277+
opened = ClayUI.BeginContextMenu("ctx_right", triggerId);
278+
if (opened)
279+
ClayUI.MenuItem("Action");
280+
ClayUI.EndContextMenu();
281+
}, mousePos: new Vector2(20, 20));
282+
283+
Assert.True(opened);
284+
Assert.True(ClayUI.IsPopupOpen("ctx_right"));
285+
}
286+
237287
// ============ OpenPopupBelow ============
238288

239289
[Fact]

src/Clay.Test/ClayUISliderTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ public void Slider_CustomRange_Renders()
5050
_fixture.RunFrame(() => ClayUI.Slider("S3", ref val, min: 0f, max: 100f));
5151
}
5252

53+
[Fact]
54+
public void Slider_ZeroRange_DoesNotCrash()
55+
{
56+
float val = 10f;
57+
_fixture.RunFrame(() => ClayUI.Slider("SZero", ref val, min: 10f, max: 10f));
58+
}
59+
5360
[Fact]
5461
public void Slider_EmptyLabel_Renders()
5562
{

src/Clay.Test/ClayUITooltipTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,40 @@ public void BeginTooltip_ReturnsTrue_AfterDelay()
179179
Assert.True(opened, "BeginTooltip should return true after hover delay");
180180
}
181181

182+
[Fact]
183+
public void Tooltip_TextInputTarget_Renders_AfterDelay()
184+
{
185+
string text = "hello";
186+
187+
_fixture.RunFrame(() =>
188+
{
189+
ClayUI.TextInput("InputTip", ref text);
190+
ClayUI.Tooltip("Input tip");
191+
}, mousePos: new Vector2(20, 10));
192+
193+
ReadOnlySpan<RenderCommand> commands = default;
194+
for (int i = 0; i < 5; i++)
195+
{
196+
commands = _fixture.RunFrame(() =>
197+
{
198+
ClayUI.TextInput("InputTip", ref text);
199+
ClayUI.Tooltip("Input tip");
200+
}, mousePos: new Vector2(20, 10), deltaTime: 0.2f);
201+
}
202+
203+
bool hasTooltipText = false;
204+
foreach (var cmd in commands)
205+
{
206+
if (cmd.CommandType == RenderCommandType.Text && cmd.Text.Text == "Input tip")
207+
{
208+
hasTooltipText = true;
209+
break;
210+
}
211+
}
212+
213+
Assert.True(hasTooltipText, "Tooltip should render for stable-ID widgets like TextInput");
214+
}
215+
182216
[Fact]
183217
public void Tooltip_OnlyOnePerFrame()
184218
{

0 commit comments

Comments
 (0)