metaowl 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ useAuth,
4
+ useLocalStorage,
5
+ useFetch,
6
+ useDebounce,
7
+ useThrottle,
8
+ useWindowSize,
9
+ useOnlineStatus,
10
+ useAsyncState,
11
+ useCache,
12
+ Composables
13
+ } from '../modules/composables.js'
14
+
15
+ describe('Composables', () => {
16
+ describe('Exports', () => {
17
+ it('should export useAuth function', () => {
18
+ expect(typeof useAuth).toBe('function')
19
+ })
20
+
21
+ it('should export useLocalStorage function', () => {
22
+ expect(typeof useLocalStorage).toBe('function')
23
+ })
24
+
25
+ it('should export useFetch function', () => {
26
+ expect(typeof useFetch).toBe('function')
27
+ })
28
+
29
+ it('should export useDebounce function', () => {
30
+ expect(typeof useDebounce).toBe('function')
31
+ })
32
+
33
+ it('should export useThrottle function', () => {
34
+ expect(typeof useThrottle).toBe('function')
35
+ })
36
+
37
+ it('should export useWindowSize function', () => {
38
+ expect(typeof useWindowSize).toBe('function')
39
+ })
40
+
41
+ it('should export useOnlineStatus function', () => {
42
+ expect(typeof useOnlineStatus).toBe('function')
43
+ })
44
+
45
+ it('should export useAsyncState function', () => {
46
+ expect(typeof useAsyncState).toBe('function')
47
+ })
48
+
49
+ it('should export useCache function', () => {
50
+ expect(typeof useCache).toBe('function')
51
+ })
52
+ })
53
+
54
+ describe('Composables namespace', () => {
55
+ it('should export all composables via namespace', () => {
56
+ expect(Composables.useAuth).toBe(useAuth)
57
+ expect(Composables.useLocalStorage).toBe(useLocalStorage)
58
+ expect(Composables.useFetch).toBe(useFetch)
59
+ expect(Composables.useDebounce).toBe(useDebounce)
60
+ expect(Composables.useThrottle).toBe(useThrottle)
61
+ expect(Composables.useWindowSize).toBe(useWindowSize)
62
+ expect(Composables.useOnlineStatus).toBe(useOnlineStatus)
63
+ expect(Composables.useAsyncState).toBe(useAsyncState)
64
+ expect(Composables.useCache).toBe(useCache)
65
+ })
66
+
67
+ it('should have correct function signatures', () => {
68
+ // Check that all functions accept parameters
69
+ // Note: function.length only counts required parameters before first default
70
+ expect(useAuth.length).toBeGreaterThanOrEqual(0)
71
+ expect(useLocalStorage.length).toBeGreaterThanOrEqual(0)
72
+ expect(useFetch.length).toBeGreaterThanOrEqual(0)
73
+ expect(useDebounce.length).toBeGreaterThanOrEqual(0)
74
+ expect(useThrottle.length).toBeGreaterThanOrEqual(0)
75
+ expect(useWindowSize.length).toBeGreaterThanOrEqual(0)
76
+ expect(useOnlineStatus.length).toBeGreaterThanOrEqual(0)
77
+ expect(useAsyncState.length).toBeGreaterThanOrEqual(0)
78
+ expect(useCache.length).toBeGreaterThanOrEqual(0)
79
+ })
80
+ })
81
+
82
+ describe('Documentation', () => {
83
+ it('should have JSDoc comments', () => {
84
+ // Check that functions are documented by checking they're exported
85
+ const exportedFunctions = [
86
+ useAuth,
87
+ useLocalStorage,
88
+ useFetch,
89
+ useDebounce,
90
+ useThrottle,
91
+ useWindowSize,
92
+ useOnlineStatus,
93
+ useAsyncState,
94
+ useCache
95
+ ]
96
+
97
+ for (const fn of exportedFunctions) {
98
+ expect(typeof fn).toBe('function')
99
+ expect(fn.name).toBeTruthy()
100
+ }
101
+ })
102
+ })
103
+ })
@@ -0,0 +1,520 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import {
3
+ pathFromKey,
4
+ matchRoute,
5
+ isDynamicRoute,
6
+ findRoute,
7
+ generateUrl,
8
+ validateRouteParams,
9
+ createCatchAllRoute,
10
+ createRedirectRoute,
11
+ buildRoutes,
12
+ defineRoute,
13
+ route
14
+ } from '../modules/file-router.js'
15
+
16
+ // Mock Component class
17
+ class MockComponent {
18
+ static template = '<div>Mock</div>'
19
+ }
20
+
21
+ class TestPage extends MockComponent {}
22
+ class UserPage extends MockComponent {}
23
+ class ProductPage extends MockComponent {}
24
+ class CatchAllPage extends MockComponent {}
25
+
26
+ describe('Dynamic Routes', () => {
27
+ describe('pathFromKey', () => {
28
+ it('converts static routes', () => {
29
+ expect(pathFromKey('./pages/about/About.js')).toBe('/about')
30
+ expect(pathFromKey('./pages/blog/post/Post.js')).toBe('/blog/post')
31
+ })
32
+
33
+ it('converts index route', () => {
34
+ expect(pathFromKey('./pages/index/Index.js')).toBe('/')
35
+ })
36
+
37
+ it('converts [param] routes', () => {
38
+ expect(pathFromKey('./pages/user/[id]/User.js')).toBe('/user/:id')
39
+ })
40
+
41
+ it('converts nested [params]', () => {
42
+ expect(pathFromKey('./pages/product/[category]/[slug]/Product.js'))
43
+ .toBe('/product/:category/:slug')
44
+ })
45
+
46
+ it('converts optional params [param?]', () => {
47
+ expect(pathFromKey('./pages/blog/[id]/[slug?]/Blog.js'))
48
+ .toBe('/blog/:id/:slug?')
49
+ })
50
+
51
+ it('converts catch-all [...path]', () => {
52
+ expect(pathFromKey('./pages/docs/[...path]/Docs.js'))
53
+ .toBe('/docs/:path(.*)')
54
+ })
55
+
56
+ it('handles root level params', () => {
57
+ expect(pathFromKey('./pages/[id]/Page.js')).toBe('/:id')
58
+ })
59
+ })
60
+
61
+ describe('matchRoute', () => {
62
+ it('matches static routes', () => {
63
+ const match = matchRoute('/about', '/about')
64
+ expect(match).toEqual({ params: {}, pattern: '/about' })
65
+ })
66
+
67
+ it('extracts single param', () => {
68
+ const match = matchRoute('/user/:id', '/user/123')
69
+ expect(match.params).toEqual({ id: '123' })
70
+ })
71
+
72
+ it('extracts multiple params', () => {
73
+ const match = matchRoute('/product/:category/:slug', '/product/tech/hello-world')
74
+ expect(match.params).toEqual({ category: 'tech', slug: 'hello-world' })
75
+ })
76
+
77
+ it('returns null for non-matching route', () => {
78
+ const match = matchRoute('/user/:id', '/about')
79
+ expect(match).toBeNull()
80
+ })
81
+
82
+ it('matches optional params when provided', () => {
83
+ const match = matchRoute('/blog/:id/:slug?', '/blog/123/my-post')
84
+ expect(match.params).toEqual({ id: '123', slug: 'my-post' })
85
+ })
86
+
87
+ it('matches optional params when omitted', () => {
88
+ const match = matchRoute('/blog/:id/:slug?', '/blog/123')
89
+ expect(match.params).toEqual({ id: '123' })
90
+ })
91
+
92
+ it('matches catch-all routes', () => {
93
+ const match = matchRoute('/docs/:path(.*)', '/docs/getting-started/install')
94
+ expect(match.params).toEqual({ path: 'getting-started/install' })
95
+ })
96
+
97
+ it('handles root catch-all', () => {
98
+ const match = matchRoute('/:path(.*)', '/anything/here')
99
+ expect(match.params).toEqual({ path: 'anything/here' })
100
+ })
101
+
102
+ it('handles routes with special characters in params', () => {
103
+ const match = matchRoute('/user/:id', '/user/user%40example.com')
104
+ expect(match.params.id).toBe('user%40example.com')
105
+ })
106
+
107
+ it('does not match when required param is missing', () => {
108
+ const match = matchRoute('/user/:id/profile', '/user/')
109
+ expect(match).toBeNull()
110
+ })
111
+ })
112
+
113
+ describe('isDynamicRoute', () => {
114
+ it('returns true for routes with params', () => {
115
+ expect(isDynamicRoute('/user/:id')).toBe(true)
116
+ expect(isDynamicRoute('/product/:category/:id')).toBe(true)
117
+ })
118
+
119
+ it('returns false for static routes', () => {
120
+ expect(isDynamicRoute('/about')).toBe(false)
121
+ expect(isDynamicRoute('/blog/post')).toBe(false)
122
+ })
123
+
124
+ it('returns true for optional params', () => {
125
+ expect(isDynamicRoute('/blog/:id?')).toBe(true)
126
+ })
127
+
128
+ it('returns true for catch-all', () => {
129
+ expect(isDynamicRoute('/:path(.*)')).toBe(true)
130
+ })
131
+ })
132
+
133
+ describe('findRoute', () => {
134
+ const routes = [
135
+ { name: 'index', path: ['/'], component: MockComponent },
136
+ { name: 'about', path: ['/about'], component: MockComponent },
137
+ { name: 'user', path: ['/user/:id'], component: UserPage, params: ['id'] },
138
+ { name: 'product', path: ['/product/:category/:slug'], component: ProductPage, params: ['category', 'slug'] },
139
+ { name: 'catch-all', path: ['/:path(.*)'], component: CatchAllPage, params: ['path'] }
140
+ ]
141
+
142
+ it('finds static route', () => {
143
+ const route = findRoute(routes, '/about')
144
+ expect(route.name).toBe('about')
145
+ })
146
+
147
+ it('finds dynamic route with params', () => {
148
+ const route = findRoute(routes, '/user/123')
149
+ expect(route.name).toBe('user')
150
+ expect(route.params).toEqual({ id: '123' })
151
+ })
152
+
153
+ it('finds route with multiple params', () => {
154
+ const route = findRoute(routes, '/product/tech/hello')
155
+ expect(route.name).toBe('product')
156
+ expect(route.params).toEqual({ category: 'tech', slug: 'hello' })
157
+ })
158
+
159
+ it('finds catch-all route', () => {
160
+ const route = findRoute(routes, '/not/a/known/path')
161
+ expect(route.name).toBe('catch-all')
162
+ expect(route.params.path).toBe('not/a/known/path')
163
+ })
164
+
165
+ it('returns null for unknown route', () => {
166
+ const route = findRoute(routes, '/unknown')
167
+ // Should find catch-all instead
168
+ expect(route?.name).toBe('catch-all')
169
+ })
170
+
171
+ it('prefers exact match over dynamic', () => {
172
+ const specificRoutes = [
173
+ { name: 'specific', path: ['/user/me'], component: MockComponent },
174
+ { name: 'dynamic', path: ['/user/:id'], component: MockComponent, params: ['id'] }
175
+ ]
176
+
177
+ const route = findRoute(specificRoutes, '/user/me')
178
+ expect(route.name).toBe('specific')
179
+ })
180
+ })
181
+
182
+ describe('generateUrl', () => {
183
+ const routes = [
184
+ { name: 'index', path: ['/'] },
185
+ { name: 'about', path: ['/about'] },
186
+ { name: 'user', path: ['/user/:id'] },
187
+ { name: 'product', path: ['/product/:category/:slug'] },
188
+ { name: 'optional', path: ['/blog/:id/:slug?'] }
189
+ ]
190
+
191
+ it('generates static URL', () => {
192
+ expect(generateUrl(routes, 'about')).toBe('/about')
193
+ })
194
+
195
+ it('generates URL with single param', () => {
196
+ expect(generateUrl(routes, 'user', { id: '123' })).toBe('/user/123')
197
+ })
198
+
199
+ it('generates URL with multiple params', () => {
200
+ expect(generateUrl(routes, 'product', { category: 'tech', slug: 'hello' }))
201
+ .toBe('/product/tech/hello')
202
+ })
203
+
204
+ it('throws for unknown route', () => {
205
+ expect(() => generateUrl(routes, 'unknown')).toThrow('Route "unknown" not found')
206
+ })
207
+
208
+ it('handles optional param when provided', () => {
209
+ expect(generateUrl(routes, 'optional', { id: '123', slug: 'my-post' }))
210
+ .toBe('/blog/123/my-post')
211
+ })
212
+
213
+ it('handles optional param when omitted', () => {
214
+ expect(generateUrl(routes, 'optional', { id: '123' }))
215
+ .toBe('/blog/123')
216
+ })
217
+ })
218
+
219
+ describe('validateRouteParams', () => {
220
+ const route = {
221
+ name: 'user',
222
+ params: ['id', 'name']
223
+ }
224
+
225
+ it('returns valid when all params provided', () => {
226
+ const result = validateRouteParams(route, { id: '123', name: 'John' })
227
+ expect(result.valid).toBe(true)
228
+ expect(result.missing).toEqual([])
229
+ expect(result.extra).toEqual([])
230
+ })
231
+
232
+ it('returns missing params', () => {
233
+ const result = validateRouteParams(route, { id: '123' })
234
+ expect(result.valid).toBe(false)
235
+ expect(result.missing).toEqual(['name'])
236
+ expect(result.extra).toEqual([])
237
+ })
238
+
239
+ it('returns extra params', () => {
240
+ const result = validateRouteParams(route, { id: '123', name: 'John', extra: 'value' })
241
+ expect(result.valid).toBe(true)
242
+ expect(result.missing).toEqual([])
243
+ expect(result.extra).toEqual(['extra'])
244
+ })
245
+
246
+ it('handles route without params', () => {
247
+ const staticRoute = { name: 'about' }
248
+ const result = validateRouteParams(staticRoute, {})
249
+ expect(result.valid).toBe(true)
250
+ })
251
+ })
252
+
253
+ describe('createCatchAllRoute', () => {
254
+ it('creates catch-all route', () => {
255
+ const route = createCatchAllRoute(CatchAllPage)
256
+
257
+ expect(route.name).toBe('404')
258
+ expect(route.path).toEqual(['/:path(.*)'])
259
+ expect(route.component).toBe(CatchAllPage)
260
+ expect(route.params).toEqual(['path'])
261
+ expect(route.meta.catchAll).toBe(true)
262
+ })
263
+
264
+ it('accepts custom name', () => {
265
+ const route = createCatchAllRoute(CatchAllPage, { name: 'not-found' })
266
+ expect(route.name).toBe('not-found')
267
+ })
268
+
269
+ it('accepts custom meta', () => {
270
+ const route = createCatchAllRoute(CatchAllPage, { meta: { requiresAuth: true } })
271
+ expect(route.meta.requiresAuth).toBe(true)
272
+ expect(route.meta.catchAll).toBe(true)
273
+ })
274
+ })
275
+
276
+ describe('createRedirectRoute', () => {
277
+ it('creates redirect route', () => {
278
+ const route = createRedirectRoute('/old-path', '/new-path')
279
+
280
+ expect(route.name).toBe('redirect-old-path')
281
+ expect(route.path).toEqual(['/old-path'])
282
+ expect(route.redirect).toBe('/new-path')
283
+ expect(route.component).toBeNull()
284
+ })
285
+
286
+ it('sanitizes name from path', () => {
287
+ const route = createRedirectRoute('/path/with/many/slashes', '/new')
288
+ expect(route.name).toBe('redirect-path-with-many-slashes')
289
+ })
290
+ })
291
+
292
+ describe('buildRoutes', () => {
293
+ it('builds routes from glob modules', () => {
294
+ const modules = {
295
+ './pages/index/Index.js': { default: TestPage },
296
+ './pages/about/About.js': { default: MockComponent },
297
+ './pages/user/[id]/User.js': { default: UserPage }
298
+ }
299
+
300
+ const routes = buildRoutes(modules)
301
+
302
+ expect(routes).toHaveLength(3)
303
+ expect(routes.map(r => r.name)).toContain('index')
304
+ expect(routes.map(r => r.name)).toContain('about')
305
+ expect(routes.map(r => r.name)).toContain('user')
306
+ })
307
+
308
+ it('creates correct path patterns', () => {
309
+ const modules = {
310
+ './pages/user/[id]/User.js': { default: UserPage }
311
+ }
312
+
313
+ const routes = buildRoutes(modules)
314
+ const userRoute = routes.find(r => r.name === 'user')
315
+
316
+ expect(userRoute.path[0]).toBe('/user/:id')
317
+ })
318
+
319
+ it('extracts parameter names', () => {
320
+ const modules = {
321
+ './pages/product/[category]/[slug]/Product.js': { default: ProductPage }
322
+ }
323
+
324
+ const routes = buildRoutes(modules)
325
+ const productRoute = routes.find(r => r.name === 'product')
326
+
327
+ expect(productRoute.params).toEqual(['category', 'slug'])
328
+ })
329
+
330
+ it('extracts component from module', () => {
331
+ const modules = {
332
+ './pages/Test.js': { default: TestPage }
333
+ }
334
+
335
+ const routes = buildRoutes(modules)
336
+
337
+ expect(routes[0].component).toBe(TestPage)
338
+ })
339
+
340
+ it('falls back to first named export', () => {
341
+ const modules = {
342
+ './pages/About.js': { AboutPage: MockComponent }
343
+ }
344
+
345
+ const routes = buildRoutes(modules)
346
+
347
+ expect(routes[0].component).toBe(MockComponent)
348
+ })
349
+
350
+ it('copies route config from component', () => {
351
+ class ConfiguredPage extends MockComponent {
352
+ static route = {
353
+ meta: { requiresAuth: true },
354
+ beforeEnter: () => {}
355
+ }
356
+ }
357
+
358
+ const modules = {
359
+ './pages/Configured.js': { default: ConfiguredPage }
360
+ }
361
+
362
+ const routes = buildRoutes(modules)
363
+
364
+ expect(routes[0].meta).toEqual({ requiresAuth: true })
365
+ expect(routes[0].beforeEnter).toBeDefined()
366
+ })
367
+
368
+ it('sorts static routes before dynamic', () => {
369
+ const modules = {
370
+ './pages/user/[id]/User.js': { default: UserPage },
371
+ './pages/user/me/User.js': { default: MockComponent },
372
+ './pages/about/About.js': { default: MockComponent }
373
+ }
374
+
375
+ const routes = buildRoutes(modules)
376
+
377
+ expect(routes[0].name).toBe('about') // static
378
+ expect(routes[1].name).toBe('user-me') // static with more segments
379
+ expect(routes[2].name).toBe('user') // dynamic
380
+ })
381
+
382
+ it('throws on missing component export', () => {
383
+ const modules = {
384
+ './pages/Empty.js': {}
385
+ }
386
+
387
+ expect(() => buildRoutes(modules)).toThrow('No component export found')
388
+ })
389
+ })
390
+
391
+ describe('defineRoute', () => {
392
+ it('returns route config object', () => {
393
+ const config = defineRoute({
394
+ path: '/custom',
395
+ meta: { requiresAuth: true }
396
+ })
397
+
398
+ expect(config.path).toBe('/custom')
399
+ expect(config.meta.requiresAuth).toBe(true)
400
+ })
401
+
402
+ it('can be assigned to component', () => {
403
+ class MyPage extends MockComponent {}
404
+ MyPage.route = defineRoute({
405
+ path: '/my-page',
406
+ meta: { title: 'My Page' }
407
+ })
408
+
409
+ expect(MyPage.route.path).toBe('/my-page')
410
+ })
411
+ })
412
+
413
+ describe('route decorator', () => {
414
+ it('sets route config on component', () => {
415
+ const decorator = route({ meta: { requiresAuth: true } })
416
+ class TestComponent extends MockComponent {}
417
+
418
+ decorator(TestComponent)
419
+
420
+ expect(TestComponent.route.meta.requiresAuth).toBe(true)
421
+ })
422
+
423
+ it('returns the component class', () => {
424
+ const decorator = route({})
425
+ class TestComponent extends MockComponent {}
426
+
427
+ const result = decorator(TestComponent)
428
+
429
+ expect(result).toBe(TestComponent)
430
+ })
431
+ })
432
+
433
+ describe('complex routing scenarios', () => {
434
+ it('handles blog with categories and posts', () => {
435
+ const modules = {
436
+ './pages/blog/Blog.js': { default: MockComponent },
437
+ './pages/blog/[category]/Category.js': { default: MockComponent },
438
+ './pages/blog/[category]/[slug]/Post.js': { default: MockComponent }
439
+ }
440
+
441
+ const routes = buildRoutes(modules)
442
+
443
+ expect(routes).toHaveLength(3)
444
+
445
+ // Check that routes are sorted correctly
446
+ const blogRoute = routes.find(r => r.name === 'blog')
447
+ const categoryRoute = routes.find(r => r.name.includes('category'))
448
+ const postRoute = routes.find(r => r.name.includes('slug'))
449
+
450
+ expect(blogRoute.path[0]).toBe('/blog')
451
+ expect(categoryRoute.path[0]).toBe('/blog/:category')
452
+ expect(postRoute.path[0]).toBe('/blog/:category/:slug')
453
+ })
454
+
455
+ it('handles admin area with nested routes', () => {
456
+ const modules = {
457
+ './pages/admin/dashboard/Dashboard.js': { default: MockComponent },
458
+ './pages/admin/users/[id]/User.js': { default: MockComponent },
459
+ './pages/admin/settings/Settings.js': { default: MockComponent }
460
+ }
461
+
462
+ const routes = buildRoutes(modules)
463
+
464
+ expect(routes).toHaveLength(3)
465
+ expect(routes.every(r => r.name.startsWith('admin'))).toBe(true)
466
+ })
467
+
468
+ it('handles catch-all 404 page', () => {
469
+ const modules = {
470
+ './pages/index/Index.js': { default: MockComponent },
471
+ './pages/[...path]/NotFound.js': { default: MockComponent }
472
+ }
473
+
474
+ const routes = buildRoutes(modules)
475
+
476
+ const notFoundRoute = routes.find(r => r.name === 'path')
477
+ expect(notFoundRoute.path[0]).toBe('/:path(.*)')
478
+ })
479
+ })
480
+
481
+ describe('edge cases', () => {
482
+ it('handles empty modules', () => {
483
+ const routes = buildRoutes({})
484
+ expect(routes).toEqual([])
485
+ })
486
+
487
+ it('handles deep nesting', () => {
488
+ const modules = {
489
+ './pages/a/b/c/d/e/Deep.js': { default: MockComponent }
490
+ }
491
+
492
+ const routes = buildRoutes(modules)
493
+
494
+ expect(routes[0].path[0]).toBe('/a/b/c/d/e')
495
+ expect(routes[0].name).toBe('a-b-c-d-e')
496
+ })
497
+
498
+ it('handles special characters in paths', () => {
499
+ // These would be unusual but should work
500
+ const modules = {
501
+ './pages/[id]/[action]/Dynamic.js': { default: MockComponent }
502
+ }
503
+
504
+ const routes = buildRoutes(modules)
505
+
506
+ expect(routes[0].path[0]).toBe('/:id/:action')
507
+ })
508
+
509
+ it('handles single letter params', () => {
510
+ const modules = {
511
+ './pages/[a]/[b]/[c]/Page.js': { default: MockComponent }
512
+ }
513
+
514
+ const routes = buildRoutes(modules)
515
+
516
+ expect(routes[0].path[0]).toBe('/:a/:b/:c')
517
+ expect(routes[0].params).toEqual(['a', 'b', 'c'])
518
+ })
519
+ })
520
+ })