@quenty/playerutils 8.40.2 → 8.41.1

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [8.41.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerutils@8.41.0...@quenty/playerutils@8.41.1) (2026-08-14)
7
+
8
+ **Note:** Version bump only for package @quenty/playerutils
9
+
10
+ # [8.41.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerutils@8.40.2...@quenty/playerutils@8.41.0) (2026-07-28)
11
+
12
+ ### Features
13
+
14
+ - Mock player utils + Player mock ([4caed96](https://github.com/Quenty/NevermoreEngine/commit/4caed968ad05b1bce254fbafb0998a7a1b207f29))
15
+
6
16
  ## [8.40.2](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerutils@8.40.1...@quenty/playerutils@8.40.2) (2026-07-27)
7
17
 
8
18
  **Note:** Version bump only for package @quenty/playerutils
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 109313722555246,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/playerutils",
3
- "version": "8.40.2",
3
+ "version": "8.41.1",
4
4
  "description": "Player utility functions",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -31,14 +31,16 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@quenty/brio": "14.35.1",
35
- "@quenty/characterutils": "12.40.2",
36
- "@quenty/instanceutils": "13.35.1",
37
- "@quenty/loader": "10.11.0",
38
- "@quenty/maid": "3.11.0",
39
- "@quenty/playermock": "1.5.2",
40
- "@quenty/promise": "10.23.0",
41
- "@quenty/rx": "13.33.0"
34
+ "@quenty/brio": "14.35.2",
35
+ "@quenty/characterutils": "12.41.1",
36
+ "@quenty/instanceutils": "13.35.2",
37
+ "@quenty/loader": "10.11.1",
38
+ "@quenty/maid": "3.11.1",
39
+ "@quenty/nevermore-test-runner": "1.5.1",
40
+ "@quenty/playermock": "1.6.1",
41
+ "@quenty/promise": "10.23.1",
42
+ "@quenty/rx": "13.33.1",
43
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
42
44
  },
43
- "gitHead": "b2fa2984c2ea6e83136e5a7cbf57b14038d9a265"
45
+ "gitHead": "f084360c3729ea424d49d9bc42f8f0d1c128fa82"
44
46
  }
@@ -5,6 +5,8 @@
5
5
 
6
6
  local require = require(script.Parent.loader).load(script)
7
7
 
8
+ local Players = game:GetService("Players")
9
+
8
10
  local PlayerMock = require("PlayerMock")
9
11
  local Promise = require("Promise")
10
12
 
@@ -23,10 +25,10 @@ local PlayerUtils = {}
23
25
  @return string -- Formatted name
24
26
  ]=]
25
27
  function PlayerUtils.formatName(player: Player): string
26
- assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
28
+ assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
27
29
 
28
30
  local name = player.Name
29
- local displayName = player.DisplayName
31
+ local displayName = if PlayerMock.isMock(player) then PlayerMock.read(player, "DisplayName") else player.DisplayName
30
32
 
31
33
  return PlayerUtils.formatDisplayName(name, displayName)
32
34
  end
@@ -134,18 +136,20 @@ function PlayerUtils.promiseLoadCharacter(player: Player): Promise.Promise<Model
134
136
  assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
135
137
 
136
138
  return Promise.spawn(function(resolve, reject)
139
+ local character: Model?
137
140
  local ok, err = pcall(function()
138
141
  if PlayerMock.isMock(player) then
139
- PlayerMock.loadCharacterAsync(player)
142
+ character = PlayerMock.loadCharacterAsync(player)
140
143
  else
141
144
  player:LoadCharacterAsync()
145
+ character = player.Character
142
146
  end
143
147
  end)
144
148
  if not ok then
145
149
  return reject(err or "Failed to load character")
146
150
  end
147
151
 
148
- return resolve()
152
+ return resolve(character)
149
153
  end)
150
154
  end
151
155
 
@@ -160,21 +164,32 @@ function PlayerUtils.promiseLoadCharacterWithHumanoidDescription(
160
164
  player: Player,
161
165
  humanoidDescription: HumanoidDescription
162
166
  ): Promise.Promise<Model>
