|
| 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