fp-lib/test/fp-lib.test.ts
2025-06-07 20:52:15 -05:00

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()
})
})