163
- assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
167
+ assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
164
168
  assert(
165
169
  typeof(humanoidDescription) == "Instance" and humanoidDescription:IsA("HumanoidDescription"),
166
170
  "Bad humanoidDescription"
167
171
  )
168
172
 
169
173
  return Promise.spawn(function(resolve, reject)
174
+ local character: Model?
170
175
  local ok, err = pcall(function()
171
- player:LoadCharacterWithHumanoidDescriptionAsync(humanoidDescription)
176
+ if PlayerMock.isMock(player) then
177
+ -- The engine builds the rig from the description before spawning it; the mock's spawn
178
+ -- path takes the built rig, which is the same split the engine call does internally.
179
+ character = PlayerMock.loadCharacterAsync(
180
+ player,
181
+ Players:CreateHumanoidModelFromDescription(humanoidDescription, Enum.HumanoidRigType.R15)
182
+ )
183
+ else
184
+ player:LoadCharacterWithHumanoidDescriptionAsync(humanoidDescription)
185
+ character = player.Character
186
+ end
172
187
  end)
173
188
  if not ok then
174
189
  return reject(err or "Failed to load character")
175
190
  end
176
191
 
177
- return resolve()
192
+ return resolve(character)
178
193
  end)
179
194
  end
180
195
 
@@ -0,0 +1,169 @@
1
+ --!strict
2
+ local require = require(script.Parent.loader).load(script)
3
+
4
+ local Players = game:GetService("Players")
5
+
6
+ local Jest = require("Jest")
7
+ local Maid = require("Maid")
8
+ local PlayerMock = require("PlayerMock")
9
+ local PlayerUtils = require("PlayerUtils")
10
+ local PromiseTestUtils = require("PromiseTestUtils")
11
+
12
+ local describe = Jest.Globals.describe
13
+ local expect = Jest.Globals.expect
14
+ local it = Jest.Globals.it
15
+
16
+ local function setup(): any
17
+ local maid = Maid.new()
18
+
19
+ local controller = {
20
+ newPlayer = function(overrides: { [string]: any }?): Player
21
+ local player = PlayerMock.new(overrides)
22
+ player.Parent = Players
23
+ maid:GiveTask(player)
24
+ return player
25
+ end,
26
+ destroy = function()
27
+ maid:DoCleaning()
28
+ end,
29
+ }
30
+
31
+ return controller
32
+ end
33
+
34
+ describe("PlayerUtils.formatDisplayName", function()
35
+ it("returns the display name when it matches the username", function()
36
+ expect(PlayerUtils.formatDisplayName("oot", "oot")).toBe("oot")
37
+ end)
38
+
39
+ it("ignores case when comparing", function()
40
+ expect(PlayerUtils.formatDisplayName("MARTXN", "martxn")).toBe("martxn")
41
+ end)
42
+
43
+ it("qualifies the display name with the username when they differ", function()
44
+ expect(PlayerUtils.formatDisplayName("martxn", "oot")).toBe("oot (@martxn)")
45
+ end)
46
+ end)
47
+
48
+ describe("PlayerUtils.formatDisplayNameFromUserInfo", function()
49
+ it("formats without a badge", function()
50
+ expect(PlayerUtils.formatDisplayNameFromUserInfo({
51
+ Username = "martxn",
52
+ DisplayName = "oot",
53
+ HasVerifiedBadge = false,
54
+ })).toBe("oot (@martxn)")
55
+ end)
56
+
57
+ it("appends the verified badge", function()
58
+ expect(PlayerUtils.formatDisplayNameFromUserInfo({
59
+ Username = "martxn",
60
+ DisplayName = "oot",
61
+ HasVerifiedBadge = true,
62
+ })).toBe(PlayerUtils.addVerifiedBadgeToName("oot (@martxn)"))
63
+ end)
64
+
65
+ it("rejects a malformed userInfo", function()
66
+ expect(function()
67
+ PlayerUtils.formatDisplayNameFromUserInfo({ Username = "martxn" } :: any)
68
+ end).toThrow()
69
+ end)
70
+ end)
71
+
72
+ describe("PlayerUtils.getDefaultNameColor", function()
73
+ it("is stable for the same name", function()
74
+ expect(PlayerUtils.getDefaultNameColor("oot")).toEqual(PlayerUtils.getDefaultNameColor("oot"))
75
+ end)
76
+
77
+ it("returns a Color3", function()
78
+ expect(typeof(PlayerUtils.getDefaultNameColor("oot"))).toBe("Color3")
79
+ end)
80
+ end)
81
+
82
+ describe("PlayerUtils.formatName", function()
83
+ it("returns the mock's name when its display name stand-in matches", function()
84
+ local controller = setup()
85
+
86
+ local player = controller.newPlayer({ UserId = 8675309 })
87
+ expect(PlayerUtils.formatName(player)).toBe(player.Name)
88
+
89
+ controller.destroy()
90
+ end)
91
+
92
+ it("qualifies the mock's display name stand-in with its name", function()
93
+ local controller = setup()
94
+
95
+ local player = controller.newPlayer({ UserId = 8675309, DisplayName = "oot" })
96
+ expect(PlayerUtils.formatName(player)).toBe(string.format("oot (@%s)", player.Name))
97
+
98
+ controller.destroy()
99
+ end)
100
+
101
+ it("follows a display name written mid-test", function()
102
+ local controller = setup()
103
+
104
+ local player = controller.newPlayer({ UserId = 8675309 })
105
+ PlayerMock.write(player, "DisplayName", "oot")
106
+ expect(PlayerUtils.formatName(player)).toBe(string.format("oot (@%s)", player.Name))
107
+
108
+ controller.destroy()
109
+ end)
110
+
111
+ it("rejects an instance that is neither a Player nor a mock", function()
112
+ expect(function()
113
+ PlayerUtils.formatName(Instance.new("Folder") :: any)
114
+ end).toThrow()
115
+ end)
116
+ end)
117
+
118
+ describe("PlayerUtils.promiseLoadCharacter", function()
119
+ it("spawns the mock's character and resolves it", function()
120
+ local controller = setup()
121
+
122
+ local player = controller.newPlayer({ UserId = 8675309 })
123
+ local outcome, character = PromiseTestUtils.awaitOutcome(PlayerUtils.promiseLoadCharacter(player), 30)
124
+
125
+ expect(outcome).toBe("resolved")
126
+ expect(character).toBe(PlayerMock.read(player, "Character"))
127
+ expect(character.Parent).toBe(workspace)
128
+
129
+ controller.destroy()
130
+ end)
131
+
132
+ it("rejects an instance that is neither a Player nor a mock", function()
133
+ expect(function()
134
+ PlayerUtils.promiseLoadCharacter(Instance.new("Folder") :: any)
135
+ end).toThrow()
136
+ end)
137
+ end)
138
+
139
+ describe("PlayerUtils.promiseLoadCharacterWithHumanoidDescription", function()
140
+ it("spawns a rig built from the description and resolves it", function()
141
+ local controller = setup()
142
+
143
+ local player = controller.newPlayer({ UserId = 8675309 })
144
+ local description = Instance.new("HumanoidDescription")
145
+
146
+ local outcome, character = PromiseTestUtils.awaitOutcome(
147
+ PlayerUtils.promiseLoadCharacterWithHumanoidDescription(player, description),
148
+ 30
149
+ )
150
+
151
+ expect(outcome).toBe("resolved")
152
+ expect(character).toBe(PlayerMock.read(player, "Character"))
153
+ expect(character:FindFirstChildOfClass("Humanoid")).never.toBeNil()
154
+
155
+ description:Destroy()
156
+ controller.destroy()
157
+ end)
158
+
159
+ it("rejects a bad humanoidDescription", function()
160
+ local controller = setup()
161
+
162
+ local player = controller.newPlayer({ UserId = 8675309 })
163
+ expect(function()
164
+ PlayerUtils.promiseLoadCharacterWithHumanoidDescription(player, Instance.new("Folder") :: any)
165
+ end).toThrow()
166
+
167
+ controller.destroy()
168
+ end)
169
+ end)
@@ -11,6 +11,8 @@ local Players = game:GetService("Players")
11
11
  local Brio = require("Brio")
