@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,212 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for BoundChildCollection, which tracks the bound children of a parent instance.
4
+
5
+ The binder is booted through a ServiceBag (as production code does); the collection itself is a
6
+ plain BaseObject and is constructed directly. Everything lives under a workspace container so
7
+ CollectionService and ChildAdded/ChildRemoved signals fire. Children tagged before the service
8
+ bag starts bind synchronously; changes made after construction are awaited event-driven via the
9
+ collection's own ClassAdded/ClassRemoved signals rather than a fixed sleep.
10
+
11
+ @class BoundChildCollection.spec.lua
12
+ ]]
13
+
14
+ local require = require(script.Parent.loader).load(script)
15
+
16
+ local Binder = require("Binder")
17
+ local BinderProvider = require("BinderProvider")
18
+ local BoundChildCollection = require("BoundChildCollection")
19
+ local Jest = require("Jest")
20
+ local ServiceBag = require("ServiceBag")
21
+
22
+ local describe = Jest.Globals.describe
23
+ local expect = Jest.Globals.expect
24
+ local it = Jest.Globals.it
25
+
26
+ local specCounter = 0
27
+
28
+ local function makeClass()
29
+ local Class = {}
30
+ Class.__index = Class
31
+ Class.ClassName = "BoundChildCollectionSpecClass"
32
+ function Class.new(inst)
33
+ return setmetatable({ instance = inst }, Class)
34
+ end
35
+ function Class:Destroy() end
36
+ return Class
37
+ end
38
+
39
+ local function setup()
40
+ specCounter += 1
41
+ local suffix = specCounter
42
+
43
+ local serviceBag = ServiceBag.new()
44
+ local container = Instance.new("Folder")
45
+ container.Name = "BoundChildCollectionSpecContainer"
46
+ container.Parent = workspace
47
+
48
+ local instances = {}
49
+ local cleanups = {}
50
+ local booted = false
51
+
52
+ local binder = Binder.new(string.format("BoundChildCollectionSpecTag_%d", suffix), makeClass() :: any)
53
+
54
+ local function newInstance(parent: Instance?): Instance
55
+ local inst = Instance.new("Folder")
56
+ inst.Parent = parent or container
57
+ table.insert(instances, inst)
58
+ return inst
59
+ end
60
+
61
+ local function track(item: any): any
62
+ table.insert(cleanups, item)
63
+ return item
64
+ end
65
+
66
+ local function boot()
67
+ assert(not booted, "Already booted")
68
+ booted = true
69
+
70
+ local provider = BinderProvider.new(string.format("BoundChildCollectionSpecProvider_%d", suffix), function(self)
71
+ self:Add(binder)
72
+ end)
73
+ serviceBag:GetService(provider)
74
+ serviceBag:Init()
75
+ serviceBag:Start()
76
+ end
77
+
78
+ return {
79
+ container = container,
80
+ binder = binder,
81
+ newInstance = newInstance,
82
+ track = track,
83
+ boot = boot,
84
+ destroy = function()
85
+ for _, item in cleanups do
86
+ pcall(function()
87
+ item:Destroy()
88
+ end)
89
+ end
90
+ serviceBag:Destroy()
91
+ for _, inst in instances do
92
+ pcall(function()
93
+ inst:Destroy()
94
+ end)
95
+ end
96
+ container:Destroy()
97
+ end,
98
+ }
99
+ end
100
+
101
+ describe("BoundChildCollection construction", function()
102
+ it("counts children bound before construction without firing ClassAdded", function()
103
+ local env = setup()
104
+
105
+ local parent = env.newInstance()
106
+ local childA = env.newInstance(parent)
107
+ local childB = env.newInstance(parent)
108
+ env.newInstance(parent) -- unbound child
109
+
110
+ env.binder:Tag(childA)
111
+ env.binder:Tag(childB)
112
+ env.boot()
113
+
114
+ local fired = 0
115
+ local collection = env.track(BoundChildCollection.new(env.binder, parent))
116
+ collection.ClassAdded:Connect(function()
117
+ fired += 1
118
+ end)
119
+
120
+ expect(collection:GetSize()).toEqual(2)
121
+ expect(fired).toEqual(0)
122
+ expect(#collection:GetClasses()).toEqual(2)
123
+ expect(collection:HasClass(env.binder:Get(childA))).toEqual(true)
124
+
125
+ env.destroy()
126
+ end)
127
+ end)
128
+
129
+ describe("BoundChildCollection dynamic updates", function()
130
+ it("fires ClassAdded when a bound child is reparented in", function()
131
+ local env = setup()
132
+
133
+ local parent = env.newInstance()
134
+ env.boot()
135
+
136
+ local collection = env.track(BoundChildCollection.new(env.binder, parent))
137
+
138
+ -- Bind an instance living elsewhere, then move it under the tracked parent.
139
+ local child = env.newInstance()
140
+ env.binder:Tag(child)
141
+ local ok, class = env.binder:Promise(child):Yield()
142
+ assert(ok, "child never bound")
143
+
144
+ local addedClass
145
+ local conn = collection.ClassAdded:Connect(function(c)
146
+ addedClass = c
147
+ end)
148
+
149
+ child.Parent = parent
150
+ if collection:GetSize() == 0 then
151
+ collection.ClassAdded:Wait()
152
+ end
153
+ conn:Disconnect()
154
+
155
+ expect(addedClass).toEqual(class)
156
+ expect(collection:GetSize()).toEqual(1)
157
+
158
+ env.destroy()
159
+ end)
160
+
161
+ it("fires ClassRemoved when a tracked child is reparented out", function()
162
+ local env = setup()
163
+
164
+ local parent = env.newInstance()
165
+ local child = env.newInstance(parent)
166
+ env.binder:Tag(child)
167
+ env.boot()
168
+
169
+ local collection = env.track(BoundChildCollection.new(env.binder, parent))
170
+ expect(collection:GetSize()).toEqual(1)
171
+
172
+ local removedClass
173
+ local conn = collection.ClassRemoved:Connect(function(c)
174
+ removedClass = c
175
+ end)
176
+
177
+ local class = env.binder:Get(child)
178
+ child.Parent = env.container
179
+ if collection:GetSize() == 1 then
180
+ collection.ClassRemoved:Wait()
181
+ end
182
+ conn:Disconnect()
183
+
184
+ expect(removedClass).toEqual(class)
185
+ expect(collection:GetSize()).toEqual(0)
186
+
187
+ env.destroy()
188
+ end)
189
+
190
+ it("fires ClassRemoved when a tracked child is unbound", function()
191
+ local env = setup()
192
+
193
+ local parent = env.newInstance()
194
+ local child = env.newInstance(parent)
195
+ env.binder:Tag(child)
196
+ env.boot()
197
+
198
+ local collection = env.track(BoundChildCollection.new(env.binder, parent))
199
+ expect(collection:GetSize()).toEqual(1)
200
+
201
+ local conn = collection.ClassRemoved:Connect(function() end)
202
+ env.binder:Untag(child)
203
+ if collection:GetSize() == 1 then
204
+ collection.ClassRemoved:Wait()
205
+ end
206
+ conn:Disconnect()
207
+
208
+ expect(collection:GetSize()).toEqual(0)
209
+
210
+ env.destroy()
211
+ end)
212
+ end)
@@ -0,0 +1,135 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for promiseBoundClass, a thin wrapper that delegates to Binder:Promise after
4
+ validating its arguments.
5
+
6
+ The binder is booted through a ServiceBag; the instance is tagged before start so it binds
7
+ synchronously and the promise resolves immediately.
8
+
9
+ @class promiseBoundClass.spec.lua
10
+ ]]
11
+
12
+ local require = require(script.Parent.loader).load(script)
13
+
14
+ local Binder = require("Binder")
15
+ local BinderProvider = require("BinderProvider")
16
+ local Jest = require("Jest")
17
+ local ServiceBag = require("ServiceBag")
18
+ local promiseBoundClass = require("promiseBoundClass")
19
+
20
+ local describe = Jest.Globals.describe
21
+ local expect = Jest.Globals.expect
22
+ local it = Jest.Globals.it
23
+
24
+ local specCounter = 0
25
+
26
+ local function makeClass()
27
+ local Class = {}
28
+ Class.__index = Class
29
+ Class.ClassName = "PromiseBoundClassSpecClass"
30
+ function Class.new(inst)
31
+ return setmetatable({ instance = inst }, Class)
32
+ end
33
+ function Class:Destroy() end
34
+ return Class
35
+ end
36
+
37
+ local function setup()
38
+ specCounter += 1
39
+ local suffix = specCounter
40
+
41
+ local serviceBag = ServiceBag.new()
42
+ local container = Instance.new("Folder")
43
+ container.Name = "PromiseBoundClassSpecContainer"
44
+ container.Parent = workspace
45
+
46
+ local instances = {}
47
+ local booted = false
48
+
49
+ local binder = Binder.new(string.format("PromiseBoundClassSpecTag_%d", suffix), makeClass() :: any)
50
+
51
+ local function newInstance(): Instance
52
+ local inst = Instance.new("Folder")
53
+ inst.Parent = container
54
+ table.insert(instances, inst)
55
+ return inst
56
+ end
57
+
58
+ local function boot()
59
+ assert(not booted, "Already booted")
60
+ booted = true
61
+
62
+ local provider = BinderProvider.new(string.format("PromiseBoundClassSpecProvider_%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
+ binder = binder,
72
+ newInstance = newInstance,
73
+ boot = boot,
74
+ destroy = function()
75
+ serviceBag:Destroy()
76
+ for _, inst in instances do
77
+ pcall(function()
78
+ inst:Destroy()
79
+ end)
80
+ end
81
+ container:Destroy()
82
+ end,
83
+ }
84
+ end
85
+
86
+ describe("promiseBoundClass()", function()
87
+ it("resolves with the bound class", function()
88
+ local env = setup()
89
+
90
+ local inst = env.newInstance()
91
+ env.binder:Tag(inst)
92
+ env.boot()
93
+
94
+ local ok, class = promiseBoundClass(env.binder, inst):Yield()
95
+ assert(ok, "Never bound")
96
+ expect(class).toEqual(env.binder:Get(inst))
97
+
98
+ env.destroy()
99
+ end)
100
+
101
+ it("resolves once an instance is bound after start", function()
102
+ local env = setup()
103
+
104
+ env.boot()
105
+ local inst = env.newInstance()
106
+ env.binder:Tag(inst)
107
+
108
+ local ok = promiseBoundClass(env.binder, inst):Yield()
109
+ expect(ok).toEqual(true)
110
+
111
+ env.destroy()
112
+ end)
113
+
114
+ it("throws when the binder is not a binder", function()
115
+ local env = setup()
116
+ env.boot()
117
+
118
+ expect(function()
119
+ promiseBoundClass({} :: any, env.newInstance())
120
+ end).toThrow()
121
+
122
+ env.destroy()
123
+ end)
124
+
125
+ it("throws when the instance is not an Instance", function()
126
+ local env = setup()
127
+ env.boot()
128
+
129
+ expect(function()
130
+ promiseBoundClass(env.binder, 5 :: any)
131
+ end).toThrow()
132
+
133
+ env.destroy()
134
+ end)
135
+ end)
@@ -0,0 +1,175 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for BoundAncestorTracker, which exposes the bound class of a child's nearest bound
4
+ ancestor.
5
+
6
+ The binder is booted through a ServiceBag; the tracker is a plain BaseObject constructed
7
+ directly. Instances live under a workspace container so ancestry and CollectionService signals
8
+ fire. Ancestry changes and late binds are awaited event-driven via the tracker's Class.Changed
9
+ signal rather than a fixed sleep.
10
+
11
+ @class BoundAncestorTracker.spec.lua
12
+ ]]
13
+
14
+ local require = require(script.Parent.loader).load(script)
15
+
16
+ local Binder = require("Binder")
17
+ local BinderProvider = require("BinderProvider")
18
+ local BoundAncestorTracker = require("BoundAncestorTracker")
19
+ local Jest = require("Jest")
20
+ local ServiceBag = require("ServiceBag")
21
+
22
+ local describe = Jest.Globals.describe
23
+ local expect = Jest.Globals.expect
24
+ local it = Jest.Globals.it
25
+
26
+ local specCounter = 0
27
+
28
+ local function makeClass()
29
+ local Class = {}
30
+ Class.__index = Class
31
+ Class.ClassName = "BoundAncestorTrackerSpecClass"
32
+ function Class.new(inst)
33
+ return setmetatable({ instance = inst }, Class)
34
+ end
35
+ function Class:Destroy() end
36
+ return Class
37
+ end
38
+
39
+ local function setup()
40
+ specCounter += 1
41
+ local suffix = specCounter
42
+
43
+ local serviceBag = ServiceBag.new()
44
+ local container = Instance.new("Folder")
45
+ container.Name = "BoundAncestorTrackerSpecContainer"
46
+ container.Parent = workspace
47
+
48
+ local instances = {}
49
+ local cleanups = {}
50
+ local booted = false
51
+
52
+ local binder = Binder.new(string.format("BoundAncestorTrackerSpecTag_%d", suffix), makeClass() :: any)
53
+
54
+ local function newInstance(parent: Instance?): Instance
55
+ local inst = Instance.new("Folder")
56
+ inst.Parent = parent or container
57
+ table.insert(instances, inst)
58
+ return inst
59
+ end
60
+
61
+ local function track(item: any): any
62
+ table.insert(cleanups, item)
63
+ return item
64
+ end
65
+
66
+ local function boot()
67
+ assert(not booted, "Already booted")
68
+ booted = true
69
+
70
+ local provider = BinderProvider.new(string.format("BoundAncestorTrackerSpecProvider_%d", suffix), function(self)
71
+ self:Add(binder)
72
+ end)
73
+ serviceBag:GetService(provider)
74
+ serviceBag:Init()
75
+ serviceBag:Start()
76
+ end
77
+
78
+ local function awaitChange(tracker, previous)
79
+ if tracker.Class.Value == previous then
80
+ tracker.Class.Changed:Wait()
81
+ end
82
+ return tracker.Class.Value
83
+ end
84
+
85
+ return {
86
+ container = container,
87
+ binder = binder,
88
+ newInstance = newInstance,
89
+ track = track,
90
+ boot = boot,
91
+ awaitChange = awaitChange,
92
+ destroy = function()
93
+ for _, item in cleanups do
94
+ pcall(function()
95
+ item:Destroy()
96
+ end)
97
+ end
98
+ serviceBag:Destroy()
99
+ for _, inst in instances do
100
+ pcall(function()
101
+ inst:Destroy()
102
+ end)
103
+ end
104
+ container:Destroy()
105
+ end,
106
+ }
107
+ end
108
+
109
+ describe("BoundAncestorTracker tracking", function()
110
+ it("exposes the nearest bound ancestor's class", function()
111
+ local env = setup()
112
+
113
+ local grandparent = env.newInstance()
114
+ local parent = env.newInstance(grandparent)
115
+ local child = env.newInstance(parent)
116
+ env.binder:Tag(grandparent)
117
+ env.boot()
118
+
119
+ local tracker = env.track(BoundAncestorTracker.new(env.binder, child))
120
+ expect(tracker.Class.Value).toEqual(env.binder:Get(grandparent))
121
+
122
+ env.destroy()
123
+ end)
124
+
125
+ it("has no value when no ancestor is bound", function()
126
+ local env = setup()
127
+
128
+ local parent = env.newInstance()
129
+ local child = env.newInstance(parent)
130
+ env.boot()
131
+
132
+ local tracker = env.track(BoundAncestorTracker.new(env.binder, child))
133
+ expect(tracker.Class.Value).toBeNil()
134
+
135
+ env.destroy()
136
+ end)
137
+
138
+ it("updates when an ancestor becomes bound", function()
139
+ local env = setup()
140
+
141
+ -- The tracker resolves ancestors above the child's direct parent, so bind the grandparent.
142
+ local ancestor = env.newInstance()
143
+ local parent = env.newInstance(ancestor)
144
+ local child = env.newInstance(parent)
145
+ env.boot()
146
+
147
+ local tracker = env.track(BoundAncestorTracker.new(env.binder, child))
148
+ expect(tracker.Class.Value).toBeNil()
149
+
150
+ env.binder:Tag(ancestor)
151
+ local value = env.awaitChange(tracker, nil)
152
+ expect(value).toEqual(env.binder:Get(ancestor))
153
+
154
+ env.destroy()
155
+ end)
156
+
157
+ it("clears the value when the child leaves the bound ancestry", function()
158
+ local env = setup()
159
+
160
+ local ancestor = env.newInstance()
161
+ local parent = env.newInstance(ancestor)
162
+ local child = env.newInstance(parent)
163
+ env.binder:Tag(ancestor)
164
+ env.boot()
165
+
166
+ local tracker = env.track(BoundAncestorTracker.new(env.binder, child))
167
+ local class = env.binder:Get(ancestor)
168
+ expect(tracker.Class.Value).toEqual(class)
169
+
170
+ child.Parent = env.container
171
+ expect(env.awaitChange(tracker, class)).toBeNil()
172
+
173
+ env.destroy()
174
+ end)
175
+ end)
@@ -0,0 +1,167 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for BoundParentTracker, which exposes the bound class on a child's direct parent.
4
+
5
+ The binder is booted through a ServiceBag; the tracker is a plain BaseObject constructed
6
+ directly. Instances live under a workspace container so parent-change and CollectionService
7
+ signals fire. Parent changes and unbinds are awaited event-driven via the tracker's
8
+ Class.Changed signal rather than a fixed sleep.
9
+
10
+ @class BoundParentTracker.spec.lua
11
+ ]]
12
+
13
+ local require = require(script.Parent.loader).load(script)
14
+
15
+ local Binder = require("Binder")
16
+ local BinderProvider = require("BinderProvider")
17
+ local BoundParentTracker = require("BoundParentTracker")
18
+ local Jest = require("Jest")
19
+ local ServiceBag = require("ServiceBag")
20
+
21
+ local describe = Jest.Globals.describe
22
+ local expect = Jest.Globals.expect
23
+ local it = Jest.Globals.it
24
+
25
+ local specCounter = 0
26
+
27
+ local function makeClass()
28
+ local Class = {}
29
+ Class.__index = Class
30
+ Class.ClassName = "BoundParentTrackerSpecClass"
31
+ function Class.new(inst)
32
+ return setmetatable({ instance = inst }, Class)
33
+ end
34
+ function Class:Destroy() end
35
+ return Class
36
+ end
37
+
38
+ local function setup()
39
+ specCounter += 1
40
+ local suffix = specCounter
41
+
42
+ local serviceBag = ServiceBag.new()
43
+ local container = Instance.new("Folder")
44
+ container.Name = "BoundParentTrackerSpecContainer"
45
+ container.Parent = workspace
46
+
47
+ local instances = {}
48
+ local cleanups = {}
49
+ local booted = false
50
+
51
+ local binder = Binder.new(string.format("BoundParentTrackerSpecTag_%d", suffix), makeClass() :: any)
52
+
53
+ local function newInstance(parent: Instance?): Instance
54
+ local inst = Instance.new("Folder")
55
+ inst.Parent = parent or container
56
+ table.insert(instances, inst)
57
+ return inst
58
+ end
59
+
60
+ local function track(item: any): any
61
+ table.insert(cleanups, item)
62
+ return item
63
+ end
64
+
65
+ local function boot()
66
+ assert(not booted, "Already booted")
67
+ booted = true
68
+
69
+ local provider = BinderProvider.new(string.format("BoundParentTrackerSpecProvider_%d", suffix), function(self)
70
+ self:Add(binder)
71
+ end)
72
+ serviceBag:GetService(provider)
73
+ serviceBag:Init()
74
+ serviceBag:Start()
75
+ end
76
+
77
+ -- Blocks until the tracker's value moves off `previous`.
78
+ local function awaitChange(tracker, previous)
79
+ if tracker.Class.Value == previous then
80
+ tracker.Class.Changed:Wait()
81
+ end
82
+ return tracker.Class.Value
83
+ end
84
+
85
+ return {
86
+ container = container,
87
+ binder = binder,
88
+ newInstance = newInstance,
89
+ track = track,
90
+ boot = boot,
91
+ awaitChange = awaitChange,
92
+ destroy = function()
93
+ for _, item in cleanups do
94
+ pcall(function()
95
+ item:Destroy()
96
+ end)
97
+ end
98
+ serviceBag:Destroy()
99
+ for _, inst in instances do
100
+ pcall(function()
101
+ inst:Destroy()
102
+ end)
103
+ end
104
+ container:Destroy()
105
+ end,
106
+ }
107
+ end
108
+
109
+ describe("BoundParentTracker.new()", function()
110
+ it("throws without a binder or child", function()
111
+ expect(function()
112
+ BoundParentTracker.new(nil :: any, Instance.new("Folder"))
113
+ end).toThrow()
114
+ end)
115
+ end)
116
+
117
+ describe("BoundParentTracker tracking", function()
118
+ it("exposes the bound class of the direct parent", function()
119
+ local env = setup()
120
+
121
+ local parent = env.newInstance()
122
+ local child = env.newInstance(parent)
123
+ env.binder:Tag(parent)
124
+ env.boot()
125
+
126
+ local tracker = env.track(BoundParentTracker.new(env.binder, child))
127
+ expect(tracker.Class.Value).toEqual(env.binder:Get(parent))
128
+
129
+ env.destroy()
130
+ end)
131
+
132
+ it("clears the value when the child is reparented off the bound parent", function()
133
+ local env = setup()
134
+
135
+ local parent = env.newInstance()
136
+ local child = env.newInstance(parent)
137
+ env.binder:Tag(parent)
138
+ env.boot()
139
+
140
+ local tracker = env.track(BoundParentTracker.new(env.binder, child))
141
+ local class = env.binder:Get(parent)
142
+ expect(tracker.Class.Value).toEqual(class)
143
+
144
+ child.Parent = env.container
145
+ expect(env.awaitChange(tracker, class)).toBeNil()
146
+
147
+ env.destroy()
148
+ end)
149
+
150
+ it("clears the value when the parent's class is unbound", function()
151
+ local env = setup()
152
+
153
+ local parent = env.newInstance()
154
+ local child = env.newInstance(parent)
155
+ env.binder:Tag(parent)
156
+ env.boot()
157
+
158
+ local tracker = env.track(BoundParentTracker.new(env.binder, child))
159
+ local class = env.binder:Get(parent)
160
+ expect(tracker.Class.Value).toEqual(class)
161
+
162
+ env.binder:Untag(parent)
163
+ expect(env.awaitChange(tracker, class)).toBeNil()
164
+
165
+ env.destroy()
166
+ end)
167
+ end)