@rbxts/specium 1.0.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.
- package/README.md +20 -0
- package/lib/index.d.ts +66 -0
- package/lib/init.luau +517 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>Specium</h1>
|
|
3
|
+
<p>A simple and flexible <code>testing framework</code> for Roblox</p>
|
|
4
|
+
<a href="https://thecogumeta.github.io/specium/"><strong>View docs</strong></a>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<!--moonwave-hide-before-this-line-->
|
|
8
|
+
|
|
9
|
+
## Why Specium?
|
|
10
|
+
|
|
11
|
+
Testing in Roblox usually means scattered `print` statements or heavy dependencies like TestEZ. **Specium** is a lightweight alternative that gives you structured test suites, readable assertions, and detailed output — with no external dependencies.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Suites & sub-suites**: Organize tests with `suite` and `describe`.
|
|
16
|
+
- **Expressive assertions**: `expect` with matchers like `toBe`, `toEqual`, `toThrow`, `toContain` and more.
|
|
17
|
+
- **Invertible matchers**: Use `.never` to flip any assertion.
|
|
18
|
+
- **Custom failure messages**: Chain `.withMessage(msg)` for clearer output.
|
|
19
|
+
- **Structured results**: Get a full `SpeciumRunResult` to inspect programmatically.
|
|
20
|
+
- **Auto-discovery**: Use `runTests` to scan folders for `.spec` modules automatically.
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
interface SpeciumResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface SpeciumRunResult {
|
|
7
|
+
total: number;
|
|
8
|
+
passed: number;
|
|
9
|
+
failed: number;
|
|
10
|
+
suites: SpeciumSuiteResult[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SpeciumSuiteResult {
|
|
14
|
+
name: string;
|
|
15
|
+
tests: SpeciumTestResult[];
|
|
16
|
+
subSuites: SpeciumSuiteResult[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SpeciumTestResult {
|
|
20
|
+
name: string;
|
|
21
|
+
success: boolean;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SpeciumSuiteContext {
|
|
26
|
+
it(name: string, fn: () => SpeciumResult | void): void;
|
|
27
|
+
describe(name: string, fn: (ctx: SpeciumSuiteContext) => void): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SpeciumSuite {
|
|
31
|
+
name: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SpeciumMatchersRaw {
|
|
35
|
+
toBe(expected: unknown): void;
|
|
36
|
+
toEqual(expected: unknown): void;
|
|
37
|
+
toBeA(type: string, legacy?: boolean): void;
|
|
38
|
+
toBeTruthy(): void;
|
|
39
|
+
toBeNil(): void;
|
|
40
|
+
toBeNear(expected: number, epsilon?: number): void;
|
|
41
|
+
toBeGreaterThan(expected: number): void;
|
|
42
|
+
toBeLessThan(expected: number): void;
|
|
43
|
+
toContain(item: unknown): void;
|
|
44
|
+
toHaveLength(expected: number): void;
|
|
45
|
+
toMatch(pattern: string): void;
|
|
46
|
+
toThrow(expected?: unknown, ...args: unknown[]): void;
|
|
47
|
+
withMessage(msg: string): SpeciumMatchersRaw;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SpeciumMatchers extends SpeciumMatchersRaw {
|
|
51
|
+
never: SpeciumMatchersRaw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Specium {
|
|
55
|
+
suite(name: string, fn: (ctx: SpeciumSuiteContext) => void): SpeciumSuite;
|
|
56
|
+
run(suite: SpeciumSuite): [string, SpeciumRunResult];
|
|
57
|
+
runTests(
|
|
58
|
+
testsFolder: Instance | Instance[],
|
|
59
|
+
): [string, Array<SpeciumRunResult>];
|
|
60
|
+
expect(received: unknown): SpeciumMatchers;
|
|
61
|
+
success(message: string): SpeciumResult;
|
|
62
|
+
error(message: string): SpeciumResult;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
declare const Specium: Specium;
|
|
66
|
+
export = Specium;
|
package/lib/init.luau
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
--[=[
|
|
2
|
+
@class Specium
|
|
3
|
+
A lightweight and flexible testing framework for Roblox.
|
|
4
|
+
|
|
5
|
+
Specium allows you to organize tests into suites and sub-suites,
|
|
6
|
+
run them and get a detailed report of the results.
|
|
7
|
+
]=]
|
|
8
|
+
local Specium = {}
|
|
9
|
+
|
|
10
|
+
type SpeciumTest = {
|
|
11
|
+
name: string,
|
|
12
|
+
fn: () -> SpeciumResult,
|
|
13
|
+
}
|
|
14
|
+
type SpeciumSuite = {
|
|
15
|
+
name: string,
|
|
16
|
+
tests: { SpeciumTest },
|
|
17
|
+
subSuites: { SpeciumSuite },
|
|
18
|
+
}
|
|
19
|
+
type SpeciumTestResult = {
|
|
20
|
+
name: string,
|
|
21
|
+
success: boolean,
|
|
22
|
+
message: string,
|
|
23
|
+
}
|
|
24
|
+
type SpeciumSuiteResult = {
|
|
25
|
+
name: string,
|
|
26
|
+
tests: { SpeciumTestResult },
|
|
27
|
+
subSuites: { SpeciumSuiteResult },
|
|
28
|
+
}
|
|
29
|
+
--[=[
|
|
30
|
+
@interface SpeciumResult
|
|
31
|
+
@within Specium
|
|
32
|
+
.success boolean -- Whether the test passed or failed
|
|
33
|
+
.message string -- A message describing the result
|
|
34
|
+
]=]
|
|
35
|
+
export type SpeciumResult = {
|
|
36
|
+
success: boolean,
|
|
37
|
+
message: string,
|
|
38
|
+
}
|
|
39
|
+
--[=[
|
|
40
|
+
@interface SpeciumRunResult
|
|
41
|
+
@within Specium
|
|
42
|
+
.total number -- Total number of tests run
|
|
43
|
+
.passed number -- Number of tests that passed
|
|
44
|
+
.failed number -- Number of tests that failed
|
|
45
|
+
.suites {SpeciumSuiteResult} -- Results for each root suite
|
|
46
|
+
]=]
|
|
47
|
+
export type SpeciumRunResult = {
|
|
48
|
+
total: number,
|
|
49
|
+
passed: number,
|
|
50
|
+
failed: number,
|
|
51
|
+
suites: { SpeciumSuiteResult },
|
|
52
|
+
}
|
|
53
|
+
--[=[
|
|
54
|
+
@interface SpeciumSuiteContext
|
|
55
|
+
@within Specium
|
|
56
|
+
.it (name: string, fn: () -> SpeciumResult) -> () -- Registers a test case inside the current suite
|
|
57
|
+
.describe (name: string, fn: (SpeciumSuiteContext) -> ()) -> () -- Creates a nested sub-suite
|
|
58
|
+
]=]
|
|
59
|
+
export type SpeciumSuiteContext = {
|
|
60
|
+
it: (name: string, fn: () -> SpeciumResult) -> (),
|
|
61
|
+
describe: (name: string, fn: (SpeciumSuiteContext) -> ()) -> (),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
--[=[
|
|
65
|
+
@within Specium
|
|
66
|
+
@param name string -- The name of the suite
|
|
67
|
+
@param fn (SpeciumSuiteContext) -> () -- A function that registers tests and sub-suites
|
|
68
|
+
@return SpeciumSuite -- The constructed suite, ready to be passed to `Specium.run`
|
|
69
|
+
|
|
70
|
+
Creates a new test suite.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
```lua
|
|
74
|
+
local suite = Specium.suite("Math", function(t)
|
|
75
|
+
t.it("adds numbers", function()
|
|
76
|
+
Specium.expect(1 + 1).toBe(2)
|
|
77
|
+
return Specium.success("ok")
|
|
78
|
+
end)
|
|
79
|
+
|
|
80
|
+
t.describe("subtraction", function(t)
|
|
81
|
+
t.it("subtracts numbers", function()
|
|
82
|
+
Specium.expect(5 - 3).toBe(2)
|
|
83
|
+
return Specium.success("ok")
|
|
84
|
+
end)
|
|
85
|
+
end)
|
|
86
|
+
end)
|
|
87
|
+
```
|
|
88
|
+
]=]
|
|
89
|
+
function Specium.suite(name: string, fn: (SpeciumSuiteContext) -> ()): SpeciumSuite
|
|
90
|
+
local suite: SpeciumSuite = {
|
|
91
|
+
name = name,
|
|
92
|
+
tests = {},
|
|
93
|
+
subSuites = {},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
local function createContext(currentSuite: SpeciumSuite): SpeciumSuiteContext
|
|
97
|
+
local ctx = {}
|
|
98
|
+
|
|
99
|
+
function ctx.it(name: string, fn: () -> SpeciumResult)
|
|
100
|
+
table.insert(currentSuite.tests, {
|
|
101
|
+
name = name,
|
|
102
|
+
fn = fn,
|
|
103
|
+
})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
function ctx.describe(name: string, fn: (SpeciumSuiteContext) -> ())
|
|
107
|
+
local newSuite: SpeciumSuite = {
|
|
108
|
+
name = name,
|
|
109
|
+
tests = {},
|
|
110
|
+
subSuites = {},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
table.insert(currentSuite.subSuites, newSuite)
|
|
114
|
+
|
|
115
|
+
fn(createContext(newSuite))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
return ctx
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
fn(createContext(suite))
|
|
122
|
+
|
|
123
|
+
return suite
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
--[=[
|
|
127
|
+
@within Specium
|
|
128
|
+
@param suite SpeciumSuite -- The suite to run
|
|
129
|
+
@return string -- A formatted output string with a tree-view of results and a summary
|
|
130
|
+
@return SpeciumRunResult -- A structured table with the full results
|
|
131
|
+
|
|
132
|
+
Runs all tests in a suite (including sub-suites).
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
```lua
|
|
136
|
+
local output, results = Specium.run(suite)
|
|
137
|
+
print(output)
|
|
138
|
+
-- > Math
|
|
139
|
+
-- [Pass] adds numbers - ok
|
|
140
|
+
-- > subtraction
|
|
141
|
+
-- [Pass] subtracts numbers - ok
|
|
142
|
+
--
|
|
143
|
+
-- Result:
|
|
144
|
+
-- 2/2 (100%) tests passed
|
|
145
|
+
```
|
|
146
|
+
]=]
|
|
147
|
+
function Specium.run(suite: SpeciumSuite): (string, SpeciumRunResult)
|
|
148
|
+
local total = 0
|
|
149
|
+
local passed = 0
|
|
150
|
+
|
|
151
|
+
local output = "\n"
|
|
152
|
+
|
|
153
|
+
local results: SpeciumRunResult = {
|
|
154
|
+
total = 0,
|
|
155
|
+
passed = 0,
|
|
156
|
+
failed = 0,
|
|
157
|
+
suites = {},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
local function runSuite(currentSuite: SpeciumSuite, depth: number): SpeciumSuiteResult
|
|
161
|
+
depth += 1
|
|
162
|
+
local indent = string.rep(" ", depth)
|
|
163
|
+
|
|
164
|
+
local suiteResult: SpeciumSuiteResult = {
|
|
165
|
+
name = currentSuite.name,
|
|
166
|
+
tests = {},
|
|
167
|
+
subSuites = {},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
output ..= `{string.rep(" ", depth - 1)}> {currentSuite.name}\n`
|
|
171
|
+
|
|
172
|
+
for _, test in currentSuite.tests do
|
|
173
|
+
total += 1
|
|
174
|
+
|
|
175
|
+
local ok, result = pcall(test.fn)
|
|
176
|
+
|
|
177
|
+
if not ok then
|
|
178
|
+
result = Specium.error(tostring(result))
|
|
179
|
+
end
|
|
180
|
+
if not result then
|
|
181
|
+
result = {
|
|
182
|
+
message = ok and "Pass" or "Failure",
|
|
183
|
+
success = ok,
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
passed += result.success and 1 or 0
|
|
188
|
+
output ..= `{indent}[{result.success and "Pass" or "Failure"}] {test.name} - {result.message}\n`
|
|
189
|
+
|
|
190
|
+
table.insert(suiteResult.tests, {
|
|
191
|
+
name = test.name,
|
|
192
|
+
success = result.success,
|
|
193
|
+
message = result.message,
|
|
194
|
+
})
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
for _, sub in currentSuite.subSuites do
|
|
198
|
+
local subResult = runSuite(sub, depth + 1)
|
|
199
|
+
table.insert(suiteResult.subSuites, subResult)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
return suiteResult
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
local rootResult = runSuite(suite, 0)
|
|
206
|
+
|
|
207
|
+
results.total = total
|
|
208
|
+
results.passed = passed
|
|
209
|
+
results.failed = total - passed
|
|
210
|
+
results.suites = { rootResult }
|
|
211
|
+
|
|
212
|
+
output ..= "\n\nResult:\n"
|
|
213
|
+
output ..= total > 0 and `{passed}/{total} ({math.round(passed / total * 1000) / 10}%) tests passed` or "no tests found"
|
|
214
|
+
|
|
215
|
+
return output, results
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
--[=[
|
|
219
|
+
@within Specium
|
|
220
|
+
@param testsFolder {Instance} | Instance -- A single folder or a list of folders to scan for `.spec` ModuleScripts.
|
|
221
|
+
@return string -- A concatenated output string of all suite results
|
|
222
|
+
@return {SpeciumRunResult} -- A list of results, one per suite found
|
|
223
|
+
|
|
224
|
+
Scans one or more folders for `.spec` ModuleScripts and runs each one as a suite.
|
|
225
|
+
|
|
226
|
+
Each `.spec` module must return a `SpeciumSuite` created via `Specium.suite`.
|
|
227
|
+
If a module fails to load, a warning is emitted and it is skipped.
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
```lua
|
|
231
|
+
-- Single folder
|
|
232
|
+
local output, results = Specium.runTests(ServerScriptService.Tests)
|
|
233
|
+
print(output)
|
|
234
|
+
|
|
235
|
+
-- Multiple folders
|
|
236
|
+
local output, results = Specium.runTests({
|
|
237
|
+
ServerScriptService.Tests,
|
|
238
|
+
ReplicatedStorage.Shared.Tests,
|
|
239
|
+
})
|
|
240
|
+
print(output)
|
|
241
|
+
```
|
|
242
|
+
]=]
|
|
243
|
+
function Specium.runTests(testsFolder: { Instance } | Instance): (string, { SpeciumRunResult })
|
|
244
|
+
if typeof(testsFolder) == "Instance" then
|
|
245
|
+
return Specium.runTests({ testsFolder })
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
local output = ""
|
|
249
|
+
local results: { SpeciumRunResult } = {}
|
|
250
|
+
|
|
251
|
+
for _, testFolder in testsFolder do
|
|
252
|
+
for _, testsScript in testFolder:QueryDescendants("ModuleScript") do
|
|
253
|
+
if not testsScript.Name:match("%.spec$") then
|
|
254
|
+
continue
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
local ok, suite = pcall(require, testsScript)
|
|
258
|
+
if not ok then
|
|
259
|
+
warn(`[Specium] Failed to load {testsScript:GetFullName()}: {suite}`)
|
|
260
|
+
continue
|
|
261
|
+
end
|
|
262
|
+
local o, r = Specium.run(suite)
|
|
263
|
+
output ..= `{o}\n`
|
|
264
|
+
table.insert(results, r)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
return output, results
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
--[=[
|
|
272
|
+
@within Specium
|
|
273
|
+
@param message string -- The failure message
|
|
274
|
+
@return SpeciumResult -- A result with `success = false`
|
|
275
|
+
|
|
276
|
+
Returns a failed `SpeciumResult` with the given message.
|
|
277
|
+
|
|
278
|
+
Example:
|
|
279
|
+
```lua
|
|
280
|
+
t.it("always fails", function()
|
|
281
|
+
return Specium.error("this test is not implemented yet")
|
|
282
|
+
end)
|
|
283
|
+
```
|
|
284
|
+
]=]
|
|
285
|
+
function Specium.error(message: string): SpeciumResult
|
|
286
|
+
return { success = false, message = message }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
--[=[
|
|
290
|
+
@within Specium
|
|
291
|
+
@param message string -- The success message
|
|
292
|
+
@return SpeciumResult -- A result with `success = true`
|
|
293
|
+
|
|
294
|
+
Returns a successful `SpeciumResult` with the given message.
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
```lua
|
|
298
|
+
-- Returning Specium.success is optional — if omitted, the test is marked as passed automatically.
|
|
299
|
+
t.it("works", function()
|
|
300
|
+
Specium.expect(1 + 1).toBe(2)
|
|
301
|
+
return Specium.success("all good")
|
|
302
|
+
end)
|
|
303
|
+
```
|
|
304
|
+
]=]
|
|
305
|
+
function Specium.success(message: string): SpeciumResult
|
|
306
|
+
return { success = true, message = message }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
--[=[
|
|
310
|
+
@within Specium
|
|
311
|
+
@param received any -- The value to assert against
|
|
312
|
+
@return SpeciumMatchers -- A table of matcher functions
|
|
313
|
+
|
|
314
|
+
Creates a matcher chain for the given value.
|
|
315
|
+
|
|
316
|
+
Use `.never` to invert any matcher, and `.withMessage(msg)` to prepend
|
|
317
|
+
a custom message to any failure.
|
|
318
|
+
|
|
319
|
+
Available matchers:
|
|
320
|
+
- `toBe(expected)` — strict equality (`==`)
|
|
321
|
+
- `toEqual(expected)` — deep equality (tables compared recursively)
|
|
322
|
+
- `toBeA(type, legacy?)` — checks `typeof` (or `type` if `legacy` is true)
|
|
323
|
+
- `toBeTruthy()` — value is not `nil` and not `false`
|
|
324
|
+
- `toBeNil()` — value is `nil`
|
|
325
|
+
- `toBeNear(expected, epsilon?)` — numeric proximity (default epsilon: `1e-5`)
|
|
326
|
+
- `toBeGreaterThan(expected)` — numeric `>`
|
|
327
|
+
- `toBeLessThan(expected)` — numeric `<`
|
|
328
|
+
- `toContain(item)` — table contains value (deep) or string contains substring
|
|
329
|
+
- `toHaveLength(expected)` — checks `#value`
|
|
330
|
+
- `toMatch(pattern)` — string matches a Lua pattern
|
|
331
|
+
- `toThrow(expected?, ...args)` — function throws when called with `args`
|
|
332
|
+
- `withMessage(msg)` — prepends a custom message to any failure
|
|
333
|
+
- `never` — inverts the next matcher
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
```lua
|
|
337
|
+
-- All examples below pass
|
|
338
|
+
Specium.expect(2 + 2).toBe(4)
|
|
339
|
+
Specium.expect("hello world").toContain("world")
|
|
340
|
+
Specium.expect({1, 2, 3}).toHaveLength(3)
|
|
341
|
+
Specium.expect(math.huge).never.toBeNil()
|
|
342
|
+
Specium.expect(function() error("oops") end).toThrow()
|
|
343
|
+
Specium.expect(42).withMessage("answer should be 42").toBe(42)
|
|
344
|
+
Specium.expect(1 + 1).never.toBe(3)
|
|
345
|
+
```
|
|
346
|
+
]=]
|
|
347
|
+
function Specium.expect(received: any)
|
|
348
|
+
local function format(value: any, indent: number?): string
|
|
349
|
+
indent = indent or 0
|
|
350
|
+
assert(indent) -- ignore '?'
|
|
351
|
+
local t = typeof(value)
|
|
352
|
+
if t == "string" then
|
|
353
|
+
return `"{value}"`
|
|
354
|
+
elseif t == "number" or t == "boolean" or value == nil then
|
|
355
|
+
return tostring(value)
|
|
356
|
+
elseif t == "table" then
|
|
357
|
+
local parts = {}
|
|
358
|
+
local innerIndent = indent + 1
|
|
359
|
+
local pad = string.rep(" ", innerIndent)
|
|
360
|
+
local closePad = string.rep(" ", indent)
|
|
361
|
+
for k, v in pairs(value) do
|
|
362
|
+
table.insert(parts, `{pad}[{format(k)}] = {format(v, innerIndent)}`)
|
|
363
|
+
end
|
|
364
|
+
if #parts == 0 then
|
|
365
|
+
return "{}"
|
|
366
|
+
end
|
|
367
|
+
return `\{\n{table.concat(parts, ",\n")}\n{closePad}\}`
|
|
368
|
+
else
|
|
369
|
+
return `<{t}> {tostring(value)}`
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
local function deepEqual(a: any, b: any): boolean
|
|
374
|
+
if a == b then
|
|
375
|
+
return true
|
|
376
|
+
end
|
|
377
|
+
if typeof(a) ~= "table" or typeof(b) ~= "table" then
|
|
378
|
+
return false
|
|
379
|
+
end
|
|
380
|
+
for k, v in pairs(a) do
|
|
381
|
+
if not deepEqual(v, b[k]) then
|
|
382
|
+
return false
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
for k in pairs(b) do
|
|
386
|
+
if a[k] == nil then
|
|
387
|
+
return false
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
return true
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
local function contains(collection: any, item: any): boolean
|
|
394
|
+
local t = typeof(collection)
|
|
395
|
+
if t == "string" then
|
|
396
|
+
return string.find(collection, item, 1, true) ~= nil
|
|
397
|
+
elseif t == "table" then
|
|
398
|
+
for _, v in pairs(collection) do
|
|
399
|
+
if deepEqual(v, item) then
|
|
400
|
+
return true
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
return false
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
local function makeMatchers(invert: boolean)
|
|
408
|
+
local matchers = {}
|
|
409
|
+
local op = invert and " not " or " "
|
|
410
|
+
local customMessage: string? = nil
|
|
411
|
+
|
|
412
|
+
local function check(condition: boolean, message: string)
|
|
413
|
+
if invert then
|
|
414
|
+
condition = not condition
|
|
415
|
+
end
|
|
416
|
+
if not condition then
|
|
417
|
+
local finalMessage = customMessage and `{customMessage}\n{message}` or message
|
|
418
|
+
error(finalMessage, 2)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
function matchers.withMessage(msg: string)
|
|
423
|
+
customMessage = msg
|
|
424
|
+
return matchers
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
function matchers.toBeA(expected: string, legacy: boolean?)
|
|
428
|
+
local getType = legacy and type or typeof
|
|
429
|
+
local actual = getType(received)
|
|
430
|
+
check(
|
|
431
|
+
actual == expected,
|
|
432
|
+
`Expected type: "{expected}"\nReceived type: "{actual}"\nValue: {format(received)}`
|
|
433
|
+
)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
function matchers.toBe(expected: any)
|
|
437
|
+
check(received == expected, `Expected (===): {format(expected)}\nReceived: {format(received)}`)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
function matchers.toEqual(expected: any)
|
|
441
|
+
check(
|
|
442
|
+
deepEqual(received, expected),
|
|
443
|
+
`Expected (deep): {format(expected)}\nReceived: {format(received)}`
|
|
444
|
+
)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
function matchers.toBeTruthy()
|
|
448
|
+
check(received ~= nil and received ~= false, `Expected value to{op}be truthy\nReceived: {format(received)}`)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
function matchers.toBeNil()
|
|
452
|
+
check(received == nil, `Expected value to{op}be nil\nReceived: {format(received)}`)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
function matchers.toBeNear(expected: number, epsilon: number?)
|
|
456
|
+
assert(typeof(received) == "number", "Expected received to be a number")
|
|
457
|
+
epsilon = epsilon or 1e-5
|
|
458
|
+
local diff = math.abs(received - expected)
|
|
459
|
+
check(diff <= epsilon, `Expected: {expected} ± {epsilon}\nReceived: {received}\nDifference: {diff}`)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
function matchers.toBeGreaterThan(expected: number)
|
|
463
|
+
assert(typeof(received) == "number", "Expected received to be a number")
|
|
464
|
+
check(received > expected, `Expected: > {expected}\nReceived: {received}`)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
function matchers.toBeLessThan(expected: number)
|
|
468
|
+
assert(typeof(received) == "number", "Expected received to be a number")
|
|
469
|
+
check(received < expected, `Expected: < {expected}\nReceived: {received}`)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
function matchers.toContain(item: any)
|
|
473
|
+
local t = typeof(received)
|
|
474
|
+
assert(t == "string" or t == "table", "Expected received to be a string or table")
|
|
475
|
+
check(contains(received, item), `Expected{op}to contain: {format(item)}\nIn: {format(received)}`)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
function matchers.toHaveLength(expected: number)
|
|
479
|
+
local t = typeof(received)
|
|
480
|
+
assert(t == "string" or t == "table", "Expected received to be a string or table")
|
|
481
|
+
local actual = #received
|
|
482
|
+
check(actual == expected, `Expected length: {expected}\nReceived length: {actual}`)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
function matchers.toMatch(pattern: string)
|
|
486
|
+
assert(typeof(received) == "string", "Expected received to be a string")
|
|
487
|
+
check(
|
|
488
|
+
string.match(received, pattern) ~= nil,
|
|
489
|
+
`Expected string to{op}match pattern: "{pattern}"\nReceived: {format(received)}`
|
|
490
|
+
)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
function matchers.toThrow(expected: any?, ...)
|
|
494
|
+
assert(typeof(received) == "function", "Expected received to be a function")
|
|
495
|
+
local args = { ... }
|
|
496
|
+
local ok, err = pcall(function()
|
|
497
|
+
return received(table.unpack(args))
|
|
498
|
+
end)
|
|
499
|
+
|
|
500
|
+
check(not ok, `Expected function to{op}throw`)
|
|
501
|
+
|
|
502
|
+
if expected ~= nil and not ok then
|
|
503
|
+
assert(err == expected, `Expected error: {format(expected)}\nReceived error: {format(err)}`)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
if not invert then
|
|
508
|
+
matchers.never = makeMatchers(true)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
return matchers
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
return makeMatchers(false)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
return Specium
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rbxts/specium",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A simple and flexible testing framework for Roblox",
|
|
5
|
+
"main": "lib/init.luau",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/thecogumeta/specium.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"roblox",
|
|
19
|
+
"roblox-ts",
|
|
20
|
+
"rbxts",
|
|
21
|
+
"testing",
|
|
22
|
+
"luau"
|
|
23
|
+
],
|
|
24
|
+
"author": "Cogu ( @thecogumeta )",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/thecogumeta/specium/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/thecogumeta/specium#readme",
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@rbxts/types": "*"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|