12
12
  local Maid = require("Maid")
13
13
  local Observable = require("Observable")
14
+ local PlayerMock = require("PlayerMock")
15
+ local PlayerMockUtils = require("PlayerMockUtils")
14
16
  local Rx = require("Rx")
15
17
  local RxBrioUtils = require("RxBrioUtils")
16
18
  local RxCharacterUtils = require("RxCharacterUtils")
@@ -38,11 +40,17 @@ function RxPlayerUtils.observePlayersBrio(predicate: Rx.Predicate<Player>?): Obs
38
40
  end
39
41
  end
40
42
 
43
+ local function handleRemoving(player: Player)
44
+ maid[player] = nil
45
+ end
46
+
41
47
  maid:GiveTask(Players.PlayerAdded:Connect(handlePlayer))
48
+ maid:GiveTask(Players.PlayerRemoving:Connect(handleRemoving))
42
49
 
43
- maid:GiveTask(Players.PlayerRemoving:Connect(function(player)
44
- maid[player] = nil
45
- end))
50
+ -- Mocks are invisible to the Players service, so their tag lifecycle is the counterpart of
51
+ -- the join events, feeding the same handlers.
52
+ maid:GiveTask(PlayerMock.getMockAddedSignal():Connect(handlePlayer))
53
+ maid:GiveTask(PlayerMock.getMockRemovingSignal():Connect(handleRemoving))
46
54
 
