@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.
@@ -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
+ }