@quenty/binder 14.39.0 → 14.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,338 @@
1
+ --!strict
2
+ --[[
3
+ @class BinderUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Binder = require("Binder")
9
+ local BinderProvider = require("BinderProvider")
10
+ local BinderUtils = require("BinderUtils")
11
+ local Jest = require("Jest")
12
+ local ServiceBag = require("ServiceBag")
13
+
14
+ local describe = Jest.Globals.describe
15
+ local expect = Jest.Globals.expect
16
+ local it = Jest.Globals.it
17
+
18
+ local specCounter = 0
19
+
20
+ local function makeClass()
21
+ local Class = {}
22
+ Class.__index = Class
23
+ Class.ClassName = "BinderUtilsSpecClass"
24
+ function Class.new(inst)
25
+ return setmetatable({ instance = inst }, Class)
26
+ end
27
+ function Class:Destroy() end
28
+ return Class
29
+ end
30
+
31
+ local function setup()
32
+ specCounter += 1
33
+ local suffix = specCounter
34
+
35
+ local serviceBag = ServiceBag.new()
36
+ local container = Instance.new("Folder")
37
+ container.Name = "BinderUtilsSpecContainer"
38
+ container.Parent = workspace
39
+
40
+ local instances = {}
41
+ local pendingBinders: { any } = {}
42
+ local tagCounter = 0
43
+ local booted = false
44
+
45
+ local function newBinder(): Binder.Binder<any>
46
+ assert(not booted, "Cannot add a binder after boot()")
47
+ tagCounter += 1
48
+ local binder = Binder.new(string.format("BinderUtilsSpecTag_%d_%d", suffix, tagCounter), makeClass() :: any)
49
+ table.insert(pendingBinders, binder)
50
+ return binder
51
+ end
52
+
53
+ local function newInstance(parent: Instance?, className: string?): Instance
54
+ local inst = Instance.new(className or "Folder")
55
+ inst.Parent = parent or container
56
+ table.insert(instances, inst)
57
+ return inst
58
+ end
59
+
60
+ local function boot()
61
+ assert(not booted, "Already booted")
62
+ booted = true
63
+
64
+ local binders = pendingBinders
65
+ local provider = BinderProvider.new(string.format("BinderUtilsSpecProvider_%d", suffix), function(self)
66
+ for _, binder in binders do
67
+ self:Add(binder)
68
+ end
69
+ end)
70
+ serviceBag:GetService(provider)
71
+ serviceBag:Init()
72
+ serviceBag:Start()
73
+ end
74
+
75
+ return {
76
+ container = container,
77
+ newBinder = newBinder,
78
+ newInstance = newInstance,
79
+ boot = boot,
80
+ destroy = function()
81
+ serviceBag:Destroy()
82
+ for _, inst in instances do
83
+ pcall(function()
84
+ inst:Destroy()
85
+ end)
86
+ end
87
+ container:Destroy()
88
+ end,
89
+ }
90
+ end
91
+
92
+ describe("BinderUtils.findFirstAncestor()", function()
93
+ it("returns the nearest bound ancestor, skipping the child itself", function()
94
+ local controller = setup()
95
+
96
+ local grandparent = controller.newInstance()
97
+ local parent = controller.newInstance(grandparent)
98
+ local child = controller.newInstance(parent)
99
+
100
+ local binder = controller.newBinder()
101
+ binder:Tag(grandparent)
102
+ binder:Tag(child) -- child is bound too, but must be skipped
103
+ controller.boot()
104
+
105
+ expect(BinderUtils.findFirstAncestor(binder, child)).toEqual(binder:Get(grandparent))
106
+
107
+ controller.destroy()
108
+ end)
109
+
110
+ it("returns nil when no ancestor is bound", function()
111
+ local controller = setup()
112
+
113
+ local parent = controller.newInstance()
114
+ local child = controller.newInstance(parent)
115
+ local binder = controller.newBinder()
116
+ controller.boot()
117
+
118
+ expect(BinderUtils.findFirstAncestor(binder, child)).toBeNil()
119
+
120
+ controller.destroy()
121
+ end)
122
+
123
+ it("throws for a non-instance child", function()
124
+ local controller = setup()
125
+
126
+ local binder = controller.newBinder()
127
+ controller.boot()
128
+ expect(function()
129
+ BinderUtils.findFirstAncestor(binder, 5 :: any)
130
+ end).toThrow()
131
+
132
+ controller.destroy()
133
+ end)
134
+ end)
135
+
136
+ describe("BinderUtils.findFirstChild()", function()
137
+ it("returns the first bound child", function()
138
+ local controller = setup()
139
+
140
+ local parent = controller.newInstance()
141
+ local unboundChild = controller.newInstance(parent)
142
+ local boundChild = controller.newInstance(parent)
143
+
144
+ local binder = controller.newBinder()
145
+ binder:Tag(boundChild)
146
+ controller.boot()
147
+
148
+ expect(BinderUtils.findFirstChild(binder, parent)).toEqual(binder:Get(boundChild))
149
+ expect(binder:Get(unboundChild)).toBeNil()
150
+
151
+ controller.destroy()
152
+ end)
153
+
154
+ it("returns nil when no child is bound", function()
155
+ local controller = setup()
156
+
157
+ local parent = controller.newInstance()
158
+ controller.newInstance(parent)
159
+ local binder = controller.newBinder()
160
+ controller.boot()
161
+
162
+ expect(BinderUtils.findFirstChild(binder, parent)).toBeNil()
163
+
164
+ controller.destroy()
165
+ end)
166
+ end)
167
+
168
+ describe("BinderUtils.getChildren()", function()
169
+ it("returns every bound child", function()
170
+ local controller = setup()
171
+
172
+ local parent = controller.newInstance()
173
+ local childA = controller.newInstance(parent)
174
+ local childB = controller.newInstance(parent)
175
+ controller.newInstance(parent) -- unbound
176
+
177
+ local binder = controller.newBinder()
178
+ binder:Tag(childA)
179
+ binder:Tag(childB)
180
+ controller.boot()
181
+
182
+ expect(#BinderUtils.getChildren(binder, parent)).toEqual(2)
183
+
184
+ controller.destroy()
185
+ end)
186
+
187
+ it("does not include bound descendants deeper than one level", function()
188
+ local controller = setup()
189
+
190
+ local parent = controller.newInstance()
191
+ local child = controller.newInstance(parent)
192
+ local grandchild = controller.newInstance(child)
193
+
194
+ local binder = controller.newBinder()
195
+ binder:Tag(grandchild)
196
+ controller.boot()
197
+
198
+ expect(#BinderUtils.getChildren(binder, parent)).toEqual(0)
199
+
200
+ controller.destroy()
201
+ end)
202
+ end)
203
+
204
+ describe("BinderUtils.getDescendants()", function()
205
+ it("returns bound instances at any depth", function()
206
+ local controller = setup()
207
+
208
+ local parent = controller.newInstance()
209
+ local child = controller.newInstance(parent)
210
+ local grandchild = controller.newInstance(child)
211
+
212
+ local binder = controller.newBinder()
213
+ binder:Tag(child)
214
+ binder:Tag(grandchild)
215
+ controller.boot()
216
+
217
+ expect(#BinderUtils.getDescendants(binder, parent)).toEqual(2)
218
+
219
+ controller.destroy()
220
+ end)
221
+ end)
222
+
223
+ describe("BinderUtils.mapBinderListToTable()", function()
224
+ it("keys binders by their tag", function()
225
+ local controller = setup()
226
+
227
+ local binderA = controller.newBinder()
228
+ local binderB = controller.newBinder()
229
+ controller.boot()
230
+
231
+ local map = BinderUtils.mapBinderListToTable({ binderA, binderB })
232
+ expect(map[binderA:GetTag()]).toEqual(binderA)
233
+ expect(map[binderB:GetTag()]).toEqual(binderB)
234
+
235
+ controller.destroy()
236
+ end)
237
+ end)
238
+
239
+ describe("BinderUtils.getMappedFromList()", function()
240
+ it("resolves bound values across an instance list by tag", function()
241
+ local controller = setup()
242
+
243
+ local instA = controller.newInstance()
244
+ local instB = controller.newInstance()
245
+ local binderA = controller.newBinder()
246
+ local binderB = controller.newBinder()
247
+ binderA:Tag(instA)
248
+ binderB:Tag(instB)
249
+ controller.boot()
250
+
251
+ local tagsMap = BinderUtils.mapBinderListToTable({ binderA, binderB })
252
+ local objects = BinderUtils.getMappedFromList(tagsMap, { instA, instB, controller.newInstance() })
253
+
254
+ expect(#objects).toEqual(2)
255
+
256
+ controller.destroy()
257
+ end)
258
+ end)
259
+
260
+ describe("BinderUtils.getChildrenOfBinders()", function()
261
+ it("returns children bound by any binder in the list", function()
262
+ local controller = setup()
263
+
264
+ local parent = controller.newInstance()
265
+ local childA = controller.newInstance(parent)
266
+ local childB = controller.newInstance(parent)
267
+
268
+ local binderA = controller.newBinder()
269
+ local binderB = controller.newBinder()
270
+ binderA:Tag(childA)
271
+ binderB:Tag(childB)
272
+ controller.boot()
273
+
274
+ expect(#BinderUtils.getChildrenOfBinders({ binderA, binderB }, parent)).toEqual(2)
275
+
276
+ controller.destroy()
277
+ end)
278
+ end)
279
+
280
+ describe("BinderUtils.getLinkedChildren()", function()
281
+ it("resolves bound targets of matching ObjectValue links", function()
282
+ local controller = setup()
283
+
284
+ local parent = controller.newInstance()
285
+ local target = controller.newInstance()
286
+ local binder = controller.newBinder()
287
+ binder:Tag(target)
288
+
289
+ local link = controller.newInstance(parent, "ObjectValue") :: ObjectValue
290
+ link.Name = "Link"
291
+ link.Value = target
292
+ controller.boot()
293
+
294
+ local objects = BinderUtils.getLinkedChildren(binder, "Link", parent)
295
+ expect(#objects).toEqual(1)
296
+ expect(objects[1]).toEqual(binder:Get(target))
297
+
298
+ controller.destroy()
299
+ end)
300
+
301
+ it("ignores links whose name does not match", function()
302
+ local controller = setup()
303
+
304
+ local parent = controller.newInstance()
305
+ local target = controller.newInstance()
306
+ local binder = controller.newBinder()
307
+ binder:Tag(target)
308
+
309
+ local link = controller.newInstance(parent, "ObjectValue") :: ObjectValue
310
+ link.Name = "OtherLink"
311
+ link.Value = target
312
+ controller.boot()
313
+
314
+ expect(#BinderUtils.getLinkedChildren(binder, "Link", parent)).toEqual(0)
315
+
316
+ controller.destroy()
317
+ end)
318
+
319
+ it("deduplicates when two links point to the same bound target", function()
320
+ local controller = setup()
321
+
322
+ local parent = controller.newInstance()
323
+ local target = controller.newInstance()
324
+ local binder = controller.newBinder()
325
+ binder:Tag(target)
326
+
327
+ for _ = 1, 2 do
328
+ local link = controller.newInstance(parent, "ObjectValue") :: ObjectValue
329
+ link.Name = "Link"
330
+ link.Value = target
331
+ end
332
+ controller.boot()
333
+
334
+ expect(#BinderUtils.getLinkedChildren(binder, "Link", parent)).toEqual(1)
335
+
336
+ controller.destroy()
337
+ end)
338
+ end)
@@ -0,0 +1,203 @@
1
+ --!strict
2
+ --[[
3
+ @class BoundChildCollection.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Binder = require("Binder")
9
+ local BinderProvider = require("BinderProvider")
10
+ local BoundChildCollection = require("BoundChildCollection")
11
+ local Jest = require("Jest")
12
+ local ServiceBag = require("ServiceBag")
13
+
14
+ local describe = Jest.Globals.describe
15
+ local expect = Jest.Globals.expect
16
+ local it = Jest.Globals.it
17
+
18
+ local specCounter = 0
19
+
20
+ local function makeClass()
21
+ local Class = {}
22
+ Class.__index = Class
23
+ Class.ClassName = "BoundChildCollectionSpecClass"
24
+ function Class.new(inst)
25
+ return setmetatable({ instance = inst }, Class)
26
+ end
27
+ function Class:Destroy() end
28
+ return Class
29
+ end
30
+
31
+ local function setup()
32
+ specCounter += 1
33
+ local suffix = specCounter
34
+
35
+ local serviceBag = ServiceBag.new()
36
+ local container = Instance.new("Folder")
37
+ container.Name = "BoundChildCollectionSpecContainer"
38
+ container.Parent = workspace
39
+
40
+ local instances = {}
41
+ local cleanups = {}
42
+ local booted = false
43
+
44
+ local binder = Binder.new(string.format("BoundChildCollectionSpecTag_%d", suffix), makeClass() :: any)
45
+
46
+ local function newInstance(parent: Instance?): Instance
47
+ local inst = Instance.new("Folder")
48
+ inst.Parent = parent or container
49
+ table.insert(instances, inst)
50
+ return inst
51
+ end
52
+
53
+ local function track(item: any): any
54
+ table.insert(cleanups, item)
55
+ return item
56
+ end
57
+
58
+ local function boot()
59
+ assert(not booted, "Already booted")
60
+ booted = true
61
+
62
+ local provider = BinderProvider.new(string.format("BoundChildCollectionSpecProvider_%d", suffix), function(self)
63
+ self:Add(binder)
64
+ end)
65
+ serviceBag:GetService(provider)
66
+ serviceBag:Init()
67
+ serviceBag:Start()
68
+ end
69
+
70
+ return {
71
+ container = container,
72
+ binder = binder,
73
+ newInstance = newInstance,
74
+ track = track,
75
+ boot = boot,
76
+ destroy = function()
77
+ for _, item in cleanups do
78
+ pcall(function()
79
+ item:Destroy()
80
+ end)
81
+ end
82
+ serviceBag:Destroy()
83
+ for _, inst in instances do
84
+ pcall(function()
85
+ inst:Destroy()
86
+ end)
87
+ end
88
+ container:Destroy()
89
+ end,
90
+ }
91
+ end
92
+
93
+ describe("BoundChildCollection construction", function()
94
+ it("counts children bound before construction without firing ClassAdded", function()
95
+ local controller = setup()
96
+
97
+ local parent = controller.newInstance()
98
+ local childA = controller.newInstance(parent)
99
+ local childB = controller.newInstance(parent)
100
+ controller.newInstance(parent) -- unbound child
101
+
102
+ controller.binder:Tag(childA)
103
+ controller.binder:Tag(childB)
104
+ controller.boot()
105
+
106
+ local fired = 0
107
+ local collection = controller.track(BoundChildCollection.new(controller.binder, parent))
108
+ collection.ClassAdded:Connect(function()
109
+ fired += 1
110
+ end)
111
+
112
+ expect(collection:GetSize()).toEqual(2)
113
+ expect(fired).toEqual(0)
114
+ expect(#collection:GetClasses()).toEqual(2)
115
+ expect(collection:HasClass(controller.binder:Get(childA))).toEqual(true)
116
+
117
+ controller.destroy()
118
+ end)
119
+ end)
120
+
121
+ describe("BoundChildCollection dynamic updates", function()
122
+ it("fires ClassAdded when a bound child is reparented in", function()
123
+ local controller = setup()
124
+
125
+ local parent = controller.newInstance()
126
+ controller.boot()
127
+
128
+ local collection = controller.track(BoundChildCollection.new(controller.binder, parent))
129
+
130
+ local child = controller.newInstance()
131
+ controller.binder:Tag(child)
132
+ local ok, class = controller.binder:Promise(child):Yield()
133
+ assert(ok, "child never bound")
134
+
135
+ local addedClass
136
+ local conn = collection.ClassAdded:Connect(function(c)
137
+ addedClass = c
138
+ end)
139
+
140
+ child.Parent = parent
141
+ if collection:GetSize() == 0 then
142
+ collection.ClassAdded:Wait()
143
+ end
144
+ conn:Disconnect()
145
+
146
+ expect(addedClass).toEqual(class)
147
+ expect(collection:GetSize()).toEqual(1)
148
+
149
+ controller.destroy()
150
+ end)
151
+
152
+ it("fires ClassRemoved when a tracked child is reparented out", function()
153
+ local controller = setup()
154
+
155
+ local parent = controller.newInstance()
156
+ local child = controller.newInstance(parent)
157
+ controller.binder:Tag(child)
158
+ controller.boot()
159
+
160
+ local collection = controller.track(BoundChildCollection.new(controller.binder, parent))
161
+ expect(collection:GetSize()).toEqual(1)
162
+
163
+ local removedClass
164
+ local conn = collection.ClassRemoved:Connect(function(c)
165
+ removedClass = c
166
+ end)
167
+
168
+ local class = controller.binder:Get(child)
169
+ child.Parent = controller.container
170
+ if collection:GetSize() == 1 then
171
+ collection.ClassRemoved:Wait()
172
+ end
173
+ conn:Disconnect()
174
+
175
+ expect(removedClass).toEqual(class)
176
+ expect(collection:GetSize()).toEqual(0)
177
+
178
+ controller.destroy()
179
+ end)
180
+
181
+ it("fires ClassRemoved when a tracked child is unbound", function()
182
+ local controller = setup()
183
+
184
+ local parent = controller.newInstance()
185
+ local child = controller.newInstance(parent)
186
+ controller.binder:Tag(child)
187
+ controller.boot()
188
+
189
+ local collection = controller.track(BoundChildCollection.new(controller.binder, parent))
190
+ expect(collection:GetSize()).toEqual(1)
191
+
192
+ local conn = collection.ClassRemoved:Connect(function() end)
193
+ controller.binder:Untag(child)
194
+ if collection:GetSize() == 1 then
195
+ collection.ClassRemoved:Wait()
196
+ end
197
+ conn:Disconnect()
198
+
199
+ expect(collection:GetSize()).toEqual(0)
200
+
201
+ controller.destroy()
202
+ end)
203
+ end)
@@ -0,0 +1,129 @@
1
+ --!strict
2
+ --[[
3
+ @class promiseBoundClass.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Binder = require("Binder")
9
+ local BinderProvider = require("BinderProvider")
10
+ local Jest = require("Jest")
11
+ local ServiceBag = require("ServiceBag")
12
+ local promiseBoundClass = require("promiseBoundClass")
13
+
14
+ local describe = Jest.Globals.describe
15
+ local expect = Jest.Globals.expect
16
+ local it = Jest.Globals.it
17
+
18
+ local specCounter = 0
19
+
20
+ local function makeClass()
21
+ local Class = {}
22
+ Class.__index = Class
23
+ Class.ClassName = "PromiseBoundClassSpecClass"
24
+ function Class.new(inst)
25
+ return setmetatable({ instance = inst }, Class)
26
+ end
27
+ function Class:Destroy() end
28
+ return Class
29
+ end
30
+
31
+ local function setup()
32
+ specCounter += 1
33
+ local suffix = specCounter
34
+
35
+ local serviceBag = ServiceBag.new()
36
+ local container = Instance.new("Folder")
37
+ container.Name = "PromiseBoundClassSpecContainer"
38
+ container.Parent = workspace
39
+
40
+ local instances = {}
41
+ local booted = false
42
+
43
+ local binder = Binder.new(string.format("PromiseBoundClassSpecTag_%d", suffix), makeClass() :: any)
44
+
45
+ local function newInstance(): Instance
46
+ local inst = Instance.new("Folder")
47
+ inst.Parent = container
48
+ table.insert(instances, inst)
49
+ return inst
50
+ end
51
+
52
+ local function boot()
53
+ assert(not booted, "Already booted")
54
+ booted = true
55
+
56
+ local provider = BinderProvider.new(string.format("PromiseBoundClassSpecProvider_%d", suffix), function(self)
57
+ self:Add(binder)
58
+ end)
59
+ serviceBag:GetService(provider)
60
+ serviceBag:Init()
61
+ serviceBag:Start()
62
+ end
63
+
64
+ return {
65
+ binder = binder,
66
+ newInstance = newInstance,
67
+ boot = boot,
68
+ destroy = function()
69
+ serviceBag:Destroy()
70
+ for _, inst in instances do
71
+ pcall(function()
72
+ inst:Destroy()
73
+ end)
74
+ end
75
+ container:Destroy()
76
+ end,
77
+ }
78
+ end
79
+
80
+ describe("promiseBoundClass()", function()
81
+ it("resolves with the bound class", function()
82
+ local controller = setup()
83
+
84
+ local inst = controller.newInstance()
85
+ controller.binder:Tag(inst)
86
+ controller.boot()
87
+
88
+ local ok, class = promiseBoundClass(controller.binder, inst):Yield()
89
+ assert(ok, "Never bound")
90
+ expect(class).toEqual(controller.binder:Get(inst))
91
+
92
+ controller.destroy()
93
+ end)
94
+
95
+ it("resolves once an instance is bound after start", function()
96
+ local controller = setup()
97
+
98
+ controller.boot()
99
+ local inst = controller.newInstance()
100
+ controller.binder:Tag(inst)
101
+
102
+ local ok = promiseBoundClass(controller.binder, inst):Yield()
103
+ expect(ok).toEqual(true)
104
+
105
+ controller.destroy()
106
+ end)
107
+
108
+ it("throws when the binder is not a binder", function()
109
+ local controller = setup()
110
+ controller.boot()
111
+
112
+ expect(function()
113
+ promiseBoundClass({} :: any, controller.newInstance())
114
+ end).toThrow()
115
+
116
+ controller.destroy()
117
+ end)
118
+
119
+ it("throws when the instance is not an Instance", function()
120
+ local controller = setup()
121
+ controller.boot()
122
+
123
+ expect(function()
124
+ promiseBoundClass(controller.binder, 5 :: any)
125
+ end).toThrow()
126
+
127
+ controller.destroy()
128
+ end)
129
+ end)