47
55
  for _, player in Players:GetPlayers() do
48
56
  task.spawn(function()
@@ -50,6 +58,12 @@ function RxPlayerUtils.observePlayersBrio(predicate: Rx.Predicate<Player>?): Obs
50
58
  end)
51
59
  end
52
60
 
61
+ for _, player in PlayerMock.getMocks() do
62
+ task.spawn(function()
63
+ handlePlayer(player)
64
+ end)
65
+ end
66
+
53
67
  return maid
54
68
  end) :: any
55
69
  end
@@ -82,9 +96,20 @@ end
82
96
  @return Observable<Brio<Player>>
83
97
  ]=]
84
98
  function RxPlayerUtils.observeLocalPlayerBrio(): Observable.Observable<Brio.Brio<Player>>
85
- return RxInstanceUtils.observePropertyBrio(Players, "LocalPlayer", function(value)
86
- return value ~= nil
87
- end)
99
+ -- Headless tests have no Players.LocalPlayer; a test designates a PlayerMock instead. Observed
100
+ -- rather than read once, so a designation made after subscribing still resolves.
101
+ return Rx.combineLatest({
102
+ real = RxInstanceUtils.observeProperty(Players, "LocalPlayer"),
103
+ mocked = PlayerMockUtils.observeMockedLocalPlayer(),
104
+ }):Pipe({
105
+ Rx.map(function(state: any): Player?
106
+ return state.real or state.mocked
107
+ end) :: any,
108
+ Rx.distinct() :: any,
109
+ RxBrioUtils.switchToBrio(function(player)
110
+ return player ~= nil
111
+ end) :: any,
112
+ }) :: any
88
113
  end
89
114
 
