@quenty/binder 14.38.0 → 14.40.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,721 @@
1
+ --!strict
2
+ --[[
3
+ Comprehensive coverage for Binder.
4
+
5
+ Runtime binders are booted the way production code boots them: registered on a BinderProvider
6
+ that is initialized and started through a ServiceBag, and torn down through serviceBag:Destroy().
7
+ Adornees are parented into a workspace container because CollectionService.GetInstanceAddedSignal
8
+ only fires for instances that live in the DataModel. A bind that happens after :Start() is awaited
9
+ event-driven through `binder:Promise(inst):Yield()`, never a fixed sleep. Tags are global and the
10
+ test place is shared across a batch run, so every test uses a distinct tag and cleans up after
11
+ itself.
12
+
13
+ A handful of tests exercise Binder's standalone lifecycle contract (:new/:Init/:Start guards,
14
+ :Create) and construct the binder directly -- there is no other way to assert, for example, that
15
+ :Start() is idempotent when called twice by hand.
16
+
17
+ @class Binder.spec.lua
18
+ ]]
19
+
20
+ local require = require(script.Parent.loader).load(script)
21
+
22
+ local Binder = require("Binder")
23
+ local BinderProvider = require("BinderProvider")
24
+ local Jest = require("Jest")
25
+ local ServiceBag = require("ServiceBag")
26
+
27
+ local describe = Jest.Globals.describe
28
+ local expect = Jest.Globals.expect
29
+ local it = Jest.Globals.it
30
+
31
+ local specCounter = 0
32
+
33
+ -- A minimal bound class that records its instance and whether it was destroyed. It deliberately
34
+ -- does NOT retain its constructor varargs: the ServiceBag is injected as a constructor arg, and its
35
+ -- object graph contains Quenty Signals whose strict __index makes jest's deep-equality traversal
36
+ -- throw. Keeping the class free of them lets toEqual compare instances safely.
37
+ local function makeTrackingClass()
38
+ local Class = {}
39
+ Class.__index = Class
40
+ Class.ClassName = "TrackingClass"
41
+
42
+ function Class.new(inst)
43
+ local self = setmetatable({}, Class)
44
+ self.instance = inst
45
+ self.destroyed = false
46
+ return self
47
+ end
48
+
49
+ function Class:Destroy()
50
+ self.destroyed = true
51
+ end
52
+
53
+ return Class
54
+ end
55
+
56
+ -- Returns once `inst` is no longer bound. CollectionService tag removal is synchronous here, so the
57
+ -- class is usually already gone by the time we check; the guarded wait also covers a deferred case.
58
+ local function awaitUnbound(binder, inst)
59
+ if binder:Get(inst) ~= nil then
60
+ binder:GetClassRemovedSignal():Wait()
61
+ end
62
+ end
63
+
64
+ -- A no-op constructor that still returns a value, so Binder's generic bound type stays inhabited.
65
+ local function noopConstructor()
66
+ return {}
67
+ end
68
+
69
+ local function setup()
70
+ specCounter += 1
71
+ local suffix = specCounter
72
+
73
+ local serviceBag = ServiceBag.new()
74
+ local serviceBagDestroyed = false
75
+
76
+ local container = Instance.new("Folder")
77
+ container.Name = "BinderSpecContainer"
78
+ container.Parent = workspace
79
+
80
+ local instances = {}
81
+ local pendingBinders: { any } = {}
82
+ local extraTagCounter = 0
83
+ local booted = false
84
+
85
+ local function uniqueTag(): string
86
+ extraTagCounter += 1
87
+ return string.format("BinderSpecTag_%d_%d", suffix, extraTagCounter)
88
+ end
89
+
90
+ -- Registers a binder to be booted by boot(); returns the binder so the test can tag/query it.
91
+ -- The constructor is intentionally untyped (any) so per-test generic inference does not leak
92
+ -- into this shared helper.
93
+ local function addBinder(constructor: any, ...): Binder.Binder<any>
94
+ assert(not booted, "Cannot add a binder after boot()")
95
+ local binder = Binder.new(uniqueTag(), constructor, ...)
96
+ table.insert(pendingBinders, binder)
97
+ return binder
98
+ end
99
+
100
+ -- Boots the registered binders through a ServiceBag, exactly as production code does.
101
+ local function boot()
102
+ assert(not booted, "Already booted")
103
+ booted = true
104
+
105
+ local binders = pendingBinders
106
+ local provider = BinderProvider.new(string.format("BinderSpecProvider_%d", suffix), function(self)
107
+ for _, binder in binders do
108
+ self:Add(binder)
109
+ end
110
+ end)
111
+
112
+ serviceBag:GetService(provider)
113
+ serviceBag:Init()
114
+ serviceBag:Start()
115
+ end
116
+
117
+ local function newInstance(parent: Instance?, className: string?): Instance
118
+ local inst = Instance.new(className or "Folder")
119
+ inst.Parent = parent or container
120
+ table.insert(instances, inst)
121
+ return inst
122
+ end
123
+
124
+ local function destroyServiceBag()
125
+ if not serviceBagDestroyed then
126
+ serviceBagDestroyed = true
127
+ serviceBag:Destroy()
128
+ end
129
+ end
130
+
131
+ return {
132
+ container = container,
133
+ uniqueTag = uniqueTag,
134
+ addBinder = addBinder,
135
+ boot = boot,
136
+ newInstance = newInstance,
137
+ destroyServiceBag = destroyServiceBag,
138
+ destroy = function()
139
+ destroyServiceBag()
140
+ for _, inst in instances do
141
+ pcall(function()
142
+ inst:Destroy()
143
+ end)
144
+ end
145
+ container:Destroy()
146
+ end,
147
+ }
148
+ end
149
+
150
+ describe("Binder.new()", function()
151
+ it("constructs a binder", function()
152
+ -- Never Init'd/Started, so it holds no resources and needs no teardown.
153
+ local binder = Binder.new("BinderNewSpecTag", noopConstructor)
154
+ expect(Binder.isBinder(binder)).toEqual(true)
155
+ end)
156
+
157
+ it("derives the ServiceName from the tag", function()
158
+ local binder = Binder.new("BinderNameSpecTag", noopConstructor)
159
+ expect(binder.ServiceName).toEqual("BinderNameSpecTagBinder")
160
+ end)
161
+
162
+ it("throws on a non-string tag name", function()
163
+ expect(function()
164
+ Binder.new(123 :: any, noopConstructor)
165
+ end).toThrow()
166
+ end)
167
+
168
+ it("throws when constructing a binder of a binder", function()
169
+ local inner = Binder.new("BinderOfBinderInner", noopConstructor)
170
+ expect(function()
171
+ Binder.new("BinderOfBinderOuter", inner :: any)
172
+ end).toThrow()
173
+ end)
174
+
175
+ it("captures variadic constructor args", function()
176
+ local env = setup()
177
+
178
+ -- Explicit .new()-time args take precedence over the ServiceBag that Init would otherwise
179
+ -- inject, so this class only ever captures the "a"/"b" passed here.
180
+ local binder = env.addBinder(function(_inst, a, b)
181
+ return { a = a, b = b }
182
+ end, "a", "b")
183
+ env.boot()
184
+
185
+ local inst = env.newInstance()
186
+ binder:Tag(inst)
187
+
188
+ local ok, class = binder:Promise(inst):Yield()
189
+ assert(ok, "Never bound")
190
+ expect(class.a).toEqual("a")
191
+ expect(class.b).toEqual("b")
192
+
193
+ env.destroy()
194
+ end)
195
+ end)
196
+
197
+ describe("Binder.isBinder()", function()
198
+ it("returns false for non-tables", function()
199
+ expect(Binder.isBinder(nil)).toEqual(false)
200
+ expect(Binder.isBinder(5)).toEqual(false)
201
+ expect(Binder.isBinder("binder")).toEqual(false)
202
+ end)
203
+
204
+ it("returns false for tables missing the interface", function()
205
+ expect(Binder.isBinder({ Start = function() end })).toEqual(false)
206
+ end)
207
+
208
+ it("returns true for a real binder", function()
209
+ local binder = Binder.new("BinderIsBinderSpecTag", noopConstructor)
210
+ expect(Binder.isBinder(binder)).toEqual(true)
211
+ end)
212
+ end)
213
+
214
+ describe("Binder metadata", function()
215
+ it("returns the tag from GetTag()", function()
216
+ local binder = Binder.new("BinderGetTagSpecTag", noopConstructor)
217
+ expect(binder:GetTag()).toEqual("BinderGetTagSpecTag")
218
+ end)
219
+
220
+ it("returns the constructor from GetConstructor()", function()
221
+ local constructor = noopConstructor
222
+ local binder = Binder.new("BinderGetConstructorSpecTag", constructor)
223
+ expect(binder:GetConstructor()).toEqual(constructor)
224
+ end)
225
+ end)
226
+
227
+ describe("Binder constructor variants", function()
228
+ it("supports a plain function constructor", function()
229
+ local env = setup()
230
+
231
+ local binder = env.addBinder(function(inst)
232
+ return { instance = inst, kind = "function" }
233
+ end)
234
+ env.boot()
235
+
236
+ local inst = env.newInstance()
237
+ binder:Tag(inst)
238
+
239
+ local ok, class = binder:Promise(inst):Yield()
240
+ assert(ok, "Never bound")
241
+ expect(class.kind).toEqual("function")
242
+ expect(class.instance).toEqual(inst)
243
+
244
+ env.destroy()
245
+ end)
246
+
247
+ it("supports a class table with .new", function()
248
+ local env = setup()
249
+
250
+ local Class = makeTrackingClass()
251
+ local binder = env.addBinder(Class)
252
+ env.boot()
253
+
254
+ local inst = env.newInstance()
255
+ binder:Tag(inst)
256
+
257
+ local ok, class = binder:Promise(inst):Yield()
258
+ assert(ok, "Never bound")
259
+ expect(class.instance).toEqual(inst)
260
+
261
+ env.destroy()
262
+ end)
263
+
264
+ it("supports a provider table with :Create", function()
265
+ local env = setup()
266
+
267
+ local provider = {}
268
+ function provider.Create(_self, inst)
269
+ return { instance = inst, kind = "provider" }
270
+ end
271
+
272
+ local binder = env.addBinder(provider :: any)
273
+ env.boot()
274
+
275
+ local inst = env.newInstance()
276
+ binder:Tag(inst)
277
+
278
+ local ok, class = binder:Promise(inst):Yield()
279
+ assert(ok, "Never bound")
280
+ expect(class.kind).toEqual("provider")
281
+
282
+ env.destroy()
283
+ end)
284
+ end)
285
+
286
+ describe("Binder lifecycle guards", function()
287
+ it("Init is idempotent", function()
288
+ local binder = Binder.new("BinderInitIdempotentSpecTag", noopConstructor)
289
+ binder:Init()
290
+ expect(function()
291
+ binder:Init()
292
+ end).never.toThrow()
293
+ binder:Destroy()
294
+ end)
295
+
296
+ it("Start is idempotent", function()
297
+ local binder = Binder.new("BinderStartIdempotentSpecTag", noopConstructor)
298
+ binder:Start()
299
+ expect(function()
300
+ binder:Start()
301
+ end).never.toThrow()
302
+ binder:Destroy()
303
+ end)
304
+
305
+ it("Start initializes implicitly", function()
306
+ local binder = Binder.new("BinderStartInitSpecTag", noopConstructor)
307
+ binder:Start()
308
+ expect(binder:GetAll()).toEqual({})
309
+ binder:Destroy()
310
+ end)
311
+ end)
312
+
313
+ describe("Binder binding via ServiceBag", function()
314
+ it("binds instances tagged before start", function()
315
+ local env = setup()
316
+
317
+ local Class = makeTrackingClass()
318
+ local binder = env.addBinder(Class)
319
+
320
+ local inst = env.newInstance()
321
+ binder:Tag(inst) -- tag before the service bag starts the binder
322
+
323
+ env.boot()
324
+
325
+ -- Pre-tagged instances bind synchronously as the binder starts.
326
+ expect(binder:Get(inst)).never.toBeNil()
327
+
328
+ env.destroy()
329
+ end)
330
+
331
+ it("binds instances tagged after start", function()
332
+ local env = setup()
333
+
334
+ local binder = env.addBinder(makeTrackingClass())
335
+ env.boot()
336
+
337
+ local inst = env.newInstance()
338
+ binder:Tag(inst)
339
+
340
+ local ok = binder:Promise(inst):Yield()
341
+ expect(ok).toEqual(true)
342
+
343
+ env.destroy()
344
+ end)
345
+ end)
346
+
347
+ describe("Binder:Get()", function()
348
+ it("returns nil for an unbound instance", function()
349
+ local env = setup()
350
+
351
+ local binder = env.addBinder(function() end)
352
+ env.boot()
353
+
354
+ expect(binder:Get(env.newInstance())).toBeNil()
355
+
356
+ env.destroy()
357
+ end)
358
+
359
+ it("throws when passed a non-instance", function()
360
+ local env = setup()
361
+
362
+ local binder = env.addBinder(function() end)
363
+ env.boot()
364
+
365
+ expect(function()
366
+ binder:Get(5 :: any)
367
+ end).toThrow()
368
+
369
+ env.destroy()
370
+ end)
371
+ end)
372
+
373
+ describe("Binder tagging", function()
374
+ it("Tag/HasTag/Untag manage the collection service tag", function()
375
+ local env = setup()
376
+
377
+ local binder = env.addBinder(function() end)
378
+ env.boot()
379
+
380
+ local inst = env.newInstance()
381
+ expect(binder:HasTag(inst)).toEqual(false)
382
+ binder:Tag(inst)
383
+ expect(binder:HasTag(inst)).toEqual(true)
384
+ binder:Untag(inst)
385
+ expect(binder:HasTag(inst)).toEqual(false)
386
+
387
+ env.destroy()
388
+ end)
389
+
390
+ it("Bind tags and Unbind untags on the server", function()
391
+ local env = setup()
392
+
393
+ local binder = env.addBinder(makeTrackingClass())
394
+ env.boot()
395
+
396
+ local inst = env.newInstance()
397
+ binder:Bind(inst)
398
+ expect(binder:HasTag(inst)).toEqual(true)
399
+
400
+ local ok = binder:Promise(inst):Yield()
401
+ expect(ok).toEqual(true)
402
+
403
+ binder:Unbind(inst)
404
+ expect(binder:HasTag(inst)).toEqual(false)
405
+
406
+ env.destroy()
407
+ end)
408
+ end)
409
+
410
+ describe("Binder:Create()", function()
411
+ it("creates a tagged, non-archivable instance named after the tag", function()
412
+ local binder = Binder.new("BinderCreateSpecTag", noopConstructor)
413
+
414
+ -- Cast: Create's declared signature marks className required, but it defaults when omitted.
415
+ local inst = (binder :: any):Create()
416
+ expect(inst.Name).toEqual("BinderCreateSpecTag")
417
+ expect(inst.Archivable).toEqual(false)
418
+ expect(binder:HasTag(inst)).toEqual(true)
419
+ expect(inst:IsA("Folder")).toEqual(true)
420
+
421
+ inst:Destroy()
422
+ end)
423
+
424
+ it("honors an explicit class name", function()
425
+ local binder = Binder.new("BinderCreateNamedSpecTag", noopConstructor)
426
+ local inst = binder:Create("BoolValue")
427
+ expect(inst:IsA("BoolValue")).toEqual(true)
428
+
429
+ inst:Destroy()
430
+ end)
431
+
432
+ it("throws on a non-string class name", function()
433
+ local binder = Binder.new("BinderCreateBadSpecTag", noopConstructor)
434
+ expect(function()
435
+ binder:Create(5 :: any)
436
+ end).toThrow()
437
+ end)
438
+ end)
439
+
440
+ describe("Binder:GetAll() / GetAllSet()", function()
441
+ it("tracks every bound class", function()
442
+ local env = setup()
443
+
444
+ local binder = env.addBinder(makeTrackingClass())
445
+ env.boot()
446
+
447
+ local instA = env.newInstance()
448
+ local instB = env.newInstance()
449
+ binder:Tag(instA)
450
+ binder:Tag(instB)
451
+
452
+ assert((binder:Promise(instA):Yield()), "A never bound")
453
+ assert((binder:Promise(instB):Yield()), "B never bound")
454
+
455
+ expect(#binder:GetAll()).toEqual(2)
456
+
457
+ local set = binder:GetAllSet()
458
+ expect(set[binder:Get(instA)]).toEqual(true)
459
+ expect(set[binder:Get(instB)]).toEqual(true)
460
+
461
+ env.destroy()
462
+ end)
463
+ end)
464
+
465
+ describe("Binder signals", function()
466
+ it("memoizes the added/removing/removed signals", function()
467
+ local env = setup()
468
+
469
+ local binder = env.addBinder(function() end)
470
+ env.boot()
471
+
472
+ expect(binder:GetClassAddedSignal()).toEqual(binder:GetClassAddedSignal())
473
+ expect(binder:GetClassRemovingSignal()).toEqual(binder:GetClassRemovingSignal())
474
+ expect(binder:GetClassRemovedSignal()).toEqual(binder:GetClassRemovedSignal())
475
+
476
+ env.destroy()
477
+ end)
478
+
479
+ it("fires the added signal with the class and instance", function()
480
+ local env = setup()
481
+
482
+ local binder = env.addBinder(makeTrackingClass())
483
+ env.boot()
484
+
485
+ local firedClass, firedInst
486
+ binder:GetClassAddedSignal():Connect(function(class, inst)
487
+ firedClass = class
488
+ firedInst = inst
489
+ end)
490
+
491
+ local inst = env.newInstance()
492
+ binder:Tag(inst)
493
+ assert((binder:Promise(inst):Yield()), "Never bound")
494
+
495
+ expect(firedInst).toEqual(inst)
496
+ expect(firedClass).toEqual(binder:Get(inst))
497
+
498
+ env.destroy()
499
+ end)
500
+
501
+ it("fires removing then removed on unbind", function()
502
+ local env = setup()
503
+
504
+ local binder = env.addBinder(makeTrackingClass())
505
+ env.boot()
506
+
507
+ local order = {}
508
+ binder:GetClassRemovingSignal():Connect(function()
509
+ table.insert(order, "removing")
510
+ end)
511
+ binder:GetClassRemovedSignal():Connect(function()
512
+ table.insert(order, "removed")
513
+ end)
514
+
515
+ local inst = env.newInstance()
516
+ binder:Tag(inst)
517
+ assert((binder:Promise(inst):Yield()), "Never bound")
518
+
519
+ binder:Untag(inst)
520
+ awaitUnbound(binder, inst)
521
+
522
+ expect(order[1]).toEqual("removing")
523
+ expect(order[2]).toEqual("removed")
524
+
525
+ env.destroy()
526
+ end)
527
+
528
+ it("destroys the bound class when it is removed", function()
529
+ local env = setup()
530
+
531
+ local binder = env.addBinder(makeTrackingClass())
532
+ env.boot()
533
+
534
+ local inst = env.newInstance()
535
+ binder:Tag(inst)
536
+ local ok, class = binder:Promise(inst):Yield()
537
+ assert(ok, "Never bound")
538
+
539
+ binder:Untag(inst)
540
+ awaitUnbound(binder, inst)
541
+
542
+ expect(class.destroyed).toEqual(true)
543
+
544
+ env.destroy()
545
+ end)
546
+ end)
547
+
548
+ describe("Binder:ObserveInstance()", function()
549
+ it("fires with the class on bind and nil on unbind", function()
550
+ local env = setup()
551
+
552
+ local binder = env.addBinder(makeTrackingClass())
553
+ env.boot()
554
+
555
+ local inst = env.newInstance()
556
+
557
+ -- Wrap each emission so a nil value is still recorded (table.insert(t, nil) is a no-op).
558
+ local emissions = {}
559
+ local cleanup = binder:ObserveInstance(inst, function(class)
560
+ table.insert(emissions, { value = class })
561
+ end)
562
+
563
+ binder:Tag(inst)
564
+ local ok, class = binder:Promise(inst):Yield()
565
+ assert(ok, "Never bound")
566
+
567
+ binder:Untag(inst)
568
+ awaitUnbound(binder, inst)
569
+
570
+ expect(#emissions).toEqual(2)
571
+ expect(emissions[1].value).toEqual(class)
572
+ expect(emissions[2].value).toBeNil()
573
+
574
+ cleanup()
575
+
576
+ env.destroy()
577
+ end)
578
+
579
+ it("returns a cleanup that stops future callbacks", function()
580
+ local env = setup()
581
+
582
+ local binder = env.addBinder(makeTrackingClass())
583
+ env.boot()
584
+
585
+ local inst = env.newInstance()
586
+
587
+ local count = 0
588
+ local cleanup = binder:ObserveInstance(inst, function()
589
+ count += 1
590
+ end)
591
+ cleanup()
592
+
593
+ binder:Tag(inst)
594
+ assert((binder:Promise(inst):Yield()), "Never bound")
595
+
596
+ expect(count).toEqual(0)
597
+
598
+ env.destroy()
599
+ end)
600
+
601
+ it("throws for a non-instance or non-function", function()
602
+ local env = setup()
603
+
604
+ local binder = env.addBinder(function() end)
605
+ env.boot()
606
+
607
+ expect(function()
608
+ binder:ObserveInstance(5 :: any, function() end)
609
+ end).toThrow()
610
+ expect(function()
611
+ binder:ObserveInstance(env.newInstance(), 5 :: any)
612
+ end).toThrow()
613
+
614
+ env.destroy()
615
+ end)
616
+ end)
617
+
618
+ describe("Binder:Observe()", function()
619
+ it("emits the current value on subscribe and updates on bind", function()
620
+ local env = setup()
621
+
622
+ local binder = env.addBinder(makeTrackingClass())
623
+ env.boot()
624
+
625
+ local inst = env.newInstance()
626
+
627
+ -- Wrap each emission so the initial nil value is still recorded.
628
+ local emissions = {}
629
+ local sub = binder:Observe(inst):Subscribe(function(class)
630
+ table.insert(emissions, { value = class })
631
+ end)
632
+
633
+ -- First emission is the (nil) current value.
634
+ expect(#emissions).toEqual(1)
635
+ expect(emissions[1].value).toBeNil()
636
+
637
+ binder:Tag(inst)
638
+ assert((binder:Promise(inst):Yield()), "Never bound")
639
+
640
+ expect(#emissions).toEqual(2)
641
+ expect(emissions[2].value).toEqual(binder:Get(inst))
642
+
643
+ sub:Destroy()
644
+ env.destroy()
645
+ end)
646
+ end)
647
+
648
+ describe("Binder:Promise()", function()
649
+ it("resolves immediately when already bound", function()
650
+ local env = setup()
651
+
652
+ local binder = env.addBinder(makeTrackingClass())
653
+
654
+ local inst = env.newInstance()
655
+ binder:Tag(inst)
656
+ env.boot()
657
+
658
+ local promise = binder:Promise(inst)
659
+ expect(promise:IsFulfilled()).toEqual(true)
660
+
661
+ env.destroy()
662
+ end)
663
+
664
+ it("throws when passed a non-instance", function()
665
+ local env = setup()
666
+
667
+ local binder = env.addBinder(function() end)
668
+ env.boot()
669
+
670
+ expect(function()
671
+ binder:Promise(5 :: any)
672
+ end).toThrow()
673
+
674
+ env.destroy()
675
+ end)
676
+ end)
677
+
678
+ describe("Binder:_add() dedupe", function()
679
+ it("does not rebind an already-bound instance", function()
680
+ local env = setup()
681
+
682
+ local constructed = 0
683
+ local binder = env.addBinder(function(inst)
684
+ constructed += 1
685
+ return { instance = inst }
686
+ end)
687
+ env.boot()
688
+
689
+ local inst = env.newInstance()
690
+ binder:Tag(inst)
691
+ assert((binder:Promise(inst):Yield()), "Never bound")
692
+
693
+ -- Re-tagging an already-tagged instance must not reconstruct.
694
+ binder:Tag(inst)
695
+ task.wait()
696
+
697
+ expect(constructed).toEqual(1)
698
+
699
+ env.destroy()
700
+ end)
701
+ end)
702
+
703
+ describe("Binder teardown", function()
704
+ it("removes and destroys all bound classes when the service bag is destroyed", function()
705
+ local env = setup()
706
+
707
+ local binder = env.addBinder(makeTrackingClass())
708
+ env.boot()
709
+
710
+ local inst = env.newInstance()
711
+ binder:Tag(inst)
712
+ local ok, class = binder:Promise(inst):Yield()
713
+ assert(ok, "Never bound")
714
+
715
+ env.destroyServiceBag()
716
+
717
+ expect(class.destroyed).toEqual(true)
718
+
719
+ env.destroy()
720
+ end)
721
+ end)