Skip to content

Commit 0a8dd7d

Browse files
author
RoFlection Bot
committed
APT-919 - Port rhodium input validation to DTL (#102)
* pull in input validation logic * adjust dispatchEvent to include validation * Reconfigure tap to use correct events * rearrange to accommodate validation, add test
1 parent 16893a5 commit 0a8dd7d

File tree

4 files changed

+1138
-8
lines changed

4 files changed

+1138
-8
lines changed
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
--!strict
2+
local VirtualInputManager = game:GetService("VirtualInputManager")
3+
4+
local INPUT_VALIDATION_PREFIX = "[Testing Library] input validation"
5+
6+
local function getGuiObject(instance: Instance): GuiObject
7+
if instance:IsA("GuiObject") then
8+
return instance
9+
end
10+
error(("expected instance to be a GuiObject, but got `%s`"):format(instance.ClassName))
11+
end
12+
13+
local function getCenter(element: GuiObject): Vector2
14+
local position = element.AbsolutePosition
15+
local size = element.AbsoluteSize
16+
17+
return Vector2.new(position.X + size.X / 2, position.Y + size.Y / 2)
18+
end
19+
20+
--[[
21+
Prints a short description of an instance in the format of:
22+
game.Workspace.Path.To.Button (TextButton)
23+
]]
24+
local function describeInstance(instance: Instance): string
25+
return string.format("%s (%s)", instance:GetFullName(), instance.ClassName)
26+
end
27+
28+
--[[
29+
Prints the boundaries of a GuiBase2d in the format "(minX, minY) (maxX, maxY)":
30+
(100, 100) (200, 200)
31+
]]
32+
local function describeBounds(instance: GuiBase2d): string
33+
local position = instance.AbsolutePosition
34+
local size = instance.AbsoluteSize
35+
return string.format("(%s) (%s)", tostring(position), tostring(size + position))
36+
end
37+
38+
--[[
39+
Prints a short description of a GuiBase2d:
40+
game.Workspace.Path.To.Button (TextButton)
41+
element bounds: (100, 100) (200, 200)
42+
]]
43+
local function describeGuiBase2d(instance: GuiBase2d): string
44+
return string.format("%s\n\telement bounds: %s", describeInstance(instance), describeBounds(instance))
45+
end
46+
47+
local function isPointOutsideViewport(point: Vector2, container: GuiBase2d): (boolean, string?)
48+
local directions = {}
49+
if point.Y < container.AbsolutePosition.Y then
50+
table.insert(directions, "top")
51+
end
52+
if point.Y > container.AbsolutePosition.Y + container.AbsoluteSize.Y then
53+
table.insert(directions, "bottom")
54+
end
55+
if point.X < container.AbsolutePosition.X then
56+
table.insert(directions, "left")
57+
end
58+
if point.X > container.AbsolutePosition.X + container.AbsoluteSize.X then
59+
table.insert(directions, "right")
60+
end
61+
62+
if #directions > 0 then
63+
return true, table.concat(directions, "-")
64+
end
65+
66+
return false
67+
end
68+
69+
local function isPointClippedByAncestor(target: GuiBase2d, clickLocation: Vector2)
70+
local ancestor = target.Parent
71+
while ancestor ~= nil do
72+
if ancestor:IsA("GuiBase2d") then
73+
-- Non-GuiObjects (LayerCollectors) clip descendants inherently,
74+
-- while GuiObjects like "Frame" default to _not_ clipping unless
75+
-- ClipsDescendants is true
76+
local ancestorClips = if ancestor:IsA("GuiObject") then ancestor.ClipsDescendants else true
77+
local targetIsClipped, direction = isPointOutsideViewport(clickLocation, ancestor)
78+
if ancestorClips and targetIsClipped then
79+
return true, ancestor :: GuiBase2d?, direction :: string?
80+
end
81+
end
82+
ancestor = ancestor.Parent
83+
end
84+
return false
85+
end
86+
87+
local function isClickable(instance: GuiObject, allowInactive: boolean?): (boolean, { string })
88+
local notClickableReasons = {}
89+
90+
if not instance.Active then
91+
-- Check if the element is either a kind of GuiObject that always sinks
92+
-- clicks _or_ an Instance that's set to Active to sink clicks anyways
93+
if not instance:IsA("GuiButton") and not instance:IsA("TextBox") then
94+
local interactiveDescendants = {}
95+
local instancePath = instance:GetFullName()
96+
for _, descendant in instance:GetDescendants() do
97+
if descendant:IsA("GuiButton") or descendant:IsA("TextBox") then
98+
local path = string.sub(describeInstance(descendant), #instancePath + 1)
99+
table.insert(interactiveDescendants, string.format("%s%s", instance.Name, path))
100+
end
101+
end
102+
table.insert(
103+
notClickableReasons,
104+
string.format(
105+
"target is not a GuiButton or TextBox, so it will not sink inputs unless `Active` is true.\n"
106+
.. "\tThe target instance has the following descendants that may be better click targets:%s\n\n"
107+
.. "\tIf you are intentionally simulating clicks on a GUI element that is not typically interactive\n"
108+
.. "\t(like a Frame or an ImageLabel), consider using `element:clickWithoutValidation` instead.",
109+
if #interactiveDescendants > 0
110+
then "\n\t\t* " .. table.concat(interactiveDescendants, "\n\t\t* ")
111+
else " <none>"
112+
)
113+
)
114+
end
115+
if not allowInactive then
116+
table.insert(notClickableReasons, "target is not Active")
117+
end
118+
end
119+
if not instance.Visible then
120+
table.insert(notClickableReasons, "target is not Visible")
121+
end
122+
if instance.AbsoluteSize.X <= 0 then
123+
table.insert(
124+
notClickableReasons,
125+
string.format("target has 0 width; element bounds: %s", describeBounds(instance))
126+
)
127+
end
128+
if instance.AbsoluteSize.Y <= 0 then
129+
table.insert(
130+
notClickableReasons,
131+
string.format("target has 0 height; element bounds: %s", describeBounds(instance))
132+
)
133+
end
134+
135+
local layerCollector = instance:FindFirstAncestorWhichIsA("LayerCollector")
136+
if not layerCollector then
137+
table.insert(
138+
notClickableReasons,
139+
"target is not a descendant of a LayerCollector, like a ScreenGui or a SurfaceGui"
140+
)
141+
end
142+
143+
return #notClickableReasons == 0, notClickableReasons
144+
end
145+
146+
local function assertMounted(instance: Instance)
147+
if not (instance :: Instance):IsA("GuiObject") then
148+
error(
149+
string.format("%s: %s is not a GuiObject", INPUT_VALIDATION_PREFIX, describeInstance(instance :: Instance))
150+
)
151+
end
152+
if not (instance :: GuiObject):FindFirstAncestorOfClass("DataModel") then
153+
error(
154+
string.format(
155+
"%s: %s is not mounted into the DataModel",
156+
INPUT_VALIDATION_PREFIX,
157+
describeInstance(instance :: Instance)
158+
)
159+
)
160+
end
161+
end
162+
163+
local function assertCanActivate(instance: Instance, allowInactive: boolean?)
164+
local guiObject = getGuiObject(instance)
165+
166+
local clickable, messages = isClickable(guiObject, allowInactive)
167+
if not clickable then
168+
error(
169+
string.format(
170+
"%s: %s was not clickable for the following reason(s):\n* %s",
171+
INPUT_VALIDATION_PREFIX,
172+
describeInstance(guiObject),
173+
table.concat(messages, "\n* ")
174+
)
175+
)
176+
end
177+
end
178+
179+
local function assertVisibleWithinAncestors(instance: Instance)
180+
local guiObject = getGuiObject(instance)
181+
local anchor = getCenter(guiObject)
182+
183+
local isClipped, ancestor, direction = isPointClippedByAncestor(guiObject, anchor)
184+
if isClipped then
185+
error(
186+
string.format(
187+
"%s: %s is outside bounds of ancestor %s (%s)\n\nclick at: (%s)\ntarget: %s\nancestor: %s",
188+
INPUT_VALIDATION_PREFIX,
189+
instance.Name,
190+
(ancestor :: GuiBase2d).Name,
191+
direction :: string,
192+
tostring(anchor),
193+
describeGuiBase2d(guiObject),
194+
describeGuiBase2d(ancestor :: GuiBase2d)
195+
)
196+
)
197+
end
198+
end
199+
200+
local function assertFirstInputTarget(instance: Instance)
201+
local guiObject = getGuiObject(instance)
202+
local anchor = getCenter(guiObject)
203+
204+
-- Assert that the target is the _first_ target at the given location
205+
local baseGui = instance:FindFirstAncestorWhichIsA("BasePlayerGui")
206+
if not baseGui then
207+
error(
208+
string.format(
209+
"%s: %s is not a descendant of a BasePlayerGui (like CoreGui or LocalPlayer.PlayerGui)",
210+
INPUT_VALIDATION_PREFIX,
211+
describeGuiBase2d(guiObject)
212+
)
213+
)
214+
end
215+
216+
-- FIXME: UISYS-2261 Each ScreenGui has a quadtree that's used to detect
217+
-- point intersections, but it's only generated once some input is received;
218+
-- calling `GetGuiObjectsAtPosition` should also force quadtree generation
219+
-- This issue is marked as resolved behind the flag InitUIQuadTreeRework,
220+
-- but enabling it doesn't seem to resolve the issue
221+
VirtualInputManager:SendMouseMoveEvent(anchor.X, anchor.Y, nil :: any)
222+
VirtualInputManager:WaitForInputEventsProcessed()
223+
local atClickLocation: { GuiObject } = (baseGui :: BasePlayerGui):GetGuiObjectsAtPosition(anchor.X, anchor.Y) :: any
224+
225+
for _, descendant in atClickLocation do
226+
if descendant == instance then
227+
-- The first clickable instance in the list is our target; this
228+
-- is the happy path!
229+
break
230+
end
231+
232+
local descendantIsClickable = isClickable(descendant :: GuiObject, true)
233+
if not descendantIsClickable then
234+
continue
235+
end
236+
-- There's a possibility that the obscuring element is not _actually_
237+
-- obscuring it because it's being clipped; check this before we throw
238+
local descendantIsClipped = isPointClippedByAncestor(descendant :: GuiBase2d, anchor)
239+
if descendantIsClipped then
240+
continue
241+
end
242+
243+
error(
244+
string.format(
245+
"%s: element is obscured by another clickable GuiObject at the target click location\n\n click at: (%s)\n target: %s\nobscuring: %s",
246+
INPUT_VALIDATION_PREFIX,
247+
tostring(anchor),
248+
describeGuiBase2d(instance :: GuiBase2d),
249+
describeGuiBase2d(descendant :: GuiBase2d)
250+
)
251+
)
252+
end
253+
end
254+
255+
local function validateInputReceived(instance: GuiObject, simulateInput: () -> ()): boolean
256+
local connections, didReceiveInput = {}, false
257+
if instance:IsA("GuiButton") then
258+
-- The typical case is a button: we can listen to the `Activated` signal
259+
-- to match the way that most GuiButton behavior is implemented
260+
table.insert(
261+
connections,
262+
instance.Activated:Connect(function(inputObject: InputObject)
263+
if inputObject.UserInputState ~= Enum.UserInputState.Cancel then
264+
didReceiveInput = true
265+
end
266+
end)
267+
)
268+
elseif instance:IsA("TextBox") and not instance:IsFocused() then
269+
-- If the instance is an unfocused TextBox and we're clicking on it,
270+
-- we're probably trying to simulate a user clicking to focus on it
271+
table.insert(
272+
connections,
273+
instance.Focused:Connect(function()
274+
didReceiveInput = true
275+
end)
276+
)
277+
else
278+
-- For any other
279+
local clickStarted = false
280+
table.insert(
281+
connections,
282+
instance.InputBegan:Connect(function(inputObject: InputObject)
283+
if inputObject.UserInputState ~= Enum.UserInputState.Cancel then
284+
clickStarted = true
285+
end
286+
end)
287+
)
288+
table.insert(
289+
connections,
290+
instance.InputEnded:Connect(function(inputObject: InputObject)
291+
if inputObject.UserInputState ~= Enum.UserInputState.Cancel then
292+
if clickStarted then
293+
didReceiveInput = true
294+
end
295+
end
296+
end)
297+
)
298+
end
299+
300+
simulateInput()
301+
302+
for _, connection in connections do
303+
connection:Disconnect()
304+
end
305+
return didReceiveInput
306+
end
307+
308+
local function validateInput(guiObject: GuiObject, simulateInput: () -> ())
309+
-- Before we can try processing clicks, make sure we have a valid GuiObject
310+
-- that will receive them
311+
assertMounted(guiObject)
312+
313+
-- Waiting for events will also allow a re-layout to complete, which should
314+
-- resolve any impending size and position changes
315+
VirtualInputManager:WaitForInputEventsProcessed()
316+
317+
local didReceiveInput = validateInputReceived(guiObject, simulateInput)
318+
if not didReceiveInput then
319+
assertCanActivate(guiObject)
320+
assertVisibleWithinAncestors(guiObject)
321+
assertFirstInputTarget(guiObject)
322+
323+
error(
324+
string.format(
325+
"%s: failed to click %s - reason unknown\n%s",
326+
INPUT_VALIDATION_PREFIX,
327+
guiObject.Name,
328+
describeGuiBase2d(guiObject)
329+
)
330+
)
331+
end
332+
end
333+
334+
return {
335+
getGuiObject = getGuiObject,
336+
getCenter = getCenter,
337+
assertMounted = assertMounted,
338+
assertCanActivate = assertCanActivate,
339+
assertVisibleWithinAncestors = assertVisibleWithinAncestors,
340+
assertFirstInputTarget = assertFirstInputTarget,
341+
validateInput = validateInput,
342+
}

0 commit comments

Comments
 (0)