90
115
  --[=[
@@ -116,6 +141,7 @@ function RxPlayerUtils.observePlayers(predicate: Rx.Predicate<Player>?): Observa
116
141
  end
117
142
 
118
143
  maid:GiveTask(Players.PlayerAdded:Connect(handlePlayer))
144
+ maid:GiveTask(PlayerMock.getMockAddedSignal():Connect(handlePlayer))
119
145
 
120
146
  for _, player in Players:GetPlayers() do
121
147
  task.spawn(function()
@@ -123,6 +149,12 @@ function RxPlayerUtils.observePlayers(predicate: Rx.Predicate<Player>?): Observa
123
149
  end)
124
150
  end
125
151
 
152
+ for _, player in PlayerMock.getMocks() do
153
+ task.spawn(function()
154
+ handlePlayer(player)
155
+ end)
156
+ end
157
+
126
158
  return maid
127
159
  end) :: any
128
160
  end
@@ -136,8 +168,13 @@ end
136
168
  function RxPlayerUtils.observeFirstAppearanceLoaded(player: Player): Observable.Observable<()>
137
169
  assert(typeof(player) == "Instance", "Bad player")
138
170
 
171
+ local isMock = PlayerMock.isMock(player)
172
+
139
173
  return Observable.new(function(sub)
140
- if player:HasAppearanceLoaded() then
174
+ local alreadyLoaded = if isMock
175
+ then PlayerMock.read(player, "HasAppearanceLoaded")
176
+ else player:HasAppearanceLoaded()
177
+ if alreadyLoaded then
141
178
  sub:Fire()
142
179
  sub:Complete()
143
180
  return
@@ -145,18 +182,24 @@ function RxPlayerUtils.observeFirstAppearanceLoaded(player: Player): Observable.
145
182
 
146
183
  local maid = Maid.new()
147
184
 
185
+ local characterAppearanceLoaded: RBXScriptSignal<...any> = if isMock
186
+ then PlayerMock.getSignal(player, "CharacterAppearanceLoaded")
187
+ else player.CharacterAppearanceLoaded
188
+
148
189
  -- In case this works
149
- maid:GiveTask(player.CharacterAppearanceLoaded:Connect(function()
190
+ maid:GiveTask(characterAppearanceLoaded:Connect(function()
150
191
  sub:Fire()
151
192
  sub:Complete()
152
193
  end))
153
194
 
154
195
  maid:GiveTask(task.spawn(function()
155
- while not player:HasAppearanceLoaded() and player:IsDescendantOf(game) do
196
+ local loaded = false
197
+ while not loaded and player:IsDescendantOf(game) do
156
198
  task.wait(0.05)
199
+ loaded = if isMock then PlayerMock.read(player, "HasAppearanceLoaded") else player:HasAppearanceLoaded()
157
200
  end
158
201
 
159
- if player:HasAppearanceLoaded() then
202
+ if loaded then
160
203
  sub:Fire()
161
204
  sub:Complete()
162
205
  else
@@ -0,0 +1,391 @@
1
+ --!strict
2
+ local require = require(script.Parent.loader).load(script)
3
+
4
+ local Players = game:GetService("Players")
5
+
6
+ local Jest = require("Jest")
7
+ local Maid = require("Maid")
8
+ local PlayerMock = require("PlayerMock")
9
+ local PromiseTestUtils = require("PromiseTestUtils")
10
+ local RxPlayerUtils = require("RxPlayerUtils")
11
+
12
+ local describe = Jest.Globals.describe
13
+ local expect = Jest.Globals.expect
14
+ local it = Jest.Globals.it
15
+
16
+ local TEST_USER_ID = 8675309
17
+
18
+ local function isTestPlayer(player: Player): boolean
19
+ return PlayerMock.isMock(player) and PlayerMock.read(player, "UserId") == TEST_USER_ID
20
+ end
21
+
22
+ local function findLivingBrio(brios: { any }, value: any): any
23
+ for _, brio in brios do
24
+ if not brio:IsDead() and brio:GetValue() == value then
25
+ return brio
26
+ end
27
+ end
28
+ return nil
29
+ end
30
+
31
+ local function contains(values: { any }, value: any): boolean
32
+ return table.find(values, value) ~= nil
33
+ end
34
+
35
+ local function setup(): any
36
+ local maid = Maid.new()
37
+
38
+ local controller = {
39
+ newPlayer = function(overrides: { [string]: any }?): Player
40
+ local player = PlayerMock.new(overrides or { UserId = TEST_USER_ID })
41
+ player.Parent = Players
42
+ maid:GiveTask(player)
43
+ return player
44
+ end,
45
+ designateLocalPlayer = function(player: Player?)
46
+ PlayerMock.setMockedLocalPlayer(player)
47
+ maid:GiveTask(function()
48
+ if player ~= nil and PlayerMock.getMockedLocalPlayer() == player then
49
+ PlayerMock.setMockedLocalPlayer(nil)
50
+ end
51
+ end)
52
+ end,
53
+ record = function(observable: any): { any }
54
+ local values = {}
55
+ maid:GiveTask(observable:Subscribe(function(value)
56
+ table.insert(values, value)
57
+ end))
58
+ return values
59
+ end,
60
+ awaitCount = function(values: { any }, count: number): boolean
61
+ return PromiseTestUtils.awaitValue(function()
62
+ return #values >= count
63
+ end)
64
+ end,
65
+ destroy = function()
66
+ maid:DoCleaning()
67
+ end,
68
+ }
69
+
70
+ return controller
71
+ end
72
+
73
+ describe("RxPlayerUtils.observePlayers", function()
74
+ it("emits a mock that is already in the game", function()
75
+ local controller = setup()
76
+
77
+ local player = controller.newPlayer()
78
+ local players = controller.record(RxPlayerUtils.observePlayers())
79
+
80
+ expect(controller.awaitCount(players, 1)).toBe(true)
81
+ expect(contains(players, player)).toBe(true)
82
+
83
+ controller.destroy()
84
+ end)
85
+
86
+ it("emits a mock that joins after subscribing", function()
87
+ local controller = setup()
88
+
89
+ local players = controller.record(RxPlayerUtils.observePlayers())
90
+ local player = controller.newPlayer()
91
+
92
+ expect(controller.awaitCount(players, 1)).toBe(true)
93
+ expect(contains(players, player)).toBe(true)
94
+
95
+ controller.destroy()
96
+ end)
97
+
98
+ it("skips mocks the predicate rejects", function()
99
+ local controller = setup()
100
+
101
+ local rejected = controller.newPlayer({ UserId = TEST_USER_ID + 1 })
102
+ local accepted = controller.newPlayer()
103
+ local players = controller.record(RxPlayerUtils.observePlayers(isTestPlayer))
104
+
105
+ expect(controller.awaitCount(players, 1)).toBe(true)
106
+ expect(contains(players, accepted)).toBe(true)
107
+ expect(contains(players, rejected)).toBe(false)
108
+
109
+ controller.destroy()
110
+ end)
111
+ end)
112
+
113
+ describe("RxPlayerUtils.observePlayersBrio", function()
114
+ it("emits a living brio for a mock already in the game", function()
115
+ local controller = setup()
116
+
117
+ local player = controller.newPlayer()
118
+ local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
119
+
120
+ expect(controller.awaitCount(brios, 1)).toBe(true)
121
+ expect(findLivingBrio(brios, player)).never.toBeNil()
122
+
123
+ controller.destroy()
124
+ end)
125
+
126
+ it("emits a living brio for a mock that joins after subscribing", function()
127
+ local controller = setup()
128
+
129
+ local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
130
+ local player = controller.newPlayer()
131
+
132
+ expect(controller.awaitCount(brios, 1)).toBe(true)
133
+ expect(findLivingBrio(brios, player)).never.toBeNil()
134
+
135
+ controller.destroy()
136
+ end)
137
+
138
+ it("kills the brio when the mock is destroyed", function()
139
+ local controller = setup()
140
+
141
+ local player = controller.newPlayer()
142
+ local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
143
+ expect(controller.awaitCount(brios, 1)).toBe(true)
144
+
145
+ local brio = findLivingBrio(brios, player)
146
+ player:Destroy()
147
+
148
+ expect(PromiseTestUtils.awaitValue(function()
149
+ return brio:IsDead()
150
+ end)).toBe(true)
151
+
152
+ controller.destroy()
153
+ end)
154
+
155
+ it("kills the brio when the mock is kicked", function()
156
+ local controller = setup()
157
+
158
+ local player = controller.newPlayer()
159
+ local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
160
+ expect(controller.awaitCount(brios, 1)).toBe(true)
161
+
162
+ local brio = findLivingBrio(brios, player)
163
+ -- A kick unparents rather than destroys, which is what drops the mock out of the game
164
+ PlayerMock.kick(player, "Kicked by a test")
165
+
166
+ expect(PromiseTestUtils.awaitValue(function()
167
+ return brio:IsDead()
168
+ end)).toBe(true)
169
+
170
+ controller.destroy()
171
+ end)
172
+
173
+ it("skips mocks the predicate rejects", function()
174
+ local controller = setup()
175
+
176
+ local rejected = controller.newPlayer({ UserId = TEST_USER_ID + 1 })
177
+ controller.newPlayer()
178
+ local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
179
+
180
+ expect(controller.awaitCount(brios, 1)).toBe(true)
181
+ expect(findLivingBrio(brios, rejected)).toBeNil()
182
+
183
+ controller.destroy()
184
+ end)
185
+ end)
186
+
187
+ describe("RxPlayerUtils.observeCharactersBrio", function()
188
+ it("emits a brio holding the mock's spawned character", function()
189
+ local controller = setup()
190
+
191
+ local player = controller.newPlayer()
192
+ local brios = controller.record(RxPlayerUtils.observeCharactersBrio())
193
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
194
+
195
+ expect(controller.awaitCount(brios, 1)).toBe(true)
196
+ expect(findLivingBrio(brios, character)).never.toBeNil()
197
+
198
+ controller.destroy()
199
+ end)
200
+
201
+ it("kills the brio when the character despawns", function()
202
+ local controller = setup()
203
+
204
+ local player = controller.newPlayer()
205
+ local brios = controller.record(RxPlayerUtils.observeCharactersBrio())
206
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
207
+ expect(controller.awaitCount(brios, 1)).toBe(true)
208
+
209
+ local brio = findLivingBrio(brios, character)
210
+ PlayerMock.removeCharacter(player)
211
+
212
+ expect(PromiseTestUtils.awaitValue(function()
213
+ return brio:IsDead()
214
+ end)).toBe(true)
215
+
216
+ controller.destroy()
217
+ end)
218
+ end)
219
+
220
+ describe("RxPlayerUtils.observeHumanoidsBrio", function()
221
+ it("emits a brio holding the mock character's humanoid", function()
222
+ local controller = setup()
223
+
224
+ local player = controller.newPlayer()
225
+ local brios = controller.record(RxPlayerUtils.observeHumanoidsBrio())
226
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
227
+
228
+ expect(controller.awaitCount(brios, 1)).toBe(true)
229
+ expect(findLivingBrio(brios, character:FindFirstChildOfClass("Humanoid"))).never.toBeNil()
230
+
231
+ controller.destroy()
232
+ end)
233
+ end)
234
+
235
+ describe("RxPlayerUtils.observeLocalPlayerBrio", function()
236
+ it("emits nothing while no mock is designated", function()
237
+ local controller = setup()
238
+
239
+ controller.newPlayer()
240
+ local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
241
+
242
+ expect(#brios).toBe(0)
243
+
244
+ controller.destroy()
245
+ end)
246
+
247
+ it("emits a living brio for a mock designated before subscribing", function()
248
+ local controller = setup()
249
+
250
+ local player = controller.newPlayer()
251
+ controller.designateLocalPlayer(player)
252
+ local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
253
+
254
+ expect(controller.awaitCount(brios, 1)).toBe(true)
255
+ expect(brios[1]:GetValue()).toBe(player)
256
+
257
+ controller.destroy()
258
+ end)
259
+
260
+ it("emits a living brio for a mock designated after subscribing", function()
261
+ local controller = setup()
262
+
263
+ local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
264
+ local player = controller.newPlayer()
265
+ controller.designateLocalPlayer(player)
266
+
267
+ expect(controller.awaitCount(brios, 1)).toBe(true)
268
+ expect(brios[1]:GetValue()).toBe(player)
269
+
270
+ controller.destroy()
271
+ end)
272
+
273
+ it("kills the brio when the designation is cleared", function()
274
+ local controller = setup()
275
+
276
+ local player = controller.newPlayer()
277
+ controller.designateLocalPlayer(player)
278
+ local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
279
+ expect(controller.awaitCount(brios, 1)).toBe(true)
280
+
281
+ PlayerMock.setMockedLocalPlayer(nil)
282
+
283
+ expect(PromiseTestUtils.awaitValue(function()
284
+ return brios[1]:IsDead()
285
+ end)).toBe(true)
286
+
287
+ controller.destroy()
288
+ end)
289
+ end)
290
+
291
+ describe("RxPlayerUtils.observeLocalPlayerHumanoidBrio", function()
292
+ it("emits a brio holding the designated mock's humanoid", function()
293
+ local controller = setup()
294
+
295
+ local player = controller.newPlayer()
296
+ controller.designateLocalPlayer(player)
297
+ local brios = controller.record(RxPlayerUtils.observeLocalPlayerHumanoidBrio())
298
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
299
+
300
+ expect(controller.awaitCount(brios, 1)).toBe(true)
301
+ expect(findLivingBrio(brios, character:FindFirstChildOfClass("Humanoid"))).never.toBeNil()
302
+
303
+ controller.destroy()
304
+ end)
305
+ end)
306
+
307
+ describe("RxPlayerUtils.observeFirstAppearanceLoaded", function()
308
+ it("does not fire before the mock has spawned", function()
309
+ local controller = setup()
310
+
311
+ local player = controller.newPlayer()
312
+ local fires = controller.record(RxPlayerUtils.observeFirstAppearanceLoaded(player))
313
+
314
+ expect(#fires).toBe(0)
315
+
316
+ controller.destroy()
317
+ end)
318
+
319
+ it("fires once the mock spawns", function()
320
+ local controller = setup()
321
+
322
+ local player = controller.newPlayer()
323
+ local fired = 0
324
+ local completed = false
325
+ local maid = Maid.new()
326
+ maid:GiveTask(RxPlayerUtils.observeFirstAppearanceLoaded(player):Subscribe(
327
+ function()
328
+ fired += 1
329
+ end,
330
+ nil,
331
+ function()
332
+ completed = true
333
+ end
334
+ ))
335
+
336
+ PlayerMock.loadMinimalCharacterAsync(player)
337
+
338
+ expect(PromiseTestUtils.awaitValue(function()
339
+ return fired == 1 and completed
340
+ end)).toBe(true)
341
+
342
+ maid:DoCleaning()
343
+ controller.destroy()
344
+ end)
345
+
346
+ it("fires immediately when the mock has already spawned", function()
347
+ local controller = setup()
348
+
349
+ local player = controller.newPlayer()
350
+ PlayerMock.loadMinimalCharacterAsync(player)
351
+
352
+ local fired = 0
353
+ local completed = false
354
+ local maid = Maid.new()
355
+ maid:GiveTask(RxPlayerUtils.observeFirstAppearanceLoaded(player):Subscribe(
356
+ function()
357
+ fired += 1
358
+ end,
359
+ nil,
360
+ function()
361
+ completed = true
362
+ end
363
+ ))
364
+
365
+ expect(fired).toBe(1)
366
+ expect(completed).toBe(true)
367
+
368
+ maid:DoCleaning()
369
+ controller.destroy()
370
+ end)
371
+
372
+ it("fails when the mock leaves before spawning", function()
373
+ local controller = setup()
374
+
375
+ local player = controller.newPlayer()
376
+ local failure: any = nil
377
+ local maid = Maid.new()
378
+ maid:GiveTask(RxPlayerUtils.observeFirstAppearanceLoaded(player):Subscribe(nil, function(err)
379
+ failure = err
380
+ end))
381
+
382
+ PlayerMock.kick(player, "Kicked by a test")
383
+
384
+ expect(PromiseTestUtils.awaitValue(function()
385
+ return failure ~= nil
386
+ end)).toBe(true)
387
+
388
+ maid:DoCleaning()
389
+ controller.destroy()
390
+ end)
391
+ end)
@@ -0,0 +1,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "PlayerUtilsTest",
3
+ "globIgnorePaths": [
4
+ "**/.package-lock.json",
5
+ "**/.pnpm",
6
+ "**/.pnpm-workspace-state-v1.json",
7
+ "**/.modules.yaml",
8
+ "**/.ignored",
9
+ "**/.ignored_*"
10
+ ],
11
+ "tree": {
12
+ "$className": "DataModel",
13
+ "ServerScriptService": {
14
+ "$properties": {
15
+ "LoadStringEnabled": true
16
+ },
17
+ "playerutils": {
18
+ "$path": ".."
19
+ },
20
+ "Script": {
21
+ "$path": "scripts/Server"
22
+ }
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,13 @@
1
+ --[[
2
+ @class ServerMain
3
+ ]]
4
+ local ServerScriptService = game:GetService("ServerScriptService")
5
+
6
+ local root = ServerScriptService.playerutils
7
+ local loader = root:FindFirstChild("LoaderUtils", true).Parent
8
+ local require = require(loader).bootstrapGame(root)
9
+
10
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
11
+ if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
12
+ return
13
+ end