539 lines
16 KiB
TypeScript
539 lines
16 KiB
TypeScript
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
test,
|
|
} from 'bun:test'
|
|
import * as F from '../src'
|
|
|
|
// Mock Bun environment
|
|
beforeAll(() => {
|
|
Bun.env.TEST_PORT = '3000'
|
|
Bun.env.EMPTY_VAR = ''
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete Bun.env.MISSING_VAR
|
|
})
|
|
|
|
describe('Option Module', () => {
|
|
test('creates Some and None', () => {
|
|
expect(F.some(5)).toEqual({_tag: 'Some', value: 5})
|
|
expect(F.none).toEqual({_tag: 'None'})
|
|
})
|
|
|
|
test('fromNullable handles null/undefined', () => {
|
|
expect(F.fromNullable(null)).toEqual(F.none)
|
|
expect(F.fromNullable(undefined)).toEqual(F.none)
|
|
expect(F.fromNullable(10)).toEqual(F.some(10))
|
|
expect(F.fromNullable(0)).toEqual(F.some(0)) // Edge: falsy but not null
|
|
expect(F.fromNullable('')).toEqual(F.some('')) // Edge: empty string
|
|
})
|
|
|
|
test('map transforms values', () => {
|
|
const double = (n: number) => n * 2
|
|
expect(F.map(double)(F.some(5))).toEqual(F.some(10))
|
|
expect(F.map(double)(F.none)).toEqual(F.none)
|
|
expect(F.map(() => null)(F.some(5))).toEqual(F.some(null)) // Edge: mapping to null
|
|
})
|
|
|
|
test('chain handles nested options', () => {
|
|
const safeInverse = (n: number): F.Option<number> =>
|
|
n === 0 ? F.none : F.some(1 / n)
|
|
|
|
expect(F.chain(safeInverse)(F.some(2))).toEqual(F.some(0.5))
|
|
expect(F.chain(safeInverse)(F.some(0))).toEqual(F.none) // Edge: division by zero
|
|
expect(F.chain(safeInverse)(F.none)).toEqual(F.none)
|
|
})
|
|
|
|
test('getOrElse provides defaults', () => {
|
|
const withDefault = F.getOrElse(0)
|
|
expect(withDefault(F.some(42))).toBe(42)
|
|
expect(withDefault(F.none)).toBe(0)
|
|
expect(withDefault(F.some(null))).toBe(null) // Edge: null value
|
|
})
|
|
|
|
test('DoOption composition', () => {
|
|
const result = F.DoOption.start()
|
|
.bind('a', F.some(3))
|
|
.bind('b', F.some(4))
|
|
.return(scope => scope.a * scope.b)
|
|
|
|
expect(result).toEqual(F.some(12))
|
|
})
|
|
|
|
test('DoOption.bindL uses scope for computation', () => {
|
|
const getUser = (id: number): F.Option<string> =>
|
|
id === 1 ? F.some('Alice') : F.none
|
|
|
|
const result = F.DoOption.start()
|
|
.bind('id', F.some(1))
|
|
.bindL('name', scope => getUser(scope.id))
|
|
.return(scope => `User: ${scope.name}`)
|
|
|
|
expect(result).toEqual(F.some('User: Alice'))
|
|
})
|
|
|
|
test('DoOption.bindL short-circuits on None', () => {
|
|
const getUser = (id: number): F.Option<string> =>
|
|
id === 1 ? F.some('Alice') : F.none
|
|
|
|
const result = F.DoOption.start()
|
|
.bind('id', F.some(2)) // Invalid ID
|
|
.bindL('name', scope => getUser(scope.id))
|
|
.bind('age', F.some(30)) // Shouldn't execute
|
|
.return(scope => `User: ${scope.name}, Age: ${scope.age}`)
|
|
|
|
expect(result).toEqual(F.none)
|
|
})
|
|
|
|
test('DoOption.bindL handles complex dependencies', () => {
|
|
const getProfile = (user: string): F.Option<{age: number}> =>
|
|
user === 'Alice' ? F.some({age: 30}) : F.none
|
|
|
|
const result = F.DoOption.start()
|
|
.bind('id', F.some(1))
|
|
.bindL('name', scope => scope.id === 1 ? F.some('Alice') : F.none)
|
|
.bindL('profile', scope => getProfile(scope.name))
|
|
.return(scope => `${scope.name} is ${scope.profile.age} years old`)
|
|
|
|
expect(result).toEqual(F.some('Alice is 30 years old'))
|
|
})
|
|
|
|
test('DoOption.doL executes scoped effects', () => {
|
|
const log = (msg: string): F.Option<void> => F.some(undefined)
|
|
let loggedMessage = ''
|
|
|
|
const result = F.DoOption.start()
|
|
.bind('id', F.some(1))
|
|
.doL(scope => {
|
|
loggedMessage = `Processing user ${scope.id}`
|
|
return log(loggedMessage)
|
|
})
|
|
.bind('name', F.some('Alice'))
|
|
.return(scope => scope.name)
|
|
|
|
expect(result).toEqual(F.some('Alice'))
|
|
expect(loggedMessage).toBe('Processing user 1')
|
|
})
|
|
|
|
test('DoOption.doL short-circuits on None', () => {
|
|
let effectRun = false
|
|
|
|
const result = F.DoOption.start()
|
|
.bind('id', F.some(1))
|
|
.doL(scope => {
|
|
effectRun = true
|
|
return scope.id === 1 ? F.none : F.some(undefined)
|
|
})
|
|
.bind('name', F.some('Alice')) // Shouldn't execute
|
|
.return(scope => scope.name)
|
|
|
|
expect(result).toEqual(F.none)
|
|
expect(effectRun).toBe(true)
|
|
})
|
|
|
|
test('DoOption.doL with multiple effects', () => {
|
|
const effects: string[] = []
|
|
|
|
const result = F.DoOption.start()
|
|
.bind('id', F.some(1))
|
|
.doL(scope => {
|
|
effects.push(`Starting processing for ${scope.id}`)
|
|
return F.some(undefined)
|
|
})
|
|
.bindL('name', scope => {
|
|
effects.push(`Fetching name for ${scope.id}`)
|
|
return scope.id === 1 ? F.some('Alice') : F.none
|
|
})
|
|
.doL(scope => {
|
|
effects.push(`Completed processing for ${scope.name}`)
|
|
return F.some(undefined)
|
|
})
|
|
.return(scope => scope.name)
|
|
|
|
expect(result).toEqual(F.some('Alice'))
|
|
expect(effects).toEqual([
|
|
'Starting processing for 1',
|
|
'Fetching name for 1',
|
|
'Completed processing for Alice',
|
|
])
|
|
})
|
|
|
|
test('DoOption short-circuits on None', () => {
|
|
const result = F.DoOption.start()
|
|
.bind('a', F.some(10))
|
|
.bind('b', F.none)
|
|
.bind('c', F.some(30))
|
|
.return(scope => scope.a + scope.b + scope.c)
|
|
|
|
expect(result).toEqual(F.none)
|
|
})
|
|
|
|
test('AsyncDoOption handles promises', async () => {
|
|
const fetchNumber = (n: number): Promise<F.Option<number>> =>
|
|
Promise.resolve(F.some(n * 2))
|
|
|
|
const result = await F.AsyncDoOption.start()
|
|
.bind('a', fetchNumber(10))
|
|
.bind('b', fetchNumber(20))
|
|
.return(({a, b}) => a + b)
|
|
|
|
expect(result).toEqual(F.some(60))
|
|
})
|
|
|
|
test('AsyncDoOption handles empty promise results', async () => {
|
|
const emptyFetch = (): Promise<F.Option<number>> => Promise.resolve(F.none)
|
|
|
|
const result = await F.AsyncDoOption.start()
|
|
.bind('a', Promise.resolve(F.some(10)))
|
|
.bind('b', emptyFetch())
|
|
.return(({a, b}) => a + b)
|
|
|
|
expect(result).toEqual(F.none)
|
|
})
|
|
|
|
test('AsyncDoOption handles promise rejections', async () => {
|
|
const failingFetch = (): Promise<F.Option<number>> =>
|
|
Promise.reject(new Error('Failed'))
|
|
|
|
const result = await F.AsyncDoOption.start()
|
|
.bind('a', Promise.resolve(F.some(10)))
|
|
.bind('b', failingFetch())
|
|
.return(({a, b}) => a + b)
|
|
|
|
expect(result).toEqual(F.none)
|
|
})
|
|
})
|
|
|
|
describe('Either Module', () => {
|
|
test('creates Right and Left', () => {
|
|
expect(F.right(5)).toEqual({_tag: 'Right', right: 5})
|
|
expect(F.left('error')).toEqual({_tag: 'Left', left: 'error'})
|
|
})
|
|
|
|
test('eitherMap transforms values', () => {
|
|
const double = (n: number) => n * 2
|
|
expect(F.eitherMap(double)(F.right(5))).toEqual(F.right(10))
|
|
expect(F.eitherMap(double)(F.left('error'))).toEqual(F.left('error'))
|
|
expect(F.eitherMap(() => null)(F.right(5))).toEqual(F.right(null)) // Edge: mapping to null
|
|
})
|
|
|
|
test('eitherChain handles sequential operations', () => {
|
|
const safeDivide = (a: number, b: number): F.Either<number, string> =>
|
|
b === 0 ? F.left('Division by zero') : F.right(a / b)
|
|
|
|
expect(F.eitherChain((n: number) => safeDivide(100, n))(F.right(2)))
|
|
.toEqual(F.right(50))
|
|
expect(F.eitherChain((n: number) => safeDivide(100, n))(F.left('error')))
|
|
.toEqual(F.left('error'))
|
|
expect(F.eitherChain(() => F.left('forced error'))(F.right(5))).toEqual(
|
|
F.left('forced error'),
|
|
)
|
|
})
|
|
|
|
test('fold handles both cases', () => {
|
|
const handleSuccess = F.fold(
|
|
(e: string) => `Error: ${e}`,
|
|
(n: number) => `Result: ${n}`,
|
|
)
|
|
|
|
expect(handleSuccess(F.right(42))).toBe('Result: 42')
|
|
expect(handleSuccess(F.left('failure'))).toBe('Error: failure')
|
|
expect(handleSuccess(F.right(0))).toBe('Result: 0') // Edge: falsy success value
|
|
})
|
|
|
|
test('DoEither composition', () => {
|
|
const result = F.DoEither.start<string>()
|
|
.bind('a', F.right(10))
|
|
.bind('b', F.right(20))
|
|
.return(({a, b}) => a + b)
|
|
|
|
expect(result).toEqual(F.right(30))
|
|
})
|
|
|
|
test('DoEither short-circuits on failure', () => {
|
|
const result = F.DoEither.start<string>()
|
|
.bind('a', F.right(10))
|
|
.bind('b', F.left('error'))
|
|
.bind('c', F.right(30))
|
|
.return(({a, b, c}) => a + b + c)
|
|
|
|
expect(result).toEqual(F.left('error'))
|
|
})
|
|
|
|
test('DoEither handles effects', () => {
|
|
const result = F.DoEither.start<string>()
|
|
.bind('a', F.right(10))
|
|
.do(F.right(undefined)) // Execute effect
|
|
.bind('b', F.right(20))
|
|
.return(({a, b}) => a + b)
|
|
|
|
expect(result).toEqual(F.right(30))
|
|
})
|
|
})
|
|
|
|
describe('List Module', () => {
|
|
let list: F.List<number>
|
|
|
|
beforeEach(() => {
|
|
list = F.cons(1, F.cons(2, F.cons(3, F.nil)))
|
|
})
|
|
|
|
test('constructs lists properly', () => {
|
|
expect(list._tag).toBe('Cons')
|
|
expect(list.head).toBe(1)
|
|
expect(list.tail._tag).toBe('Cons')
|
|
expect(list.tail.head).toBe(2)
|
|
expect(F.nil._tag).toBe('Nil') // Edge: empty list
|
|
})
|
|
|
|
test('fromArray and toArray conversions', () => {
|
|
const arr = [4, 5, 6]
|
|
const newList = F.fromArray(arr)
|
|
expect(F.toArray(newList)).toEqual(arr)
|
|
expect(F.toArray(F.nil)).toEqual([]) // Edge: empty array
|
|
})
|
|
|
|
test('listMap transforms elements', () => {
|
|
const double = (n: number) => n * 2
|
|
const doubled = F.listMap(double)(list)
|
|
expect(F.toArray(doubled)).toEqual([2, 4, 6])
|
|
|
|
// Edge: empty list
|
|
expect(F.toArray(F.listMap(double)(F.nil))).toEqual([])
|
|
|
|
// Edge: mapping to different types
|
|
const toString = F.listMap((n: number) => n.toString())
|
|
expect(F.toArray(toString(list))).toEqual(['1', '2', '3'])
|
|
})
|
|
|
|
test('listReduce sums values', () => {
|
|
const sum = F.listReduce((acc: number, val: number) => acc + val, 0)(list)
|
|
expect(sum).toBe(6)
|
|
|
|
// Edge: empty list
|
|
expect(F.listReduce((acc, val) => acc + val, 0)(F.nil)).toBe(0)
|
|
|
|
// Edge: different initial type
|
|
const asString = F.listReduce((acc: string, val: number) => acc + val, '')(
|
|
list,
|
|
)
|
|
expect(asString).toBe('123')
|
|
})
|
|
|
|
test('handles large lists', async () => {
|
|
// Edge: performance test for large list
|
|
const largeArray = Array.from({length: 10000}, (_, i) => i)
|
|
const largeList = F.fromArray(largeArray)
|
|
|
|
const sum = F.listReduce((acc, n) => acc + n, 0)(largeList)
|
|
expect(sum).toBe(49995000)
|
|
})
|
|
})
|
|
|
|
describe('Utility Functions', () => {
|
|
test('pipe chains functions left-to-right', () => {
|
|
const result = F.pipe(
|
|
2,
|
|
n => n * 3,
|
|
n => n + 1,
|
|
n => `${n}`,
|
|
)
|
|
expect(result).toBe('7')
|
|
|
|
// Edge: single function
|
|
expect(F.pipe(5, n => n * 2)).toBe(10)
|
|
})
|
|
|
|
test('compose chains functions right-to-left', () => {
|
|
const sum = (a: number) => (b: number) => a + b
|
|
const func = F.compose(
|
|
(s: string) => s.toUpperCase(),
|
|
(n: number) => `Result: ${n}`,
|
|
sum(2),
|
|
)
|
|
expect(func(3)).toBe('RESULT: 5')
|
|
|
|
// Edge: single function
|
|
const double = F.compose((n: number) => n * 2)
|
|
expect(double(5)).toBe(10)
|
|
})
|
|
|
|
test('tryCatch captures errors', () => {
|
|
const success = F.tryCatch(() => 42)
|
|
const failure = F.tryCatch(() => {
|
|
throw new Error('Failed')
|
|
})
|
|
const throwsString = F.tryCatch(() => {
|
|
throw 'String error'
|
|
})
|
|
|
|
expect(success).toEqual(F.right(42))
|
|
expect(failure._tag).toBe('Left')
|
|
expect((failure as any).left.message).toBe('Failed')
|
|
expect(throwsString._tag).toBe('Left')
|
|
expect((throwsString as any).left.message).toBe('String error')
|
|
})
|
|
|
|
test('hasTag creates type guards', () => {
|
|
const isCons = F.hasTag('Cons')
|
|
const list = F.cons(1, F.nil)
|
|
|
|
expect(isCons(list)).toBe(true)
|
|
expect(isCons(F.nil)).toBe(false)
|
|
expect(isCons(F.some(1))).toBe(false) // Different tagged type
|
|
})
|
|
})
|
|
|
|
describe('Bun Extensions', () => {
|
|
const TEST_FILE = 'test.txt'
|
|
const JSON_FILE = 'data.json'
|
|
|
|
beforeAll(async () => {
|
|
// Create test files
|
|
await Bun.write(TEST_FILE, 'Hello\nWorld\n')
|
|
await Bun.write(JSON_FILE, '{"id": 1, "name": "Test"}')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
// Clean up all test-generated files
|
|
const filesToClean = [
|
|
'output.txt',
|
|
'empty.txt',
|
|
'protected.txt',
|
|
]
|
|
|
|
for (const file of filesToClean) {
|
|
try {
|
|
if (await Bun.file(file).exists()) {
|
|
await Bun.rm(file)
|
|
}
|
|
} catch {}
|
|
}
|
|
})
|
|
|
|
test('readFileSafe reads files', async () => {
|
|
const result = await F.bun.readFileSafe(TEST_FILE)
|
|
expect(result).toEqual(F.right('Hello\nWorld\n'))
|
|
})
|
|
|
|
test('readFileSafe handles missing files', async () => {
|
|
const result = await F.bun.readFileSafe('missing.txt')
|
|
expect(result._tag).toBe('Left')
|
|
expect((result as any).left.message).toBe('File not found')
|
|
})
|
|
|
|
test('readFileSafe handles directory paths', async () => {
|
|
const result = await F.bun.readFileSafe('./')
|
|
expect(result._tag).toBe('Left')
|
|
expect((result as any).left).toBeInstanceOf(Error)
|
|
})
|
|
|
|
test('safeJsonParse parses valid JSON', () => {
|
|
const json = '{"id": 1, "name": "Test"}'
|
|
const result = F.bun.safeJsonParse<{id: number, name: string}>(json)
|
|
expect(result).toEqual(F.right({id: 1, name: 'Test'}))
|
|
})
|
|
|
|
test('safeJsonParse handles invalid JSON', () => {
|
|
const invalidJson = '{invalid}'
|
|
const result = F.bun.safeJsonParse(invalidJson)
|
|
expect(result._tag).toBe('Left')
|
|
expect((result as any).left).toBeInstanceOf(Error)
|
|
})
|
|
|
|
test('safeJsonParse handles empty strings', () => {
|
|
const result = F.bun.safeJsonParse('')
|
|
expect(result._tag).toBe('Left')
|
|
expect((result as any).left).toBeInstanceOf(Error)
|
|
})
|
|
|
|
test('asyncReduce processes lists', async () => {
|
|
const list = F.fromArray([1, 2, 3])
|
|
const asyncSum = async (acc: number, val: number) => acc + val
|
|
const result = await F.bun.asyncReduce(list, asyncSum, 0)
|
|
expect(result).toBe(6)
|
|
})
|
|
|
|
test('asyncReduce handles empty lists', async () => {
|
|
const asyncSum = async (acc: number, val: number) => acc + val
|
|
const result = await F.bun.asyncReduce(F.nil, asyncSum, 10)
|
|
expect(result).toBe(10)
|
|
})
|
|
|
|
test('envOption reads environment variables', () => {
|
|
// Update: envOption should return some for empty strings
|
|
const port = F.bun.envOption('TEST_PORT')
|
|
const missing = F.bun.envOption('MISSING_VAR')
|
|
const empty = F.bun.envOption('EMPTY_VAR')
|
|
|
|
expect(port).toEqual(F.some('3000'))
|
|
expect(missing).toEqual(F.none)
|
|
expect(empty).toEqual(F.some('')) // Empty string should be Some("")
|
|
})
|
|
|
|
test('listToFile writes lists to files', async () => {
|
|
const list = F.fromArray(['Line 1', 'Line 2', 'Line 3'])
|
|
const result = await F.bun.listToFile(list, 'output.txt')
|
|
|
|
expect(result).toEqual(F.right(undefined))
|
|
const content = await Bun.file('output.txt').text()
|
|
expect(content).toBe('Line 1\nLine 2\nLine 3\n')
|
|
})
|
|
|
|
test('listToFile handles empty lists', async () => {
|
|
// Use unique filename to prevent conflicts
|
|
const result = await F.bun.listToFile(F.nil, 'empty.txt')
|
|
expect(result).toEqual(F.right(undefined))
|
|
const content = await Bun.file('empty.txt').text()
|
|
expect(content).toBe('')
|
|
})
|
|
|
|
test('listToFile handles write errors', async () => {
|
|
// Try to write to protected directory
|
|
const list = F.fromArray(['Test'])
|
|
const result = await F.bun.listToFile(list, '/root/test.txt')
|
|
expect(result._tag).toBe('Left')
|
|
expect((result as any).left).toBeInstanceOf(Error)
|
|
})
|
|
|
|
test('createServer handles requests', async () => {
|
|
const handler = (req: Request): F.Either<Response, Error> => {
|
|
return req.url.endsWith('/hello')
|
|
? F.right(new Response('Hello World'))
|
|
: F.left(new Error('Not found'))
|
|
}
|
|
|
|
const server = F.bun.createServer(handler)
|
|
|
|
// Test valid request
|
|
const response1 = await server.fetch('http://localhost/hello')
|
|
expect(await response1.text()).toBe('Hello World')
|
|
|
|
// Test invalid request
|
|
const response2 = await server.fetch('http://localhost/not-found')
|
|
expect(response2.status).toBe(500)
|
|
expect(await response2.text()).toBe('Server error')
|
|
|
|
server.stop()
|
|
})
|
|
|
|
test('createServer handles thrown errors', async () => {
|
|
// This should be caught by the server's error handler
|
|
const handler = () => {
|
|
throw new Error('Unexpected error')
|
|
}
|
|
|
|
const server = F.bun.createServer(handler as any)
|
|
const response = await server.fetch('http://localhost/')
|
|
expect(response.status).toBe(500)
|
|
expect(await response.text()).toBe('Internal error')
|
|
|
|
server.stop()
|
|
})
|
|
})
|