@mantiq/helpers 0.0.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/README.md +19 -0
- package/package.json +52 -0
- package/src/Arr.ts +264 -0
- package/src/Collection.ts +804 -0
- package/src/Duration.ts +172 -0
- package/src/Http.ts +573 -0
- package/src/HttpFake.ts +361 -0
- package/src/Num.ts +187 -0
- package/src/Result.ts +196 -0
- package/src/Str.ts +457 -0
- package/src/async.ts +209 -0
- package/src/functions.ts +153 -0
- package/src/index.ts +65 -0
- package/src/is.ts +195 -0
- package/src/match.ts +78 -0
- package/src/objects.ts +180 -0
package/src/HttpFake.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP fake for testing — intercept requests and return stubbed responses
|
|
3
|
+
* without hitting the network.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const fake = new HttpFake()
|
|
8
|
+
* fake.stub('GET', '/api/users', {
|
|
9
|
+
* status: 200,
|
|
10
|
+
* body: [{ id: 1, name: 'Alice' }],
|
|
11
|
+
* })
|
|
12
|
+
*
|
|
13
|
+
* // Inject via middleware
|
|
14
|
+
* const response = await Http.withMiddleware(fake.middleware())
|
|
15
|
+
* .get('/api/users')
|
|
16
|
+
*
|
|
17
|
+
* // Or replace global fetch
|
|
18
|
+
* fake.install()
|
|
19
|
+
* const response = await Http.get('/api/users')
|
|
20
|
+
* fake.restore()
|
|
21
|
+
*
|
|
22
|
+
* // Assertions
|
|
23
|
+
* fake.assertSent('GET', '/api/users')
|
|
24
|
+
* fake.assertSentCount(1)
|
|
25
|
+
* fake.assertNotSent('POST', '/api/users')
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { HttpMiddleware, HttpResponse } from './Http.ts'
|
|
30
|
+
|
|
31
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface StubResponse {
|
|
34
|
+
status?: number
|
|
35
|
+
statusText?: string
|
|
36
|
+
headers?: Record<string, string>
|
|
37
|
+
body?: any
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type StubHandler = (request: Request) => StubResponse | Promise<StubResponse>
|
|
41
|
+
|
|
42
|
+
interface StubEntry {
|
|
43
|
+
method: string
|
|
44
|
+
pattern: string | RegExp
|
|
45
|
+
handler: StubHandler | StubResponse
|
|
46
|
+
once: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RecordedRequest {
|
|
50
|
+
method: string
|
|
51
|
+
url: string
|
|
52
|
+
headers: Record<string, string>
|
|
53
|
+
body: any
|
|
54
|
+
request: Request
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── HttpFake ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export class HttpFake {
|
|
60
|
+
private stubs: StubEntry[] = []
|
|
61
|
+
private recorded: RecordedRequest[] = []
|
|
62
|
+
private originalFetch: typeof globalThis.fetch | null = null
|
|
63
|
+
private _preventStray = false
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register a stub response for a method + URL pattern.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* fake.stub('GET', '/api/users', { status: 200, body: [] })
|
|
71
|
+
* fake.stub('POST', /\/api\/users\/\d+/, { status: 201, body: { id: 1 } })
|
|
72
|
+
* fake.stub('GET', '/api/users', (req) => ({ status: 200, body: [] }))
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
stub(method: string, pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
76
|
+
this.stubs.push({ method: method.toUpperCase(), pattern, handler, once: false })
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Register a stub that is removed after the first match */
|
|
81
|
+
stubOnce(method: string, pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
82
|
+
this.stubs.push({ method: method.toUpperCase(), pattern, handler, once: true })
|
|
83
|
+
return this
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Shorthand stubs for common methods */
|
|
87
|
+
get(pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
88
|
+
return this.stub('GET', pattern, handler)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
post(pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
92
|
+
return this.stub('POST', pattern, handler)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
put(pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
96
|
+
return this.stub('PUT', pattern, handler)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
patch(pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
100
|
+
return this.stub('PATCH', pattern, handler)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
delete(pattern: string | RegExp, handler: StubHandler | StubResponse): this {
|
|
104
|
+
return this.stub('DELETE', pattern, handler)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* When enabled, any request that doesn't match a stub will throw.
|
|
109
|
+
* Useful for ensuring all HTTP calls in a test are accounted for.
|
|
110
|
+
*/
|
|
111
|
+
preventStrayRequests(): this {
|
|
112
|
+
this._preventStray = true
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a sequence of responses that will be returned in order.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* fake.sequence('GET', '/api/status', [
|
|
122
|
+
* { status: 503 },
|
|
123
|
+
* { status: 503 },
|
|
124
|
+
* { status: 200, body: { ok: true } },
|
|
125
|
+
* ])
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
sequence(method: string, pattern: string | RegExp, responses: StubResponse[]): this {
|
|
129
|
+
let index = 0
|
|
130
|
+
return this.stub(method, pattern, () => {
|
|
131
|
+
const response = responses[Math.min(index, responses.length - 1)]!
|
|
132
|
+
index++
|
|
133
|
+
return response
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Middleware integration ─────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns an HttpMiddleware that intercepts requests and returns stubs.
|
|
141
|
+
* Use with Http.withMiddleware().
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* const response = await Http.withMiddleware(fake.middleware()).get('/api/users')
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
middleware(): HttpMiddleware {
|
|
149
|
+
return async (request: Request, next: (req: Request) => Promise<Response>) => {
|
|
150
|
+
await this.recordRequest(request)
|
|
151
|
+
const stub = this.findStub(request)
|
|
152
|
+
|
|
153
|
+
if (stub) {
|
|
154
|
+
return this.buildResponse(request, stub)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this._preventStray) {
|
|
158
|
+
throw new Error(`HttpFake: Unexpected request ${request.method} ${request.url}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return next(request)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Global fetch replacement ──────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Replace global `fetch` with the fake. Call `restore()` when done.
|
|
169
|
+
*/
|
|
170
|
+
install(): this {
|
|
171
|
+
this.originalFetch = globalThis.fetch
|
|
172
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
173
|
+
const request = input instanceof Request ? input : new Request(input, init)
|
|
174
|
+
await this.recordRequest(request)
|
|
175
|
+
const stub = this.findStub(request)
|
|
176
|
+
|
|
177
|
+
if (stub) {
|
|
178
|
+
return this.buildResponse(request, stub)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this._preventStray) {
|
|
182
|
+
throw new Error(`HttpFake: Unexpected request ${request.method} ${request.url}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return this.originalFetch!(input, init)
|
|
186
|
+
}
|
|
187
|
+
return this
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Restore the original global fetch */
|
|
191
|
+
restore(): void {
|
|
192
|
+
if (this.originalFetch) {
|
|
193
|
+
globalThis.fetch = this.originalFetch
|
|
194
|
+
this.originalFetch = null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Assertions ────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/** Assert that a request matching method + URL was sent */
|
|
201
|
+
assertSent(method: string, pattern: string | RegExp, message?: string): void {
|
|
202
|
+
const found = this.recorded.some((r) => this.matches(r, method.toUpperCase(), pattern))
|
|
203
|
+
if (!found) {
|
|
204
|
+
throw new Error(message ?? `Expected request ${method} ${pattern} was not sent`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Assert that a request was NOT sent */
|
|
209
|
+
assertNotSent(method: string, pattern: string | RegExp, message?: string): void {
|
|
210
|
+
const found = this.recorded.some((r) => this.matches(r, method.toUpperCase(), pattern))
|
|
211
|
+
if (found) {
|
|
212
|
+
throw new Error(message ?? `Unexpected request ${method} ${pattern} was sent`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Assert total number of requests sent */
|
|
217
|
+
assertSentCount(count: number, message?: string): void {
|
|
218
|
+
if (this.recorded.length !== count) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
message ?? `Expected ${count} requests, but ${this.recorded.length} were sent`,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Assert no requests were sent */
|
|
226
|
+
assertNothingSent(message?: string): void {
|
|
227
|
+
this.assertSentCount(0, message ?? 'Expected no requests, but some were sent')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Assert a request was sent and passes a custom check.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* fake.assertSentWith('POST', '/api/users', (req) => {
|
|
236
|
+
* return req.body?.name === 'Alice'
|
|
237
|
+
* })
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
assertSentWith(
|
|
241
|
+
method: string,
|
|
242
|
+
pattern: string | RegExp,
|
|
243
|
+
check: (recorded: RecordedRequest) => boolean,
|
|
244
|
+
message?: string,
|
|
245
|
+
): void {
|
|
246
|
+
const matching = this.recorded.filter((r) => this.matches(r, method.toUpperCase(), pattern))
|
|
247
|
+
const passed = matching.some(check)
|
|
248
|
+
if (!passed) {
|
|
249
|
+
throw new Error(message ?? `No ${method} ${pattern} request matched the assertion check`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Inspection ────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/** Get all recorded requests */
|
|
256
|
+
requests(): RecordedRequest[] {
|
|
257
|
+
return [...this.recorded]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Get recorded requests matching method + pattern */
|
|
261
|
+
sent(method: string, pattern?: string | RegExp): RecordedRequest[] {
|
|
262
|
+
return this.recorded.filter((r) => {
|
|
263
|
+
if (r.method !== method.toUpperCase()) return false
|
|
264
|
+
if (!pattern) return true
|
|
265
|
+
return this.matchesPattern(r.url, pattern)
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Clear recorded requests */
|
|
270
|
+
reset(): this {
|
|
271
|
+
this.recorded = []
|
|
272
|
+
return this
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Clear all stubs and recorded requests */
|
|
276
|
+
clear(): this {
|
|
277
|
+
this.stubs = []
|
|
278
|
+
this.recorded = []
|
|
279
|
+
return this
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Internal ──────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
private findStub(request: Request): StubEntry | undefined {
|
|
285
|
+
const method = request.method.toUpperCase()
|
|
286
|
+
const idx = this.stubs.findIndex((s) => {
|
|
287
|
+
if (s.method !== method && s.method !== '*') return false
|
|
288
|
+
return this.matchesPattern(request.url, s.pattern)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
if (idx === -1) return undefined
|
|
292
|
+
const stub = this.stubs[idx]!
|
|
293
|
+
if (stub.once) this.stubs.splice(idx, 1)
|
|
294
|
+
return stub
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private matchesPattern(url: string, pattern: string | RegExp): boolean {
|
|
298
|
+
if (pattern instanceof RegExp) return pattern.test(url)
|
|
299
|
+
// Support partial matching — pattern matches if URL ends with it or contains it
|
|
300
|
+
if (url === pattern) return true
|
|
301
|
+
if (url.endsWith(pattern)) return true
|
|
302
|
+
try {
|
|
303
|
+
const parsedUrl = new URL(url)
|
|
304
|
+
return parsedUrl.pathname === pattern || parsedUrl.pathname.endsWith(pattern)
|
|
305
|
+
} catch {
|
|
306
|
+
return url.includes(pattern)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private matches(recorded: RecordedRequest, method: string, pattern: string | RegExp): boolean {
|
|
311
|
+
if (recorded.method !== method && method !== '*') return false
|
|
312
|
+
return this.matchesPattern(recorded.url, pattern)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async recordRequest(request: Request): Promise<void> {
|
|
316
|
+
let body: any = null
|
|
317
|
+
try {
|
|
318
|
+
const cloned = request.clone()
|
|
319
|
+
const text = await cloned.text()
|
|
320
|
+
if (text) {
|
|
321
|
+
try { body = JSON.parse(text) } catch { body = text }
|
|
322
|
+
}
|
|
323
|
+
} catch { /* no body */ }
|
|
324
|
+
|
|
325
|
+
this.recorded.push({
|
|
326
|
+
method: request.method.toUpperCase(),
|
|
327
|
+
url: request.url,
|
|
328
|
+
headers: Object.fromEntries(request.headers),
|
|
329
|
+
body,
|
|
330
|
+
request,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async buildResponse(request: Request, stub: StubEntry): Promise<Response> {
|
|
335
|
+
const result = typeof stub.handler === 'function'
|
|
336
|
+
? await stub.handler(request)
|
|
337
|
+
: stub.handler
|
|
338
|
+
|
|
339
|
+
const status = result.status ?? 200
|
|
340
|
+
const statusText = result.statusText ?? ''
|
|
341
|
+
const headers = new Headers(result.headers ?? {})
|
|
342
|
+
|
|
343
|
+
if (result.body !== undefined && result.body !== null) {
|
|
344
|
+
if (!headers.has('content-type')) {
|
|
345
|
+
if (typeof result.body === 'object') {
|
|
346
|
+
headers.set('content-type', 'application/json')
|
|
347
|
+
} else {
|
|
348
|
+
headers.set('content-type', 'text/plain')
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const body = typeof result.body === 'object'
|
|
353
|
+
? JSON.stringify(result.body)
|
|
354
|
+
: String(result.body)
|
|
355
|
+
|
|
356
|
+
return new Response(body, { status, statusText, headers })
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return new Response(null, { status, statusText, headers })
|
|
360
|
+
}
|
|
361
|
+
}
|
package/src/Num.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Number formatting and math utilities.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* Num.format(1234567.89) // '1,234,567.89'
|
|
7
|
+
* Num.currency(99.99) // '$99.99'
|
|
8
|
+
* Num.abbreviate(1_500_000) // '1.5M'
|
|
9
|
+
* Num.fileSize(1_048_576) // '1 MB'
|
|
10
|
+
* Num.ordinal(3) // '3rd'
|
|
11
|
+
* Num.clamp(150, 0, 100) // 100
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export const Num = {
|
|
15
|
+
/** Format a number with thousand separators */
|
|
16
|
+
format(value: number, decimals = 2, locale = 'en-US'): string {
|
|
17
|
+
return new Intl.NumberFormat(locale, {
|
|
18
|
+
minimumFractionDigits: 0,
|
|
19
|
+
maximumFractionDigits: decimals,
|
|
20
|
+
}).format(value)
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/** Format as currency */
|
|
24
|
+
currency(value: number, currency = 'USD', locale = 'en-US'): string {
|
|
25
|
+
return new Intl.NumberFormat(locale, {
|
|
26
|
+
style: 'currency',
|
|
27
|
+
currency,
|
|
28
|
+
}).format(value)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/** Format as percentage */
|
|
32
|
+
percentage(value: number, decimals = 0, locale = 'en-US'): string {
|
|
33
|
+
return new Intl.NumberFormat(locale, {
|
|
34
|
+
style: 'percent',
|
|
35
|
+
minimumFractionDigits: decimals,
|
|
36
|
+
maximumFractionDigits: decimals,
|
|
37
|
+
}).format(value / 100)
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/** Abbreviate large numbers (1K, 1.5M, 2.3B, etc.) */
|
|
41
|
+
abbreviate(value: number, decimals = 1): string {
|
|
42
|
+
const abs = Math.abs(value)
|
|
43
|
+
const sign = value < 0 ? '-' : ''
|
|
44
|
+
|
|
45
|
+
if (abs >= 1e15) return sign + (abs / 1e15).toFixed(decimals) + 'Q'
|
|
46
|
+
if (abs >= 1e12) return sign + (abs / 1e12).toFixed(decimals) + 'T'
|
|
47
|
+
if (abs >= 1e9) return sign + (abs / 1e9).toFixed(decimals) + 'B'
|
|
48
|
+
if (abs >= 1e6) return sign + (abs / 1e6).toFixed(decimals) + 'M'
|
|
49
|
+
if (abs >= 1e3) return sign + (abs / 1e3).toFixed(decimals) + 'K'
|
|
50
|
+
return String(value)
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** Get the ordinal suffix (1st, 2nd, 3rd, 4th, ...) */
|
|
54
|
+
ordinal(value: number): string {
|
|
55
|
+
const abs = Math.abs(value)
|
|
56
|
+
const mod100 = abs % 100
|
|
57
|
+
if (mod100 >= 11 && mod100 <= 13) return `${value}th`
|
|
58
|
+
const mod10 = abs % 10
|
|
59
|
+
if (mod10 === 1) return `${value}st`
|
|
60
|
+
if (mod10 === 2) return `${value}nd`
|
|
61
|
+
if (mod10 === 3) return `${value}rd`
|
|
62
|
+
return `${value}th`
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/** Format bytes as human-readable file size */
|
|
66
|
+
fileSize(bytes: number, decimals = 2): string {
|
|
67
|
+
if (bytes === 0) return '0 B'
|
|
68
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
|
|
69
|
+
const k = 1024
|
|
70
|
+
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
|
|
71
|
+
const unit = units[Math.min(i, units.length - 1)]
|
|
72
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + unit
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Clamp a number within a range */
|
|
76
|
+
clamp(value: number, min: number, max: number): number {
|
|
77
|
+
return Math.min(Math.max(value, min), max)
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/** Check if value is between min and max (inclusive) */
|
|
81
|
+
between(value: number, min: number, max: number): boolean {
|
|
82
|
+
return value >= min && value <= max
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** Random integer between min and max (inclusive) */
|
|
86
|
+
random(min: number, max: number): number {
|
|
87
|
+
return Math.floor(Math.random() * (max - min + 1)) + min
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** Random float between min and max */
|
|
91
|
+
randomFloat(min: number, max: number, decimals = 2): number {
|
|
92
|
+
const value = Math.random() * (max - min) + min
|
|
93
|
+
return parseFloat(value.toFixed(decimals))
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/** Round to a given number of decimal places */
|
|
97
|
+
round(value: number, precision = 0): number {
|
|
98
|
+
const multiplier = Math.pow(10, precision)
|
|
99
|
+
return Math.round(value * multiplier) / multiplier
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/** Floor to a given precision */
|
|
103
|
+
floor(value: number, precision = 0): number {
|
|
104
|
+
const multiplier = Math.pow(10, precision)
|
|
105
|
+
return Math.floor(value * multiplier) / multiplier
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** Ceil to a given precision */
|
|
109
|
+
ceil(value: number, precision = 0): number {
|
|
110
|
+
const multiplier = Math.pow(10, precision)
|
|
111
|
+
return Math.ceil(value * multiplier) / multiplier
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/** Linear interpolation between two values */
|
|
115
|
+
lerp(start: number, end: number, t: number): number {
|
|
116
|
+
return start + (end - start) * t
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Inverse lerp: find t given a value between start and end */
|
|
120
|
+
inverseLerp(start: number, end: number, value: number): number {
|
|
121
|
+
if (start === end) return 0
|
|
122
|
+
return (value - start) / (end - start)
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/** Map a value from one range to another */
|
|
126
|
+
map(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number {
|
|
127
|
+
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin)
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** Sum of an array of numbers */
|
|
131
|
+
sum(values: number[]): number {
|
|
132
|
+
return values.reduce((a, b) => a + b, 0)
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/** Average of an array of numbers */
|
|
136
|
+
avg(values: number[]): number {
|
|
137
|
+
if (values.length === 0) return 0
|
|
138
|
+
return Num.sum(values) / values.length
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/** Median of an array of numbers */
|
|
142
|
+
median(values: number[]): number {
|
|
143
|
+
if (values.length === 0) return 0
|
|
144
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
145
|
+
const mid = Math.floor(sorted.length / 2)
|
|
146
|
+
return sorted.length % 2 !== 0
|
|
147
|
+
? sorted[mid]!
|
|
148
|
+
: (sorted[mid - 1]! + sorted[mid]!) / 2
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/** Mode (most frequent value) */
|
|
152
|
+
mode(values: number[]): number | undefined {
|
|
153
|
+
if (values.length === 0) return undefined
|
|
154
|
+
const counts = new Map<number, number>()
|
|
155
|
+
let maxCount = 0
|
|
156
|
+
let maxValue = values[0]!
|
|
157
|
+
for (const v of values) {
|
|
158
|
+
const count = (counts.get(v) ?? 0) + 1
|
|
159
|
+
counts.set(v, count)
|
|
160
|
+
if (count > maxCount) {
|
|
161
|
+
maxCount = count
|
|
162
|
+
maxValue = v
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return maxValue
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** Standard deviation */
|
|
169
|
+
stddev(values: number[]): number {
|
|
170
|
+
if (values.length === 0) return 0
|
|
171
|
+
const mean = Num.avg(values)
|
|
172
|
+
const squareDiffs = values.map((v) => Math.pow(v - mean, 2))
|
|
173
|
+
return Math.sqrt(Num.avg(squareDiffs))
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/** Percentile (0-100) */
|
|
177
|
+
percentile(values: number[], p: number): number {
|
|
178
|
+
if (values.length === 0) return 0
|
|
179
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
180
|
+
const idx = (p / 100) * (sorted.length - 1)
|
|
181
|
+
const lower = Math.floor(idx)
|
|
182
|
+
const upper = Math.ceil(idx)
|
|
183
|
+
if (lower === upper) return sorted[lower]!
|
|
184
|
+
const frac = idx - lower
|
|
185
|
+
return sorted[lower]! * (1 - frac) + sorted[upper]! * frac
|
|
186
|
+
},
|
|
187
|
+
}
|