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,126 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import {
3
+ onError,
4
+ setErrorContext,
5
+ getErrorContext,
6
+ clearErrorContext,
7
+ captureError,
8
+ initGlobalErrorHandling
9
+ } from '../modules/error-boundary.js'
10
+
11
+ describe('Error Boundary', () => {
12
+ beforeEach(() => {
13
+ clearErrorContext()
14
+ // Clear all handlers
15
+ vi.clearAllMocks()
16
+ })
17
+
18
+ describe('onError', () => {
19
+ it('registers an error handler', () => {
20
+ const handler = vi.fn()
21
+ const unsubscribe = onError(handler)
22
+
23
+ // Simulate error
24
+ const error = new Error('Test error')
25
+ captureError(error)
26
+
27
+ expect(handler).toHaveBeenCalled()
28
+ unsubscribe()
29
+ })
30
+
31
+ it('returns unsubscribe function', () => {
32
+ const handler = vi.fn()
33
+ const unsubscribe = onError(handler)
34
+
35
+ unsubscribe()
36
+
37
+ // Simulate error after unsubscribe
38
+ const error = new Error('Test error')
39
+ captureError(error)
40
+
41
+ expect(handler).not.toHaveBeenCalled()
42
+ })
43
+
44
+ it('calls multiple handlers', () => {
45
+ const handler1 = vi.fn()
46
+ const handler2 = vi.fn()
47
+
48
+ onError(handler1)
49
+ onError(handler2)
50
+
51
+ const error = new Error('Test error')
52
+ captureError(error)
53
+
54
+ expect(handler1).toHaveBeenCalled()
55
+ expect(handler2).toHaveBeenCalled()
56
+ })
57
+
58
+ it('passes error and context to handler', () => {
59
+ const handler = vi.fn()
60
+ setErrorContext({ route: '/test' })
61
+
62
+ onError(handler)
63
+
64
+ const error = new Error('Test error')
65
+ captureError(error, { component: 'TestComponent' })
66
+
67
+ expect(handler).toHaveBeenCalledWith(error, {
68
+ route: '/test',
69
+ component: 'TestComponent'
70
+ })
71
+ })
72
+ })
73
+
74
+ describe('setErrorContext / getErrorContext', () => {
75
+ it('sets and gets error context', () => {
76
+ setErrorContext({ route: '/home' })
77
+
78
+ expect(getErrorContext()).toEqual({ route: '/home' })
79
+ })
80
+
81
+ it('merges context objects', () => {
82
+ setErrorContext({ route: '/home' })
83
+ setErrorContext({ user: 'john' })
84
+
85
+ expect(getErrorContext()).toEqual({
86
+ route: '/home',
87
+ user: 'john'
88
+ })
89
+ })
90
+ })
91
+
92
+ describe('clearErrorContext', () => {
93
+ it('clears all context', () => {
94
+ setErrorContext({ route: '/home', user: 'john' })
95
+ clearErrorContext()
96
+
97
+ expect(getErrorContext()).toEqual({})
98
+ })
99
+ })
100
+
101
+ describe('captureError', () => {
102
+ it('captures error and notifies handlers', () => {
103
+ const handler = vi.fn()
104
+ onError(handler)
105
+
106
+ const error = new Error('Captured error')
107
+ captureError(error)
108
+
109
+ expect(handler).toHaveBeenCalledWith(error, {})
110
+ })
111
+
112
+ it('includes context in captured error', () => {
113
+ const handler = vi.fn()
114
+ setErrorContext({ app: 'test' })
115
+ onError(handler)
116
+
117
+ const error = new Error('Test error')
118
+ captureError(error, { component: 'Home' })
119
+
120
+ expect(handler).toHaveBeenCalledWith(error, {
121
+ app: 'test',
122
+ component: 'Home'
123
+ })
124
+ })
125
+ })
126
+ })
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useForm, validators, createSchema } from '../modules/forms.js'
3
+
4
+ describe('Forms', () => {
5
+ describe('useForm', () => {
6
+ it('creates form with initial values', () => {
7
+ const form = useForm({
8
+ name: { default: 'John' },
9
+ email: { default: '' }
10
+ })
11
+
12
+ expect(form.fields.name).toBe('John')
13
+ expect(form.fields.email).toBe('')
14
+ })
15
+
16
+ it('tracks dirty state', () => {
17
+ const form = useForm({
18
+ name: { default: '' }
19
+ })
20
+
21
+ expect(form.isDirty).toBe(false)
22
+ form.setValue('name', 'Jane')
23
+ expect(form.isDirty).toBe(true)
24
+ })
25
+
26
+ it('tracks touched state', () => {
27
+ const form = useForm({
28
+ name: { default: '' }
29
+ })
30
+
31
+ expect(form.isTouched).toBe(false)
32
+ form.setTouched('name')
33
+ expect(form.isTouched).toBe(true)
34
+ })
35
+
36
+ it('validates with sync validators', async () => {
37
+ const form = useForm({
38
+ name: {
39
+ default: '',
40
+ validation: (v) => v.length >= 3 || 'Min 3 chars'
41
+ }
42
+ })
43
+
44
+ // Invalid
45
+ form.setValue('name', 'Jo')
46
+ const isValid1 = await form.validateField('name')
47
+ expect(isValid1).toBe(false)
48
+ expect(form.errors.name).toBe('Min 3 chars')
49
+
50
+ // Valid
51
+ form.setValue('name', 'John')
52
+ const isValid2 = await form.validateField('name')
53
+ expect(isValid2).toBe(true)
54
+ expect(form.errors.name).toBe(null)
55
+ })
56
+
57
+ it('validates all fields', async () => {
58
+ const form = useForm({
59
+ name: {
60
+ default: '',
61
+ validation: (v) => !!v || 'Required'
62
+ },
63
+ email: {
64
+ default: '',
65
+ validation: (v) => !!v || 'Required'
66
+ }
67
+ })
68
+
69
+ const isValid = await form.validate()
70
+ expect(isValid).toBe(false)
71
+ expect(form.errors.name).toBe('Required')
72
+ expect(form.errors.email).toBe('Required')
73
+ })
74
+
75
+ it('handles submit', async () => {
76
+ const onSubmit = vi.fn()
77
+ const form = useForm({
78
+ name: { default: 'John' }
79
+ })
80
+
81
+ const handleSubmit = form.handleSubmit(onSubmit)
82
+ await handleSubmit()
83
+
84
+ expect(onSubmit).toHaveBeenCalledWith({ name: 'John' })
85
+ })
86
+
87
+ it('validates before submit', async () => {
88
+ const onSubmit = vi.fn()
89
+ const form = useForm({
90
+ name: {
91
+ default: '',
92
+ validation: (v) => !!v || 'Required'
93
+ }
94
+ })
95
+
96
+ const handleSubmit = form.handleSubmit(onSubmit)
97
+ await handleSubmit()
98
+
99
+ expect(onSubmit).not.toHaveBeenCalled()
100
+ expect(form.errors.name).toBe('Required')
101
+ })
102
+
103
+ it('resets form', () => {
104
+ const form = useForm({
105
+ name: { default: 'John' }
106
+ })
107
+
108
+ form.setValue('name', 'Jane')
109
+ form.setTouched('name')
110
+ form.reset()
111
+
112
+ expect(form.fields.name).toBe('John')
113
+ expect(form.isDirty).toBe(false)
114
+ expect(form.isTouched).toBe(false)
115
+ })
116
+
117
+ it('supports register method', () => {
118
+ const form = useForm({
119
+ name: { default: '' }
120
+ })
121
+
122
+ const props = form.register('name')
123
+
124
+ expect(props.value).toBe('')
125
+ expect(typeof props.onChange).toBe('function')
126
+ expect(typeof props.onBlur).toBe('function')
127
+ })
128
+ })
129
+
130
+ describe('validators', () => {
131
+ describe('required', () => {
132
+ it('validates non-empty', () => {
133
+ const validator = validators.required()
134
+ expect(validator('')).toBe('Required')
135
+ expect(validator('test')).toBe(true)
136
+ })
137
+
138
+ it('allows custom message', () => {
139
+ const validator = validators.required('Field required')
140
+ expect(validator('')).toBe('Field required')
141
+ })
142
+ })
143
+
144
+ describe('minLength', () => {
145
+ it('validates min length', () => {
146
+ const validator = validators.minLength(3)
147
+ expect(validator('ab')).toBe('Min 3 characters')
148
+ expect(validator('abc')).toBe(true)
149
+ })
150
+ })
151
+
152
+ describe('maxLength', () => {
153
+ it('validates max length', () => {
154
+ const validator = validators.maxLength(5)
155
+ expect(validator('abcdef')).toBe('Max 5 characters')
156
+ expect(validator('abc')).toBe(true)
157
+ })
158
+ })
159
+
160
+ describe('email', () => {
161
+ it('validates email format', () => {
162
+ const validator = validators.email()
163
+ expect(validator('invalid')).toBe('Invalid email')
164
+ expect(validator('test@example.com')).toBe(true)
165
+ })
166
+ })
167
+
168
+ describe('match', () => {
169
+ it('validates field match', () => {
170
+ const validator = validators.match('password', 'Passwords must match')
171
+ expect(validator('secret', { password: 'different' })).toBe('Passwords must match')
172
+ expect(validator('secret', { password: 'secret' })).toBe(true)
173
+ })
174
+ })
175
+ })
176
+
177
+ describe('createSchema', () => {
178
+ it('creates form config from schema', () => {
179
+ const schema = createSchema({
180
+ name: [validators.required(), validators.minLength(3)],
181
+ email: validators.email()
182
+ })
183
+
184
+ expect(schema.name).toBeDefined()
185
+ expect(schema.email).toBeDefined()
186
+
187
+ // Test validation
188
+ const nameValidator = schema.name.validation
189
+ expect(nameValidator('ab', {})).toBe('Min 3 characters')
190
+ expect(nameValidator('john', {})).toBe(true)
191
+ })
192
+
193
+ it('accepts single validator', () => {
194
+ const schema = createSchema({
195
+ email: validators.email()
196
+ })
197
+
198
+ const emailValidator = schema.email.validation
199
+ expect(emailValidator('test', {})).toBe('Invalid email')
200
+ expect(emailValidator('test@example.com', {})).toBe(true)
201
+ })
202
+ })
203
+ })
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import {
3
+ configureI18n,
4
+ t,
5
+ getLocale,
6
+ setLocale,
7
+ i18n,
8
+ loadLocaleMessages,
9
+ formatDate,
10
+ formatNumber,
11
+ formatCurrency,
12
+ formatRelativeTime,
13
+ createNamespacedT
14
+ } from '../modules/i18n.js'
15
+
16
+ describe('i18n', () => {
17
+ beforeEach(() => {
18
+ configureI18n({
19
+ locale: 'en',
20
+ fallbackLocale: 'en',
21
+ messages: {
22
+ en: {
23
+ hello: 'Hello',
24
+ greeting: 'Hello {{name}}',
25
+ count: {
26
+ zero: 'No items',
27
+ one: 'One item',
28
+ other: '{{n}} items'
29
+ }
30
+ },
31
+ de: {
32
+ hello: 'Hallo',
33
+ greeting: 'Hallo {{name}}'
34
+ }
35
+ }
36
+ })
37
+ })
38
+
39
+ describe('t()', () => {
40
+ it('translates simple key', () => {
41
+ expect(t('hello')).toBe('Hello')
42
+ })
43
+
44
+ it('interpolates values', () => {
45
+ expect(t('greeting', { name: 'World' })).toBe('Hello World')
46
+ })
47
+
48
+ it('returns key if translation not found', () => {
49
+ expect(t('nonexistent.key')).toBe('nonexistent.key')
50
+ })
51
+
52
+ it('uses fallback locale', () => {
53
+ configureI18n({ locale: 'fr', fallbackLocale: 'en' })
54
+ expect(t('hello')).toBe('Hello') // Falls back to English
55
+ })
56
+ })
57
+
58
+ describe('pluralization', () => {
59
+ it('handles zero', () => {
60
+ const result = t('count', { n: 0 })
61
+ expect(result).toBe('No items')
62
+ })
63
+
64
+ it('handles one', () => {
65
+ const result = t('count', { n: 1 })
66
+ expect(result).toBe('One item')
67
+ })
68
+
69
+ it('handles other', () => {
70
+ const result = t('count', { n: 5 })
71
+ expect(result).toBe('5 items')
72
+ })
73
+
74
+ it('uses fallback when form not defined', () => {
75
+ configureI18n({
76
+ locale: 'de',
77
+ messages: {
78
+ de: {
79
+ items: {
80
+ one: 'Ein Artikel'
81
+ // missing 'other'
82
+ }
83
+ }
84
+ }
85
+ })
86
+ })
87
+ })
88
+
89
+ describe('locale switching', () => {
90
+ it('sets and gets locale', async () => {
91
+ await setLocale('de')
92
+ expect(getLocale()).toBe('de')
93
+ expect(document.documentElement.lang).toBe('de')
94
+ })
95
+
96
+ it('updates html lang attribute', async () => {
97
+ await setLocale('de')
98
+ expect(document.documentElement.lang).toBe('de')
99
+ })
100
+ })
101
+
102
+ describe('loadLocaleMessages', () => {
103
+ it('loads messages asynchronously', async () => {
104
+ const messages = { goodbye: 'Farewell' }
105
+ await loadLocaleMessages('en', messages)
106
+
107
+ expect(t('goodbye')).toBe('Farewell')
108
+ })
109
+
110
+ it('merges with existing messages', async () => {
111
+ await loadLocaleMessages('en', { newKey: 'New Value' })
112
+
113
+ expect(t('hello')).toBe('Hello') // Still exists
114
+ expect(t('newKey')).toBe('New Value') // Added
115
+ })
116
+ })
117
+
118
+ describe('formatDate', () => {
119
+ it('formats date according to locale', () => {
120
+ const date = new Date('2024-01-15')
121
+ const result = formatDate(date, { dateStyle: 'short' })
122
+ expect(result).toContain('1')
123
+ expect(result).toContain('15')
124
+ })
125
+ })
126
+
127
+ describe('formatNumber', () => {
128
+ it('formats number according to locale', () => {
129
+ expect(formatNumber(1234.5)).toContain('1')
130
+ expect(formatNumber(1234.5)).toContain('5')
131
+ })
132
+ })
133
+
134
+ describe('formatCurrency', () => {
135
+ it('formats currency', () => {
136
+ const result = formatCurrency(1234.5, 'USD')
137
+ expect(result).toContain('$')
138
+ })
139
+
140
+ it('formats EUR', () => {
141
+ const result = formatCurrency(1234.5, 'EUR')
142
+ expect(result).toContain('€')
143
+ })
144
+ })
145
+
146
+ describe('formatRelativeTime', () => {
147
+ it('formats relative time', () => {
148
+ const future = new Date(Date.now() + 2 * 60 * 60 * 1000)
149
+ const result = formatRelativeTime(future)
150
+ expect(result).toContain('2')
151
+ })
152
+ })
153
+
154
+ describe('createNamespacedT', () => {
155
+ it('creates namespaced translator', () => {
156
+ configureI18n({
157
+ messages: {
158
+ en: {
159
+ forms: {
160
+ name: { label: 'Name' },
161
+ email: { label: 'Email' }
162
+ }
163
+ }
164
+ }
165
+ })
166
+
167
+ const tf = createNamespacedT('forms')
168
+ expect(tf('name.label')).toBe('Name')
169
+ expect(tf('email.label')).toBe('Email')
170
+ })
171
+ })
172
+
173
+ describe('i18n object', () => {
174
+ it('exposes locale getter', () => {
175
+ expect(i18n.locale).toBe('en')
176
+ })
177
+
178
+ it('exposes t function', () => {
179
+ expect(i18n.t('hello')).toBe('Hello')
180
+ })
181
+
182
+ it('exposes format functions', () => {
183
+ expect(typeof i18n.formatDate).toBe('function')
184
+ expect(typeof i18n.formatNumber).toBe('function')
185
+ expect(typeof i18n.formatCurrency).toBe('function')
186
+ })
187
+ })
188
+ })