@quenty/binder 14.40.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.
@@ -1,10 +1,5 @@
1
1
  --!strict
2
2
  --[[
3
- Coverage for BinderGroup: grouping binders, optional constructor validation, and the
4
- BinderAdded signal. The binders here are never started, so no CollectionService state is
5
- touched; each is still destroyed through the setup() controller to avoid leaking into the
6
- shared test place.
7
-
8
3
  @class BinderGroup.spec.lua
9
4
  ]]
10
5
 
@@ -46,15 +41,15 @@ end
46
41
 
47
42
  describe("BinderGroup.new()", function()
48
43
  it("constructs with an initial list of binders", function()
49
- local env = setup()
44
+ local controller = setup()
50
45
 
51
- local binderA = env.newBinder()
52
- local binderB = env.newBinder()
46
+ local binderA = controller.newBinder()
47
+ local binderB = controller.newBinder()
53
48
  local group = BinderGroup.new({ binderA, binderB })
54
49
 
55
50
  expect(#group:GetBinders()).toEqual(2)
56
51
 
57
- env.destroy()
52
+ controller.destroy()
58
53
  end)
59
54
 
60
55
  it("constructs empty", function()
@@ -65,19 +60,19 @@ end)
65
60
 
66
61
  describe("BinderGroup:Add()", function()
67
62
  it("adds a binder and exposes it via GetBinders", function()
68
- local env = setup()
63
+ local controller = setup()
69
64
 
70
65
  local group = BinderGroup.new({})
71
- local binder = env.newBinder()
66
+ local binder = controller.newBinder()
72
67
  group:Add(binder)
73
68
 
74
69
  expect(group:GetBinders()[1]).toEqual(binder)
75
70
 
76
- env.destroy()
71
+ controller.destroy()
77
72
  end)
78
73
 
79
74
  it("fires BinderAdded with the added binder", function()
80
- local env = setup()
75
+ local controller = setup()
81
76
 
82
77
  local group = BinderGroup.new({})
83
78
  local fired
@@ -85,12 +80,12 @@ describe("BinderGroup:Add()", function()
85
80
  fired = binder
86
81
  end)
87
82
 
88
- local binder = env.newBinder()
83
+ local binder = controller.newBinder()
89
84
  group:Add(binder)
90
85
 
91
86
  expect(fired).toEqual(binder)
92
87
 
93
- env.destroy()
88
+ controller.destroy()
94
89
  end)
95
90
 
96
91
  it("throws when the value is not a binder", function()
@@ -103,7 +98,7 @@ end)
103
98
 
104
99
  describe("BinderGroup constructor validation", function()
105
100
  it("accepts binders whose constructor passes validation", function()
106
- local env = setup()
101
+ local controller = setup()
107
102
 
108
103
  local validated = {}
109
104
  local group = BinderGroup.new({}, function(constructor)
@@ -111,40 +106,40 @@ describe("BinderGroup constructor validation", function()
111
106
  return true
112
107
  end)
113
108
 
114
- local binder = env.newBinder()
109
+ local binder = controller.newBinder()
115
110
  group:Add(binder)
116
111
 
117
112
  expect(validated[1]).toEqual(binder:GetConstructor())
118
113
 
119
- env.destroy()
114
+ controller.destroy()
120
115
  end)
121
116
 
122
117
  it("throws when the constructor fails validation", function()
123
- local env = setup()
118
+ local controller = setup()
124
119
 
125
120
  local group = BinderGroup.new({}, function()
126
121
  return false
127
122
  end)
128
123
 
129
- local binder = env.newBinder()
124
+ local binder = controller.newBinder()
130
125
  expect(function()
131
126
  group:Add(binder)
132
127
  end).toThrow()
133
128
 
134
- env.destroy()
129
+ controller.destroy()
135
130
  end)
136
131
  end)
137
132
 
138
133
  describe("BinderGroup:AddList()", function()
139
134
  it("adds each binder in the list", function()
140
- local env = setup()
135
+ local controller = setup()
141
136
 
142
137
  local group = BinderGroup.new({})
143
- group:AddList({ env.newBinder(), env.newBinder() })
138
+ group:AddList({ controller.newBinder(), controller.newBinder() })
144
139
 
145
140
  expect(#group:GetBinders()).toEqual(2)
146
141
 
147
- env.destroy()
142
+ controller.destroy()
148
143
  end)
149
144
 
150
145
  it("throws on a non-table argument", function()
@@ -0,0 +1,163 @@
1
+ --!nonstrict
2
+ --[=[
3
+ Shared harness for the Binder specs. [BinderTestUtils.setup] boots binders the way production code
4
+ boots them: registered on a [BinderProvider] that is Init/Start'd through a [ServiceBag] and torn
5
+ down through `serviceBag:Destroy()`. Adornees are parented into a workspace container because
6
+ CollectionService.GetInstanceAddedSignal only fires for instances that live in the DataModel.
7
+
8
+ Tags are global and the test place is shared across a batch run, so every binder gets a distinct
9
+ tag from a single module-level counter -- shared across every spec file that requires this module,
10
+ so tags never collide between files -- and each controller cleans up after itself via `destroy()`.
11
+
12
+ @class BinderTestUtils
13
+ ]=]
14
+
15
+ local require = require(script.Parent.loader).load(script)
16
+
17
+ local Binder = require("Binder")
18
+ local BinderProvider = require("BinderProvider")
19
+ local ServiceBag = require("ServiceBag")
20
+
21
+ local BinderTestUtils = {}
22
+
23
+ local specCounter = 0
24
+
25
+ --[=[
26
+ A minimal bound class that records its instance and whether it was destroyed. It deliberately does
27
+ NOT retain its constructor varargs: the ServiceBag is injected as a constructor arg, and its object
28
+ graph contains Quenty Signals whose strict __index makes jest's deep-equality traversal throw.
29
+ Keeping the class free of them lets toEqual compare instances safely.
30
+
31
+ @return TrackingClass
32
+ ]=]
33
+ function BinderTestUtils.makeTrackingClass()
34
+ local Class = {}
35
+ Class.__index = Class
36
+ Class.ClassName = "TrackingClass"
37
+
38
+ function Class.new(inst)
39
+ local self = setmetatable({}, Class)
40
+ self.instance = inst
41
+ self.destroyed = false
42
+ return self
43
+ end
44
+
45
+ function Class:Destroy()
46
+ self.destroyed = true
47
+ end
48
+
49
+ return Class
50
+ end
51
+
52
+ --[=[
53
+ Returns once `inst` is no longer bound. CollectionService tag removal is synchronous here, so the
54
+ class is usually already gone by the time we check; the guarded wait also covers a deferred case.
55
+
56
+ @param binder Binder
57
+ @param inst Instance
58
+ ]=]
59
+ function BinderTestUtils.awaitUnbound(binder, inst)
60
+ if binder:Get(inst) ~= nil then
61
+ binder:GetClassRemovedSignal():Wait()
62
+ end
63
+ end
64
+
65
+ --[=[
66
+ A no-op constructor that still returns a value, so Binder's generic bound type stays inhabited.
67
+
68
+ @return table
69
+ ]=]
70
+ function BinderTestUtils.noopConstructor()
71
+ return {}
72
+ end
73
+
74
+ --[=[
75
+ Builds the controller the Binder specs share. Register binders with `addBinder`, boot them all at
76
+ once through a ServiceBag with `boot`, create adornees with `newInstance`, and tear everything down
77
+ with `destroy` (or just the service bag with `destroyServiceBag`).
78
+
79
+ Fields: `container`.
80
+ Builders: `addBinder(constructor, ...)` -> Binder, `newInstance(parent?, className?)` -> Instance.
81
+ Lifecycle: `boot()`, `destroyServiceBag()`, `destroy()`.
82
+ Helpers: `uniqueTag()` -> string.
83
+
84
+ @return { ... }
85
+ ]=]
86
+ function BinderTestUtils.setup()
87
+ specCounter += 1
88
+ local suffix = specCounter
89
+
90
+ local serviceBag = ServiceBag.new()
91
+ local serviceBagDestroyed = false
92
+
93
+ local container = Instance.new("Folder")
94
+ container.Name = "BinderSpecContainer"
95
+ container.Parent = workspace
96
+
97
+ local instances = {}
98
+ local pendingBinders = {}
99
+ local extraTagCounter = 0
100
+ local booted = false
101
+
102
+ local function uniqueTag()
103
+ extraTagCounter += 1
104
+ return string.format("BinderSpecTag_%d_%d", suffix, extraTagCounter)
105
+ end
106
+
107
+ local function addBinder(constructor, ...)
108
+ assert(not booted, "Cannot add a binder after boot()")
109
+ local binder = Binder.new(uniqueTag(), constructor, ...)
110
+ table.insert(pendingBinders, binder)
111
+ return binder
112
+ end
113
+
114
+ local function boot()
115
+ assert(not booted, "Already booted")
116
+ booted = true
117
+
118
+ local binders = pendingBinders
119
+ local provider = BinderProvider.new(string.format("BinderSpecProvider_%d", suffix), function(self)
120
+ for _, binder in binders do
121
+ self:Add(binder)
122
+ end
123
+ end)
124
+
125
+ serviceBag:GetService(provider)
126
+ serviceBag:Init()
127
+ serviceBag:Start()
128
+ end
129
+
130
+ local function newInstance(parent, className)
131
+ local inst = Instance.new(className or "Folder")
132
+ inst.Parent = parent or container
133
+ table.insert(instances, inst)
134
+ return inst
135
+ end
136
+
137
+ local function destroyServiceBag()
138
+ if not serviceBagDestroyed then
139
+ serviceBagDestroyed = true
140
+ serviceBag:Destroy()
141
+ end
142
+ end
143
+
144
+ return {
145
+ container = container,
146
+ uniqueTag = uniqueTag,
147
+ addBinder = addBinder,
148
+ boot = boot,
149
+ newInstance = newInstance,
150
+ destroyServiceBag = destroyServiceBag,
151
+ destroy = function()
152
+ destroyServiceBag()
153
+ for _, inst in instances do
154
+ pcall(function()
155
+ inst:Destroy()
156
+ end)
157
+ end
158
+ container:Destroy()
159
+ end,
160
+ }
161
+ end
162
+
163
+ return BinderTestUtils
@@ -1,12 +1,5 @@
1
1
  --!strict
2
2
  --[[
3
- Coverage for BinderUtils lookup helpers.
4
-
5
- Binders are booted through a ServiceBag (as production code does) and instances are tagged
6
- BEFORE the service bag starts, so they bind synchronously and :Get() resolves without waiting.
7
- Instances live in a workspace container so the binders' added signals fire. Each test tears
8
- down its service bag and instances because the test place is shared across a batch run.
9
-
10
3
  @class BinderUtils.spec.lua
11
4
  ]]
12
5
 
@@ -64,8 +57,6 @@ local function setup()
64
57
  return inst
65
58
  end
66
59
 
67
- -- Tags each instance with the binder, then boots everything through the service bag so the
68
- -- instances bind synchronously.
69
60
  local function boot()
70
61
  assert(not booted, "Already booted")
71
62
  booted = true
@@ -100,248 +91,248 @@ end
100
91
 
101
92
  describe("BinderUtils.findFirstAncestor()", function()
102
93
  it("returns the nearest bound ancestor, skipping the child itself", function()
103
- local env = setup()
94
+ local controller = setup()
104
95
 
105
- local grandparent = env.newInstance()
106
- local parent = env.newInstance(grandparent)
107
- local child = env.newInstance(parent)
96
+ local grandparent = controller.newInstance()
97
+ local parent = controller.newInstance(grandparent)
98
+ local child = controller.newInstance(parent)
108
99
 
109
- local binder = env.newBinder()
100
+ local binder = controller.newBinder()
110
101
  binder:Tag(grandparent)
111
102
  binder:Tag(child) -- child is bound too, but must be skipped
112
- env.boot()
103
+ controller.boot()
113
104
 
114
105
  expect(BinderUtils.findFirstAncestor(binder, child)).toEqual(binder:Get(grandparent))
115
106
 
116
- env.destroy()
107
+ controller.destroy()
117
108
  end)
118
109
 
119
110
  it("returns nil when no ancestor is bound", function()
120
- local env = setup()
111
+ local controller = setup()
121
112
 
122
- local parent = env.newInstance()
123
- local child = env.newInstance(parent)
124
- local binder = env.newBinder()
125
- env.boot()
113
+ local parent = controller.newInstance()
114
+ local child = controller.newInstance(parent)
115
+ local binder = controller.newBinder()
116
+ controller.boot()
126
117
 
127
118
  expect(BinderUtils.findFirstAncestor(binder, child)).toBeNil()
128
119
 
129
- env.destroy()
120
+ controller.destroy()
130
121
  end)
131
122
 
132
123
  it("throws for a non-instance child", function()
133
- local env = setup()
124
+ local controller = setup()
134
125
 
135
- local binder = env.newBinder()
136
- env.boot()
126
+ local binder = controller.newBinder()
127
+ controller.boot()
137
128
  expect(function()
138
129
  BinderUtils.findFirstAncestor(binder, 5 :: any)
139
130
  end).toThrow()
140
131
 
141
- env.destroy()
132
+ controller.destroy()
142
133
  end)
143
134
  end)
144
135
 
145
136
  describe("BinderUtils.findFirstChild()", function()
146
137
  it("returns the first bound child", function()
147
- local env = setup()
138
+ local controller = setup()
148
139
 
149
- local parent = env.newInstance()
150
- local unboundChild = env.newInstance(parent)
151
- local boundChild = env.newInstance(parent)
140
+ local parent = controller.newInstance()
141
+ local unboundChild = controller.newInstance(parent)
142
+ local boundChild = controller.newInstance(parent)
152
143
 
153
- local binder = env.newBinder()
144
+ local binder = controller.newBinder()
154
145
  binder:Tag(boundChild)
155
- env.boot()
146
+ controller.boot()
156
147
 
157
148
  expect(BinderUtils.findFirstChild(binder, parent)).toEqual(binder:Get(boundChild))
158
149
  expect(binder:Get(unboundChild)).toBeNil()
159
150
 
160
- env.destroy()
151
+ controller.destroy()
161
152
  end)
162
153
 
163
154
  it("returns nil when no child is bound", function()
164
- local env = setup()
155
+ local controller = setup()
165
156
 
166
- local parent = env.newInstance()
167
- env.newInstance(parent)
168
- local binder = env.newBinder()
169
- env.boot()
157
+ local parent = controller.newInstance()
158
+ controller.newInstance(parent)
159
+ local binder = controller.newBinder()
160
+ controller.boot()
170
161
 
171
162
  expect(BinderUtils.findFirstChild(binder, parent)).toBeNil()
172
163
 
173
- env.destroy()
164
+ controller.destroy()
174
165
  end)
175
166
  end)
176
167
 
177
168
  describe("BinderUtils.getChildren()", function()
178
169
  it("returns every bound child", function()
179
- local env = setup()
170
+ local controller = setup()
180
171
 
181
- local parent = env.newInstance()
182
- local childA = env.newInstance(parent)
183
- local childB = env.newInstance(parent)
184
- env.newInstance(parent) -- unbound
172
+ local parent = controller.newInstance()
173
+ local childA = controller.newInstance(parent)
174
+ local childB = controller.newInstance(parent)
175
+ controller.newInstance(parent) -- unbound
185
176
 
186
- local binder = env.newBinder()
177
+ local binder = controller.newBinder()
187
178
  binder:Tag(childA)
188
179
  binder:Tag(childB)
189
- env.boot()
180
+ controller.boot()
190
181
 
191
182
  expect(#BinderUtils.getChildren(binder, parent)).toEqual(2)
192
183
 
193
- env.destroy()
184
+ controller.destroy()
194
185
  end)
195
186
 
196
187
  it("does not include bound descendants deeper than one level", function()
197
- local env = setup()
188
+ local controller = setup()
198
189
 
199
- local parent = env.newInstance()
200
- local child = env.newInstance(parent)
201
- local grandchild = env.newInstance(child)
190
+ local parent = controller.newInstance()
191
+ local child = controller.newInstance(parent)
192
+ local grandchild = controller.newInstance(child)
202
193
 
203
- local binder = env.newBinder()
194
+ local binder = controller.newBinder()
204
195
  binder:Tag(grandchild)
205
- env.boot()
196
+ controller.boot()
206
197
 
207
198
  expect(#BinderUtils.getChildren(binder, parent)).toEqual(0)
208
199
 
209
- env.destroy()
200
+ controller.destroy()
210
201
  end)
211
202
  end)
212
203
 
213
204
  describe("BinderUtils.getDescendants()", function()
214
205
  it("returns bound instances at any depth", function()
215
- local env = setup()
206
+ local controller = setup()
216
207
 
217
- local parent = env.newInstance()
218
- local child = env.newInstance(parent)
219
- local grandchild = env.newInstance(child)
208
+ local parent = controller.newInstance()
209
+ local child = controller.newInstance(parent)
210
+ local grandchild = controller.newInstance(child)
220
211
 
221
- local binder = env.newBinder()
212
+ local binder = controller.newBinder()
222
213
  binder:Tag(child)
223
214
  binder:Tag(grandchild)
224
- env.boot()
215
+ controller.boot()
225
216
 
226
217
  expect(#BinderUtils.getDescendants(binder, parent)).toEqual(2)
227
218
 
228
- env.destroy()
219
+ controller.destroy()
229
220
  end)
230
221
  end)
231
222
 
232
223
  describe("BinderUtils.mapBinderListToTable()", function()
233
224
  it("keys binders by their tag", function()
234
- local env = setup()
225
+ local controller = setup()
235
226
 
236
- local binderA = env.newBinder()
237
- local binderB = env.newBinder()
238
- env.boot()
227
+ local binderA = controller.newBinder()
228
+ local binderB = controller.newBinder()
229
+ controller.boot()
239
230
 
240
231
  local map = BinderUtils.mapBinderListToTable({ binderA, binderB })
241
232
  expect(map[binderA:GetTag()]).toEqual(binderA)
242
233
  expect(map[binderB:GetTag()]).toEqual(binderB)
243
234
 
244
- env.destroy()
235
+ controller.destroy()
245
236
  end)
246
237
  end)
247
238
 
248
239
  describe("BinderUtils.getMappedFromList()", function()
249
240
  it("resolves bound values across an instance list by tag", function()
250
- local env = setup()
241
+ local controller = setup()
251
242
 
252
- local instA = env.newInstance()
253
- local instB = env.newInstance()
254
- local binderA = env.newBinder()
255
- local binderB = env.newBinder()
243
+ local instA = controller.newInstance()
244
+ local instB = controller.newInstance()
245
+ local binderA = controller.newBinder()
246
+ local binderB = controller.newBinder()
256
247
  binderA:Tag(instA)
257
248
  binderB:Tag(instB)
258
- env.boot()
249
+ controller.boot()
259
250
 
260
251
  local tagsMap = BinderUtils.mapBinderListToTable({ binderA, binderB })
261
- local objects = BinderUtils.getMappedFromList(tagsMap, { instA, instB, env.newInstance() })
252
+ local objects = BinderUtils.getMappedFromList(tagsMap, { instA, instB, controller.newInstance() })
262
253
 
263
254
  expect(#objects).toEqual(2)
264
255
 
265
- env.destroy()
256
+ controller.destroy()
266
257
  end)
267
258
  end)
268
259
 
269
260
  describe("BinderUtils.getChildrenOfBinders()", function()
270
261
  it("returns children bound by any binder in the list", function()
271
- local env = setup()
262
+ local controller = setup()
272
263
 
273
- local parent = env.newInstance()
274
- local childA = env.newInstance(parent)
275
- local childB = env.newInstance(parent)
264
+ local parent = controller.newInstance()
265
+ local childA = controller.newInstance(parent)
266
+ local childB = controller.newInstance(parent)
276
267
 
277
- local binderA = env.newBinder()
278
- local binderB = env.newBinder()
268
+ local binderA = controller.newBinder()
269
+ local binderB = controller.newBinder()
279
270
  binderA:Tag(childA)
280
271
  binderB:Tag(childB)
281
- env.boot()
272
+ controller.boot()
282
273
 
283
274
  expect(#BinderUtils.getChildrenOfBinders({ binderA, binderB }, parent)).toEqual(2)
284
275
 
285
- env.destroy()
276
+ controller.destroy()
286
277
  end)
287
278
  end)
288
279
 
289
280
  describe("BinderUtils.getLinkedChildren()", function()
290
281
  it("resolves bound targets of matching ObjectValue links", function()
291
- local env = setup()
282
+ local controller = setup()
292
283
 
293
- local parent = env.newInstance()
294
- local target = env.newInstance()
295
- local binder = env.newBinder()
284
+ local parent = controller.newInstance()
285
+ local target = controller.newInstance()
286
+ local binder = controller.newBinder()
296
287
  binder:Tag(target)
297
288
 
298
- local link = env.newInstance(parent, "ObjectValue") :: ObjectValue
289
+ local link = controller.newInstance(parent, "ObjectValue") :: ObjectValue
299
290
  link.Name = "Link"
300
291
  link.Value = target
301
- env.boot()
292
+ controller.boot()
302
293
 
303
294
  local objects = BinderUtils.getLinkedChildren(binder, "Link", parent)
304
295
  expect(#objects).toEqual(1)
305
296
  expect(objects[1]).toEqual(binder:Get(target))
306
297
 
307
- env.destroy()
298
+ controller.destroy()
308
299
  end)
309
300
 
310
301
  it("ignores links whose name does not match", function()
311
- local env = setup()
302
+ local controller = setup()
312
303
 
313
- local parent = env.newInstance()
314
- local target = env.newInstance()
315
- local binder = env.newBinder()
304
+ local parent = controller.newInstance()
305
+ local target = controller.newInstance()
306
+ local binder = controller.newBinder()
316
307
  binder:Tag(target)
317
308
 
318
- local link = env.newInstance(parent, "ObjectValue") :: ObjectValue
309
+ local link = controller.newInstance(parent, "ObjectValue") :: ObjectValue
319
310
  link.Name = "OtherLink"
320
311
  link.Value = target
321
- env.boot()
312
+ controller.boot()
322
313
 
323
314
  expect(#BinderUtils.getLinkedChildren(binder, "Link", parent)).toEqual(0)
324
315
 
325
- env.destroy()
316
+ controller.destroy()
326
317
  end)
327
318
 
328
319
  it("deduplicates when two links point to the same bound target", function()
329
- local env = setup()
320
+ local controller = setup()
330
321
 
331
- local parent = env.newInstance()
332
- local target = env.newInstance()
333
- local binder = env.newBinder()
322
+ local parent = controller.newInstance()
323
+ local target = controller.newInstance()
324
+ local binder = controller.newBinder()
334
325
  binder:Tag(target)
335
326
 
336
327
  for _ = 1, 2 do
337
- local link = env.newInstance(parent, "ObjectValue") :: ObjectValue
328
+ local link = controller.newInstance(parent, "ObjectValue") :: ObjectValue
338
329
  link.Name = "Link"
339
330
  link.Value = target
340
331
  end
341
- env.boot()
332
+ controller.boot()
342
333
 
343
334
  expect(#BinderUtils.getLinkedChildren(binder, "Link", parent)).toEqual(1)
344
335
 
345
- env.destroy()
336
+ controller.destroy()
346
337
  end)
347
338
  end)