diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..1debbd8 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,66 @@ +# FEATURES.md + +## Framing + + * `execute (series)` (Iteration): + - It works *horizontally*. It takes a list of items and applies one transformation to each item in parallel or sequence. + - Orchestrate the list and report progress." + - Process independent items. + - stateless + - default to collect error strategy + + * `scan` (Iteration) + - Process dependent items + - stateful. + - locked on failFast error strategy + + * `filter` + - Selects items horizontally. + - Do not care if items are dependent or not + - stateless + - default to collect error strategy + + * `pipe / pipeAsync` (Composition): + - It works *vertically*. + - It takes one item and passes it through a chain of functions, one after the other. + - should *not* handle iterables. + - is just a "Function Builder." It creates a single, composite function f. + - has no error strategy. + + * `tryCatch` is a function wrapper + - "Protect the work" of the function + - is effectively a pipeline of length 1. + - Wraps one function with "Middleware/Lifecycle" (Start, Success, Error, Finally). + - Can handle progress / error notification even without being run in a series + - Deep Telemetry: Wrap fn in pipeline to Log specifically when step 2 of a 5-step pipe fails. + - Can be extended with specific progress/error notifications use-cases + + * `retry`: a specialized trycatch + - Perfect to combine in pipelines + +## `series` Feature Set + +* **Error Strategies** + * **Fail Fast:** Stops execution immediately upon the first error. + * **Collect:** Gathers all errors and continues processing until the end. + +* **Termination Control (`take`)** + * Allows processing a subset of data (e.g., "process only the first N items"). + * Essential for working with infinite generators or streams. + +* **Universal Input** + * Works on Arrays, Streams, Generators, and any Async Iterable. + +* **Universal Mapper** + * Handles both Synchronous and Asynchronous mapper functions automatically. + +* **Functional Flexibility** + * **Immediate Mode:** `safeMap(data, fn)` runs instantly. + * **Curried Mode:** `safeMap(fn)(data)` creates a reusable executable, ideal for pipelines. + +* **Order Guarantee** + * Because execution is sequential, output order strictly matches input order (no race conditions). + +* **Structured Results** + * Always returns a predictable object: `{ results, errors, failure }`. + * Errors are treated as data, removing the need for consumer-side `try/catch` blocks. diff --git a/README.md b/README.md index f4b4902..d29ed2e 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,23 @@ # Pipelean -Async-first, error-aware data transformation library. Never write a for loop again. +A pragmatic library for sequential async operations with robust error handling. ---- +Why? -## Core Concepts +Standard Promise.all crashes on the first error. Promise.allSettled gives you a messy array of statuses. Pipelean gives you structured results, error strategies, and flow control out of the box. -### Error Strategies +## The Concept -Three strategies control how errors are handled across all operations: `failFast` (default, stops immediately), `skip` (continues, collects errors), `collect` (continues, yields error objects). Every operation accepts `{onError: strategy}` in its options. + pipe: Compose functions vertically. + series: Execute them horizontally over a list. -### Operations vs Pipelines +## Quick Example - * **Operations** (`safeMap`, `safeFilter`) transform individual items; they accept immediate or curried arguments. - * **Pipelines** (`safePipe`, `safeAsyncIterator`) compose operations and manage error propagation across steps. +import { pipe, series, retry } from 'pipelean';// 1. Build your workflowconst pipeline = pipe( processData, retry(saveToDb, { attempts: 3 }), // Built-in resiliency notifyUI);// 2. Execute safelyconst { results, errors } = await series(items, pipeline);// results: Successful items// errors: [{ item, error }, ...] -> Structured failures -### Map, Filter, Scan, Batch — What's the Difference? + +## Documentation - * `safeMap` transforms each item (sync or async) - * `safeFilter` keeps items matching a predicate - * `scanSeries` accumulates a value while iterating - * `mapSeries` maps with a concurrency limit. - -The all return `{results, errors, failure}` for error handling. - -### Error Strategies Are Consistent - -Every *operation* and every *pipeline* defaults to `failFast`: stop on first error. Use `{onError: skip}` to continue past errors, or `{onError: collect}` to gather them. Same semantics everywhere — no surprises. - ---- - -## Operations - -### safeMap - -```js -// Immediate: array input, transform, options -const {results, errors, failure} = await safeMap(data, x => x * 2, {onError: skip}) - -// Curried: for use in pipelines -const double = safeMap(x => x * 2) -const pipeline = safePipe(double, ...) -``` - -Async transforms supported. Default: `failFast`. Returns `{results, errors, failure}`. - -### safeFilter - -```js -// Immediate -const {results, errors, failure} = await safeFilter(data, x => x > 5, {onError: collect}) - -// Curried -const bigOnly = safeFilter(x => x > 5) -``` - -Predicate can be async. Default: `failFast`. Same return structure. - -### mapSeries - -Shortcut for `safeMap({onError: none})` - - -### scanSeries - -```js -const runningTotal = await scanSeries(data, (acc, item) => acc + item, 0) -// Returns: [1, 3, 6, 10, ...] -``` - -Accumulates a value while iterating. Returns all intermediate results. - ---- - -## Pipelines - -### safePipe - -```js -const {results, errors, failure} = await safePipe( - safeMap(x => x * 2), - safeFilter(x => x > 5), - safeMap(async x => enrichData(x)) -)(data) -``` - -Chains operations with error propagation. Each step's errors accumulate; `failFast` in any step stops the entire pipeline. Returns structured result. - -### safeAsyncIterator - -```js -async function* enrichStream(source) { - const iterator = safeAsyncIterator(source, async item => ({...item, meta: await fetch(item.id)}), {onError: collect}) - for await (const result of iterator) { - yield result - } -} -``` - -Generator-based iteration. Lazy evaluation stops when you stop consuming. Default: `failFast`. Perfect for streaming large datasets. - ---- - -## Example: Full Pipeline - -```js -const data = [1, 2, 3, 4, 5] - -const {results, errors, failure} = await safePipe( - safeMap(x => x * 2, {onError: skip}), // double - safeFilter(x => x > 4, {onError: skip}), // keep > 4 - safeMap(async x => ({value: x, enriched: await fetchMeta(x)}), {onError: collect}) -)(data) - -// results: transformed data -// errors: accumulated errors from all steps -// failure: first failFast error (null if no failFast errors) -``` - ---- - -## tryCatch - -Wraps a function with hooks for start, success, error, finally. Not part of error strategies — use for side effects (logging, cleanup). For error handling in pipelines, use operations' `onError` option. - -```js -const wrapped = tryCatch(asyncFn, { - onStart: () => console.log('starting'), - onSuccess: (result) => console.log('done', result), - onError: (error) => console.error(error), - onFinally: () => cleanup() -}) - -const result = await wrapped(args) -``` - ---- - -## When to Use What - -**Need simple data transformation?** Start with `safeMap` + `safeFilter` in a `safePipe`. - -**Processing streams or large datasets?** Use `safeAsyncIterator` for lazy, memory-efficient iteration. - -**Mapping with concurrency limits?** Use `mapSeries`. + * [Architecture](docs/architecture.md) : The philosophy and design principles. + * [Guide](docs/guide.md) : Core concepts and usage patterns. + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1de736a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,15 @@ +# Design Principles & Architecture + +The Philosophy: Pragmatism Over Purity + +We built this library to be a practical tool for executing tasks, not a theoretical academic exercise. + +Many functional libraries are "Iterator-First" (lazy, yielding generators). We explicitly rejected that approach. Why? + + * Debugging is harder: Lazy execution makes stack traces difficult to read. + * Control is deferred: You don't know if an operation fails until you consume the iterator. + * Complexity: It requires users to understand generators and composition patterns just to run a simple list of tasks. + +Our Approach: Eager Execution. + +We prefer Explicit Results over Lazy Iterables. When you run series or scan, the work happens immediately. You get a structured report { results, errors, failure } back. No surprises. diff --git a/docs/docs.md b/docs/docs.md deleted file mode 100644 index c678179..0000000 --- a/docs/docs.md +++ /dev/null @@ -1,6 +0,0 @@ -# Documentation - -Markdown is good. - -```sh -``` diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..8538cb5 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,41 @@ +# Guide + +## Core + +We have four distinct tools, separated by the Direction of Data Flow and the State Dependency. + + 1. series (Horizontal / Stateless) + 2. scan (Horizontal / Stateful) + 3. filter (Horizontal / Selection) + 4. pipe (Vertical / Composition) + +## The Function Wrappers + +These are "Middleware" for your functions. They wrap a single unit of work to add behavior. + + * tryCatch (Lifecycle Middleware) + * retry (Resiliency Middleware) + +## Composition in Action + +The power of this library comes from combining these primitives. + +Example: A robust download pipeline + +```js +// 1. Define the "Work" +// pipe: Chains the logic vertically. +const pipeline = pipe( + retry(downloadTrack, 3), // Resiliency: Retry 3 times + processTrack, // Pure logic + retry(updateDb, 3), // Resiliency: Retry DB 2 times + notifyUI // Side effect +) + +// 2. Execute the "Work" +// series: Runs the pipeline horizontally over the list. +const { results, errors } = await series(tracks, pipeline, { + strategy: 'collect', // Don't stop if one track fails + onProgress: updateBar // Report global progress +}) +``` diff --git a/package.json b/package.json index 9441d69..0d042e1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "lib-template", - "version": "0.1.0", - "description": "JavaScript library template.", + "name": "pipelean", + "version": "0.2.0", + "description": "A pragmatic library for sequential async operations with first-class error handling.", "type": "module", "license": "MIT", - "homepage": "https://github.com/ildella/lib-template", + "homepage": "https://github.com/ildella/pipelean", "repository": { "type": "git", - "url": "git://github.com/ildella/lib-template.git" + "url": "git://github.com/ildella/pipelean.git" }, "author": { "name": "Daniele Dellafiore", diff --git a/src/functional.js b/src/functional.js index 853dc8e..de88a36 100644 --- a/src/functional.js +++ b/src/functional.js @@ -1,83 +1,169 @@ export const failFast = Object.freeze({name: 'failFast'}) -export const skip = Object.freeze({name: 'skip'}) export const collect = Object.freeze({name: 'collect'}) -export const none = Object.freeze({name: 'none'}) -export const safeMap = (...args) => { - const immediate = Array.isArray(args[0]) +export const tryCatch = (fn, { + onStart, onSuccess, onError, onFinally, + rethrow = false, +} = {}) => + async (...args) => { + try { + if (onStart) + onStart() + const result = await fn(...args) + if (onSuccess) + await onSuccess(result) + return result + } catch (error) { + if (onError) + await onError(error) + if (rethrow) + throw error + return null + } finally { + if (onFinally) + onFinally() + } + } + +export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +export const retry = (fn, {attempts = 3, delay: delayMs = 0} = {}) => + async (...args) => { + let lastError + for (let i = 0; i < attempts; i++) { + try { + // eslint-disable-next-line no-await-in-loop + return await fn(...args) + } catch (error) { + lastError = error + + const isLastAttempt = i === attempts - 1 + if (!isLastAttempt && delayMs > 0) { + // eslint-disable-next-line no-await-in-loop + await delay(delayMs) + } + } + } + throw lastError + } + +export const series = (...args) => { + const immediate = typeof args[0] !== 'function' const [items, fn, opts = {}] = immediate ? args : [null, args[0], args[1]] - const execute = async inputItems => { - const {onError = failFast, limit} = opts + + const run = async inputItems => { + const { + strategy = 'collect', + take, onProgress, onError, + } = opts const results = [] const errors = [] - let processed = 0 - for await (const [index, item] of inputItems.entries()) { + + // Wrap the function ONCE. + // It handles onProgress (via onSuccess) and onError (via onError). + // It rethrows so 'series' can handle the strategy. + const safeFn = tryCatch(fn, { + onSuccess: onProgress, + onError, + rethrow: true, + }) + + let index = 0 + for await (const item of inputItems) { // eslint-disable-next-line no-undefined - if (limit !== undefined && processed >= limit) + if (take !== undefined && index >= take) break + try { - results.push(await Promise.resolve(fn(item, index))) + const result = await safeFn(item, index) + results.push(result) } catch (error) { - if (onError === failFast) + if (strategy === 'failFast') { return {results, errors, failure: {item, error}} - if (onError === none) - continue + } errors.push({item, error}) } - processed += 1 + index++ } - // Return plain array for 'none' strategy, structured result otherwise - if (onError === none) - return results return {results, errors, failure: null} } - return immediate ? execute(items) : execute + + return immediate ? run(items) : run } -export const safeFilter = (...args) => { - const immediate = Array.isArray(args[0]) - const [items, predicate, opts = {}] = immediate ? args : [null, args[0], args[1]] - const execute = async inputItems => { - const {onError = failFast} = opts +export const filter = (...args) => { + const immediate = typeof args[0] !== 'function' + const [items, predicate, opts] = immediate ? args : [null, args[0], args[1]] + + // eslint-disable-next-line complexity, max-statements + const run = async inputItems => { + const {onError = 'failFast', take} = opts || {} const results = [] const errors = [] - for await (const [index, item] of inputItems.entries()) { + + let index = 0 + for await (const item of inputItems) { + // eslint-disable-next-line no-undefined + if (take !== undefined && results.length >= take) { + break + } + try { - const keep = await Promise.resolve(predicate(item, index)) - if (keep) + const keep = await predicate(item, index) + if (keep) { results.push(item) + } } catch (error) { - if (onError === failFast) + if (onError === 'failFast') { return {results, errors, failure: {item, error}} - if (onError === none) - continue + } errors.push({item, error}) } + + index++ } - // Return plain array for 'none' strategy, structured result otherwise - if (onError === none) - return results return {results, errors, failure: null} } - return immediate ? execute(items) : execute + + return immediate ? run(items) : run } -export const mapSeries = (array, asyncFn, {limit} = {}) => - safeMap(array, asyncFn, {onError: none, limit}) - -export const safePipe = (...steps) => async items => { - const allErrors = [] - let current = items - for await (const step of steps) { - const {results, errors, failure} = await step(current) - allErrors.push(...errors) - if (failure) - return {results, errors: allErrors, failure} - current = results +export const safeScan = async (iterable, scanner, initialValue) => { + const results = [] + let acc = initialValue + + for await (const item of iterable) { + try { + acc = await scanner(acc, item) + results.push(acc) + } catch (error) { + // We cannot continue if a step fails, so we return the failure immediately. + return { + results, + errors: [{item, error}], + failure: {item, error}, + } + } } - return {results: current, errors: allErrors, failure: null} + + return {results, errors: [], failure: null} } +export const scan = safeScan + +export const scanSeries = async (iterable, scanner, initialValue) => { + const {results} = await scan(iterable, scanner, initialValue) + return results +} +export const pipe = (...fns) => input => + fns.reduce(async (acc, fn) => fn(await acc), input) + +/* + "Lazy" iterator (it yields items one by one). + This forces the consumer to check every single item to + see if it's an error or a valid result + Alpha Code - not to be used yet. +*/ export async function * safeAsyncIterator (iterable, transform, { onError = failFast, } = {}) { @@ -88,68 +174,10 @@ export async function * safeAsyncIterator (iterable, transform, { } catch (error) { if (onError === failFast) throw error - if (onError === skip) - continue if (onError === collect) yield {error, item} } } } -// export const scanSeries = async (iterable, scanner, initialValue) => { -// const results = [] -// let acc = initialValue -// for await (const item of iterable) { -// acc = await scanner(acc, item) -// results.push(acc) -// } -// return results -// } - -// export const unwrapIterator = async iterator => { -// const accumulator = [] -// for await (const item of iterator) { -// accumulator.push(item) -// } -// return accumulator -// } - -export const collectAsync = async (iterable, {onError = none} = {}) => { - const results = [] - for await (const item of safeAsyncIterator(iterable, x => x, {onError})) { - results.push(item) - } - return results -} - -export const safeReduce = async (iterable, scanner, {initialValue} = {}) => { - const results = [] - let acc = initialValue - for await (const item of iterable) { - acc = await scanner(acc, item) - results.push(acc) - } - return results -} - -export const unwrapIterator = collectAsync -export const scanSeries = safeReduce - -export const tryCatch = (fn, { - onStart, onSuccess, onError, onFinally, -} = {}) => - async (...args) => { - try { - if (onStart) - onStart() - const result = await fn(...args) - if (onSuccess) - await onSuccess(result) - return result - } catch (error) { - return onError ? onError(error) : null - } finally { - if (onFinally) - onFinally() - } - } +export const collectAsync = iterator => series(iterator, x => x) diff --git a/tests/unwrap-iterator.test.js b/tests/collect.test.js similarity index 60% rename from tests/unwrap-iterator.test.js rename to tests/collect.test.js index 67dd56d..46109cd 100644 --- a/tests/unwrap-iterator.test.js +++ b/tests/collect.test.js @@ -1,5 +1,5 @@ import {test, expect} from 'vitest' -import {unwrapIterator} from '$lib/functional' +import {collectAsync} from '$src/functional' test('collects async iterator into array', async () => { const gen = async function * () { @@ -7,11 +7,13 @@ test('collects async iterator into array', async () => { yield 2 yield 3 } - await expect(unwrapIterator(gen())).resolves.toEqual([1, 2, 3]) + const {results} = await collectAsync(gen()) + expect(results).toEqual([1, 2, 3]) }) test('handles empty iterator', async () => { // eslint-disable-next-line no-empty-function const gen = async function * () {} - await expect(unwrapIterator(gen())).resolves.toEqual([]) + const {results} = await collectAsync(gen()) + expect(results).toEqual([]) }) diff --git a/tests/error-strategies.test.js b/tests/error-strategies.test.js index 2f2ee35..3aac2c2 100644 --- a/tests/error-strategies.test.js +++ b/tests/error-strategies.test.js @@ -1,23 +1,16 @@ import {test, expect} from 'vitest' -import {failFast, skip, collect} from '$lib/functional' +import {failFast, collect} from '$src/functional' test('failFast is a frozen object with name "failFast"', () => { expect(failFast).toEqual({name: 'failFast'}) expect(Object.isFrozen(failFast)).toBe(true) }) -test('skip is a frozen object with name "skip"', () => { - expect(skip).toEqual({name: 'skip'}) - expect(Object.isFrozen(skip)).toBe(true) -}) - test('collect is a frozen object with name "collect"', () => { expect(collect).toEqual({name: 'collect'}) expect(Object.isFrozen(collect)).toBe(true) }) test('strategies are distinct references', () => { - expect(failFast).not.toBe(skip) expect(failFast).not.toBe(collect) - expect(skip).not.toBe(collect) }) diff --git a/tests/safe-filter.test.js b/tests/filter.test.js similarity index 69% rename from tests/safe-filter.test.js rename to tests/filter.test.js index 81de586..f62ecb4 100644 --- a/tests/safe-filter.test.js +++ b/tests/filter.test.js @@ -1,19 +1,19 @@ import {test, expect} from 'vitest' -import {safeFilter, skip} from '$lib/functional' +import {filter, collect} from '$src/functional' test('predicate truthy keeps item in results', async () => { - const result = await safeFilter([1, 2, 3, 4], x => x > 2) + const result = await filter([1, 2, 3, 4], x => x > 2) expect(result).toEqual({results: [3, 4], errors: [], failure: null}) }) test('predicate falsy excludes item without error', async () => { - const result = await safeFilter([1, 2, 3], () => false) + const result = await filter([1, 2, 3], () => false) expect(result).toEqual({results: [], errors: [], failure: null}) }) test('predicate throws with failFast stops and populates failure', async () => { const bang = new Error('bang') - const result = await safeFilter([1, 2, 3], x => { + const result = await filter([1, 2, 3], x => { if (x === 2) throw bang return true @@ -23,35 +23,35 @@ test('predicate throws with failFast stops and populates failure', async () => { expect(result.errors).toEqual([]) }) -test('predicate throws with skip continues and collects error', async () => { +test('predicate throws with collect continues and collects error', async () => { const bang = new Error('bang') - const result = await safeFilter([1, 2, 3], x => { + const result = await filter([1, 2, 3], x => { if (x === 2) throw bang return true - }, {onError: skip}) + }, {onError: collect}) expect(result.results).toEqual([1, 3]) expect(result.errors).toEqual([{item: 2, error: bang}]) expect(result.failure).toBeNull() }) test('async predicates work', async () => { - const result = await safeFilter([1, 2, 3], x => Promise.resolve(x % 2 === 1)) + const result = await filter([1, 2, 3], x => Promise.resolve(x % 2 === 1)) expect(result.results).toEqual([1, 3]) }) test('curried form returns a function', () => { - const fn = safeFilter(x => x > 2) + const fn = filter(x => x > 2) expect(typeof fn).toBe('function') }) test('curried form executes when called with items', async () => { - const evens = safeFilter(x => x % 2 === 0) + const evens = filter(x => x % 2 === 0) const result = await evens([1, 2, 3, 4]) expect(result).toEqual({results: [2, 4], errors: [], failure: null}) }) test('empty array returns empty result shape', async () => { - const result = await safeFilter([], () => true) + const result = await filter([], () => true) expect(result).toEqual({results: [], errors: [], failure: null}) }) diff --git a/tests/map-series.test.js b/tests/map-series.test.js deleted file mode 100644 index 3a9da41..0000000 --- a/tests/map-series.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import {test, expect} from 'vitest' -import {mapSeries} from '$lib/functional' - -test('processes items sequentially and returns results', async () => { - const order = [] - const results = await mapSeries([1, 2, 3], item => { - order.push(item) - return Promise.resolve(item * 2) - }) - expect(results).toEqual([2, 4, 6]) - expect(order).toEqual([1, 2, 3]) -}) - -test('respects limit option', async () => { - const results = await mapSeries( - [1, 2, 3, 4, 5], - item => Promise.resolve(item * 10), - {limit: 3}, - ) - expect(results).toEqual([10, 20, 30]) -}) - -test('returns empty array for empty input', async () => { - await expect( - mapSeries([], item => Promise.resolve(item)), - ).resolves.toEqual([]) -}) diff --git a/tests/pipe.test.js b/tests/pipe.test.js new file mode 100644 index 0000000..71dac6e --- /dev/null +++ b/tests/pipe.test.js @@ -0,0 +1,37 @@ +import {test, expect} from 'vitest' +import {pipe} from '$src/functional' + +test('composes functions left-to-right', async () => { + // Must await because pipe is now async-safe + const result = await pipe(x => x * 2, x => x + 1)(5) + expect(result).toBe(11) +}) +test('passes result through chain', async () => { + const result = await pipe( + x => x + 1, + x => x * 3, + x => x - 2, + )(2) + expect(result).toBe(7) +}) + +test('works with single function', async () => { + await expect(pipe(x => x * 10)(4)).resolves.toBe(40) +}) + +test('propagates errors', async () => { + const pipeline = pipe( + () => { throw new Error('pipe broke') }, + x => x + 1, + ) + // Promise rejection must be caught with rejects + await expect(pipeline(1)).rejects.toThrow('pipe broke') +}) + +test('passes result through async chain', async () => { + const result = await pipe( + x => Promise.resolve(x + 1), + x => Promise.resolve(x * 3), + )(2) + expect(result).toBe(9) +}) diff --git a/tests/retry.test.js b/tests/retry.test.js new file mode 100644 index 0000000..87ee1e7 --- /dev/null +++ b/tests/retry.test.js @@ -0,0 +1,72 @@ +import {test, expect, vi} from 'vitest' +import {retry} from '$src/functional' + +test('succeeds on first attempt (default)', async () => { + const fn = vi.fn().mockResolvedValue('ok') + const result = await retry(fn)() + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(1) +}) + +test('succeeds after retries', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')) + .mockResolvedValue('ok') + + const result = await retry(fn, {attempts: 3})() + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(3) +}) + +test('throws if all attempts fail', async () => { + const error = new Error('persistent failure') + const fn = vi.fn().mockRejectedValue(error) + + await expect(retry(fn, {attempts: 2})()).rejects.toThrow('persistent failure') + expect(fn).toHaveBeenCalledTimes(2) +}) + +test('respects delay between retries', async () => { + vi.useFakeTimers() + const fn = vi.fn() + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValue('ok') + + const retryFn = retry(fn, {attempts: 2, delay: 1000}) + + // Start execution + const promise = retryFn() + + // Allow first attempt to fail (flush microtasks) + await Promise.resolve() + // At this point, the delay should have started + expect(fn).toHaveBeenCalledTimes(1) + + // Advance time by 1000ms + await vi.advanceTimersByTimeAsync(1000) + + // Now the second attempt should run + const result = await promise + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(2) + + vi.useRealTimers() +}) + +test('does not delay after last attempt fails', async () => { + vi.useFakeTimers() + const fn = vi.fn().mockRejectedValue(new Error('fail')) + const retryFn = retry(fn, {attempts: 2, delay: 200}) + + const promise = retryFn() + + // Use Promise.all to attach the assertion handler immediately + // while also advancing the timers. + await Promise.all([ + vi.advanceTimersByTimeAsync(200), + expect(promise).rejects.toThrow('fail'), + ]) + + vi.useRealTimers() +}) diff --git a/tests/safe-async-iterator.test.js b/tests/safe-async-iterator.test.js deleted file mode 100644 index c0b46b5..0000000 --- a/tests/safe-async-iterator.test.js +++ /dev/null @@ -1,344 +0,0 @@ -import {test, expect} from 'vitest' -import { - safeAsyncIterator, failFast, skip, collect, unwrapIterator, -} from '$lib/functional' - -test('safeAsyncIterator: yields transformed items', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator(source(), x => x * 2) - const results = await unwrapIterator(iterator) - expect(results).toEqual([2, 4, 6]) -}) - -test('safeAsyncIterator: works with async transform', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - async x => { - await new Promise(resolve => setTimeout(resolve, 10)) - return x * 2 - } - ) - const results = await unwrapIterator(iterator) - expect(results).toEqual([2, 4, 6]) -}) - -test('safeAsyncIterator: works with arrays', async () => { - const iterator = safeAsyncIterator([1, 2, 3], x => x + 10) - const results = await unwrapIterator(iterator) - expect(results).toEqual([11, 12, 13]) -}) - -// ============================================================================ -// ERROR HANDLING - failFast -// ============================================================================ - -test('safeAsyncIterator: failFast throws on first error', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2) - throw new Error('error at 2') - return x * 2 - }, - {onError: failFast} - ) - - const results = [] - let error = null - try { - for await (const item of iterator) { - results.push(item) - } - } catch (e) { - error = e - } - - expect(results).toEqual([2]) - expect(error).not.toBeNull() - expect(error.message).toBe('error at 2') -}) - -test('safeAsyncIterator: failFast with async transform', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - async x => { - if (x === 2) - throw new Error('async error') - return x * 2 - }, - {onError: failFast} - ) - - const results = [] - let error = null - try { - for await (const item of iterator) { - results.push(item) - } - } catch (e) { - error = e - } - - expect(results).toEqual([2]) - expect(error.message).toBe('async error') -}) - -// ============================================================================ -// ERROR HANDLING - skip -// ============================================================================ - -test('safeAsyncIterator: skip continues on error', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2 || x === 4) - throw new Error('skip this') - return x * 2 - }, - {onError: skip} - ) - - const results = await unwrapIterator(iterator) - expect(results).toEqual([2, 6]) -}) - -test('safeAsyncIterator: skip with async transform', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - async x => { - if (x === 2) - throw new Error('skip') - return x * 10 - }, - {onError: skip} - ) - - const results = await unwrapIterator(iterator) - expect(results).toEqual([10, 30]) -}) - -// ============================================================================ -// ERROR HANDLING - collect -// ============================================================================ - -test('safeAsyncIterator: collect yields error objects', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2) - throw new Error('error at 2') - return x * 2 - }, - {onError: collect} - ) - - const results = await unwrapIterator(iterator) - expect(results).toHaveLength(3) - expect(results[0]).toBe(2) - expect(results[1]).toEqual({error: expect.any(Error), item: 2}) - expect(results[1].error.message).toBe('error at 2') - expect(results[2]).toBe(6) -}) - -test('safeAsyncIterator: collect multiple errors', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x % 2 === 0) - throw new Error(`error at ${x}`) - return x * 10 - }, - {onError: collect} - ) - - const results = await unwrapIterator(iterator) - expect(results).toHaveLength(4) - expect(results[0]).toBe(10) - expect(results[1]).toEqual({error: expect.any(Error), item: 2}) - expect(results[2]).toBe(30) - expect(results[3]).toEqual({error: expect.any(Error), item: 4}) -}) - -// ============================================================================ -// LAZY EVALUATION (core generator benefit) -// ============================================================================ - -test('safeAsyncIterator: lazy evaluation - stops early', async () => { - const calls = [] - async function * source () { - for (let i = 1; i <= 10; i++) { - calls.push(i) - yield i - } - } - - const iterator = safeAsyncIterator(source(), x => x * 2) - - const results = [] - let count = 0 - for await (const item of iterator) { - results.push(item) - count += 1 - if (count === 3) - break - } - - expect(results).toEqual([2, 4, 6]) - expect(calls).toEqual([1, 2, 3]) -}) - -test('safeAsyncIterator: lazy evaluation with map then filter pattern', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - yield 5 - } - - const mapped = safeAsyncIterator(source(), x => x * 2) - const filtered = safeAsyncIterator( - mapped, - x => x > 4 ? x : undefined - ) - - const results = [] - for await (const item of filtered) { - if (item !== undefined) - results.push(item) - } - - expect(results).toEqual([6, 8, 10]) -}) - -// ============================================================================ -// PRACTICAL SCENARIOS -// ============================================================================ - -test('safeAsyncIterator: processing file-like stream with enrichment', async () => { - async function * fileStream () { - yield {id: 1, name: 'file1.txt'} - yield {id: 2, name: 'file2.txt'} - yield {id: 3, name: 'file3.txt'} - } - - const mockFetchMetadata = async id => { - if (id === 2) - throw new Error('API error') - return {id, size: id * 100} - } - - const enriched = safeAsyncIterator( - fileStream(), - async item => ({ - ...item, - meta: await mockFetchMetadata(item.id), - }), - {onError: collect} - ) - - const results = await unwrapIterator(enriched) - expect(results).toHaveLength(3) - expect(results[0]).toEqual({id: 1, name: 'file1.txt', meta: {id: 1, size: 100}}) - expect(results[1]).toEqual({error: expect.any(Error), item: {id: 2, name: 'file2.txt'}}) - expect(results[2]).toEqual({id: 3, name: 'file3.txt', meta: {id: 3, size: 300}}) -}) - -test('safeAsyncIterator: can be composed with filter', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - yield 4 - yield 5 - } - - const mapped = safeAsyncIterator(source(), x => x * 2) - - const results = [] - for await (const item of mapped) { - if (item > 4) - results.push(item) - } - - expect(results).toEqual([6, 8, 10]) -}) - -test('safeAsyncIterator: default error strategy is failFast', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const iterator = safeAsyncIterator( - source(), - x => { - if (x === 2) - throw new Error('error') - return x * 2 - } - ) - - const results = [] - let error = null - try { - for await (const item of iterator) { - results.push(item) - } - } catch (e) { - error = e - } - - expect(results).toEqual([2]) - expect(error).not.toBeNull() -}) diff --git a/tests/safe-map-limit.test.js b/tests/safe-map-limit.test.js deleted file mode 100644 index 86e47d9..0000000 --- a/tests/safe-map-limit.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import {test, expect} from 'vitest' -import { - safeMap, skip, collect, failFast, -} from '$lib/functional' - -test('safeMap: immediate call with sync function', async () => { - const {results, errors, failure} = await safeMap( - [1, 2, 3], - x => x * 2 - ) - expect(results).toEqual([2, 4, 6]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: immediate call with async function', async () => { - const {results, errors, failure} = await safeMap( - [1, 2, 3], - async x => { - await new Promise(resolve => setTimeout(resolve, 10)) - return x * 2 - } - ) - expect(results).toEqual([2, 4, 6]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: curried usage', async () => { - const double = safeMap(x => x * 2) - const {results} = await double([1, 2, 3]) - expect(results).toEqual([2, 4, 6]) -}) - -test('safeMap: receives item and index', async () => { - const {results} = await safeMap( - [10, 20, 30], - (item, index) => ({item, index}) - ) - expect(results).toEqual([ - {item: 10, index: 0}, - {item: 20, index: 1}, - {item: 30, index: 2}, - ]) -}) - -// ============================================================================ -// LIMIT OPTION -// ============================================================================ - -test('safeMap: respects limit option (positive)', async () => { - const {results, errors, failure} = await safeMap( - [1, 2, 3, 4, 5], - item => item * 10, - {limit: 3} - ) - expect(results).toEqual([10, 20, 30]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: limit 1 processes only first item', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - x => x * 10, - {limit: 1} - ) - expect(results).toEqual([10]) -}) - -test('safeMap: limit 0 returns empty results', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - x => x * 10, - {limit: 0} - ) - expect(results).toEqual([]) -}) - -test('safeMap: negative limit returns empty results', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - x => x * 10, - {limit: -1} - ) - expect(results).toEqual([]) -}) - -test('safeMap: limit greater than array length processes all', async () => { - const {results} = await safeMap( - [1, 2, 3], - x => x * 10, - {limit: 100} - ) - expect(results).toEqual([10, 20, 30]) -}) - -test('safeMap: limit with async function', async () => { - const {results} = await safeMap( - [1, 2, 3, 4, 5], - async x => { - await new Promise(resolve => setTimeout(resolve, 5)) - return x * 10 - }, - {limit: 2} - ) - expect(results).toEqual([10, 20]) -}) - -// ============================================================================ -// ERROR STRATEGIES WITH LIMIT -// ============================================================================ - -test('safeMap: failFast with limit stops at error', async () => { - const {results, failure} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x === 2) - throw new Error('error at 2') - return x * 10 - }, - {limit: 5, onError: failFast} - ) - expect(results).toEqual([10]) - expect(failure).not.toBeNull() - expect(failure.item).toBe(2) -}) - -test('safeMap: skip with limit continues past error', async () => { - const {results, errors} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x === 2 || x === 4) - throw new Error(`error at ${x}`) - return x * 10 - }, - {limit: 5, onError: skip} - ) - expect(results).toEqual([10, 30, 50]) - expect(errors).toHaveLength(2) -}) - -test('safeMap: collect with limit includes error objects', async () => { - const {results, errors} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x % 2 === 0) - throw new Error(`even: ${x}`) - return x * 10 - }, - {limit: 5, onError: collect} - ) - expect(results).toEqual([10, 30, 50]) - expect(errors).toHaveLength(2) -}) - -test('safeMap: limit applies before error strategy evaluation', async () => { - const {results, errors} = await safeMap( - [1, 2, 3, 4, 5], - x => { - if (x > 2) - throw new Error(`error at ${x}`) - return x * 10 - }, - {limit: 3, onError: collect} - ) - // Only processes first 3 items [1, 2, 3] - expect(results).toEqual([10, 20]) - expect(errors).toHaveLength(1) -}) - -// ============================================================================ -// EDGE CASES -// ============================================================================ - -test('safeMap: empty array', async () => { - const {results, errors, failure} = await safeMap([], x => x * 2) - expect(results).toEqual([]) - expect(errors).toEqual([]) - expect(failure).toBeNull() -}) - -test('safeMap: empty array with limit', async () => { - const {results} = await safeMap([], x => x * 2, {limit: 5}) - expect(results).toEqual([]) -}) - -test('safeMap: limit 0 with error strategy', async () => { - const {results, errors} = await safeMap( - [1, 2, 3], - x => { - if (x === 1) - throw new Error('error') - return x * 10 - }, - {limit: 0, onError: collect} - ) - expect(results).toEqual([]) - expect(errors).toEqual([]) -}) diff --git a/tests/safe-pipe.test.js b/tests/safe-pipe.test.js deleted file mode 100644 index faafc48..0000000 --- a/tests/safe-pipe.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import {test, expect} from 'vitest' -import { - safePipe, safeMap, safeFilter, skip, -} from '$lib/functional' - -test('chains two safeMap steps threading results through', async () => { - const pipeline = safePipe( - safeMap(x => x * 2), - safeMap(x => x + 1), - ) - const result = await pipeline([1, 2, 3]) - expect(result).toEqual({results: [3, 5, 7], errors: [], failure: null}) -}) - -test('errors from all steps merge into single errors array', async () => { - const bang1 = new Error('step1') - const bang2 = new Error('step2') - const pipeline = safePipe( - safeMap(x => { - if (x === 2) - throw bang1 - return x - }, {onError: skip}), - safeMap(x => { - if (x === 3) - throw bang2 - return x * 10 - }, {onError: skip}), - ) - const result = await pipeline([1, 2, 3]) - expect(result.results).toEqual([10]) - expect(result.errors).toEqual([ - {item: 2, error: bang1}, - {item: 3, error: bang2}, - ]) - expect(result.failure).toBeNull() -}) - -test('failure in step 1 skips step 2 and returns failure', async () => { - const bang = new Error('boom') - const step2Called = [] - const pipeline = safePipe( - safeMap(x => { - if (x === 2) - throw bang - return x - }), - safeMap(x => { - step2Called.push(x) - return x - }), - ) - const result = await pipeline([1, 2, 3]) - expect(result.failure).toEqual({item: 2, error: bang}) - expect(result.results).toEqual([1]) - expect(step2Called).toEqual([]) -}) - -test('works with mixed safeMap and safeFilter steps', async () => { - const pipeline = safePipe( - safeMap(x => x * 2), - safeFilter(x => x > 3), - ) - const result = await pipeline([1, 2, 3]) - expect(result).toEqual({results: [4, 6], errors: [], failure: null}) -}) - -test('single step pipeline', async () => { - const pipeline = safePipe( - safeMap(x => x + 1), - ) - const result = await pipeline([10, 20]) - expect(result).toEqual({results: [11, 21], errors: [], failure: null}) -}) - -test('empty pipeline returns items as results', async () => { - const pipeline = safePipe() - const result = await pipeline([1, 2, 3]) - expect(result).toEqual({results: [1, 2, 3], errors: [], failure: null}) -}) diff --git a/tests/safe-reduce.test.js b/tests/safe-reduce.test.js deleted file mode 100644 index 46fd112..0000000 --- a/tests/safe-reduce.test.js +++ /dev/null @@ -1,213 +0,0 @@ -import {test, expect} from 'vitest' -import {safeReduce, skip, collect} from '$lib/functional' - -// ============================================================================ -// WITH INITIAL VALUE -// ============================================================================ - -test('safeReduce: threads accumulator through items', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => Promise.resolve(acc + item), - {initialValue: 0} - ) - expect(results).toEqual([1, 3, 6]) -}) - -test('safeReduce: returns intermediate results', async () => { - const results = await safeReduce( - ['a', 'b', 'c'], - (acc, item) => Promise.resolve(acc + item), - {initialValue: ''} - ) - expect(results).toEqual(['a', 'ab', 'abc']) -}) - -test('safeReduce: works with async reducer', async () => { - const results = await safeReduce( - [10, 20], - async (acc, item) => { - await new Promise(resolve => setTimeout(resolve, 1)) - return acc + item - }, - {initialValue: 0} - ) - expect(results).toEqual([10, 30]) -}) - -test('safeReduce: works with sync reducer', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => acc * item, - {initialValue: 1} - ) - expect(results).toEqual([1, 2, 6]) -}) - -// ============================================================================ -// WITHOUT INITIAL VALUE -// ============================================================================ - -test.skip('safeReduce: uses first item as initial when not provided', async () => { - const results = await safeReduce( - [1, 2, 3, 4], - (acc, item) => acc + item - ) - expect(results).toEqual([1, 3, 6, 10]) -}) - -test.skip('safeReduce: single item without initialValue', async () => { - const results = await safeReduce( - [42], - (acc, item) => acc + item - ) - expect(results).toEqual([42]) -}) - -test.skip('safeReduce: string concatenation without initialValue', async () => { - const results = await safeReduce( - ['hello', ' ', 'world'], - (acc, item) => acc + item - ) - expect(results).toEqual(['hello', 'hello ', 'hello world']) -}) - -test('safeReduce: object merging without initialValue', async () => { - const results = await safeReduce( - [{a: 1}, {b: 2}, {c: 3}], - (acc, item) => ({...acc, ...item}) - ) - expect(results).toEqual([ - {a: 1}, - {a: 1, b: 2}, - {a: 1, b: 2, c: 3}, - ]) -}) - -test.skip('safeReduce: async reducer without initialValue', async () => { - const results = await safeReduce( - [1, 2, 3], - async (acc, item) => { - await new Promise(resolve => setTimeout(resolve, 1)) - return acc + item - } - ) - expect(results).toEqual([1, 3, 6]) -}) - -// ============================================================================ -// ERROR STRATEGIES WITH INITIAL VALUE -// ============================================================================ - -test.skip('safeReduce: skip strategy tracks errors but continues', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => { - if (item === 2) - throw new Error('error') - return acc + item - }, - {initialValue: 0, onError: skip} - ) - expect(results.results).toEqual([1, 4]) - expect(results.errors).toHaveLength(1) -}) - -test.skip('safeReduce: collect strategy yields error objects', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => { - if (item === 2) - throw new Error('error at 2') - return acc + item - }, - {initialValue: 0, onError: collect} - ) - expect(results).toHaveLength(3) - expect(results[0]).toBe(1) - expect(results[1]).toEqual({error: expect.any(Error), item: 2}) - expect(results[2]).toBe(4) -}) - -// ============================================================================ -// ERROR STRATEGIES WITHOUT INITIAL VALUE -// ============================================================================ - -test.skip('safeReduce: collect strategy without initialValue', async () => { - const results = await safeReduce( - ['a', 'b', 'c'], - (acc, item) => { - if (item === 'b') - throw new Error('error') - return acc + item - }, - {onError: collect} - ) - expect(results).toHaveLength(3) - expect(results[0]).toBe('a') - expect(results[1]).toEqual({error: expect.any(Error), item: 'b'}) - expect(results[2]).toBe('ac') -}) - -// ============================================================================ -// EDGE CASES -// ============================================================================ - -test('safeReduce: empty iterable with initialValue', async () => { - const results = await safeReduce( - [], - (acc, item) => acc + item, - {initialValue: 0} - ) - expect(results).toEqual([]) -}) - -test('safeReduce: empty iterable without initialValue', async () => { - const results = await safeReduce( - [], - (acc, item) => acc + item - ) - expect(results).toEqual([]) -}) - -test('safeReduce: works with async generators', async () => { - async function * source () { - yield 1 - yield 2 - yield 3 - } - - const results = await safeReduce( - source(), - (acc, item) => acc + item, - {initialValue: 0} - ) - expect(results).toEqual([1, 3, 6]) -}) - -test('safeReduce: initialValue of 0 works correctly', async () => { - const results = await safeReduce( - [1, 2, 3], - (acc, item) => acc + item, - {initialValue: 0} - ) - expect(results).toEqual([1, 3, 6]) -}) - -test('safeReduce: initialValue of empty string works correctly', async () => { - const results = await safeReduce( - ['a', 'b'], - (acc, item) => acc + item, - {initialValue: ''} - ) - expect(results).toEqual(['a', 'ab']) -}) - -test('safeReduce: initialValue of false works correctly', async () => { - const results = await safeReduce( - [true, false, true], - (acc, item) => acc || item, - {initialValue: false} - ) - expect(results).toEqual([true, true, true]) -}) diff --git a/tests/scan.test.js b/tests/scan.test.js new file mode 100644 index 0000000..657ea5c --- /dev/null +++ b/tests/scan.test.js @@ -0,0 +1,32 @@ +import {test, expect} from 'vitest' +import {scanSeries} from '$src/functional' + +test('threads accumulator through items', async () => { + const results = await scanSeries( + [1, 2, 3], + (acc, item) => Promise.resolve(acc + item), + 0, + ) + expect(results).toEqual([1, 3, 6]) +}) + +test('returns intermediate results', async () => { + const results = await scanSeries( + ['a', 'b', 'c'], + (acc, item) => Promise.resolve(acc + item), + '', + ) + expect(results).toEqual(['a', 'ab', 'abc']) +}) + +test('works with async scanner', async () => { + const results = await scanSeries( + [10, 20], + async (acc, item) => { + await new Promise(resolve => setTimeout(resolve, 1)) + return acc + item + }, + 0, + ) + expect(results).toEqual([10, 30]) +}) diff --git a/tests/safe-map.test.js b/tests/series.test.js similarity index 63% rename from tests/safe-map.test.js rename to tests/series.test.js index 72da128..dd4c39e 100644 --- a/tests/safe-map.test.js +++ b/tests/series.test.js @@ -1,57 +1,43 @@ import {test, expect} from 'vitest' -import { - safeMap, skip, collect, -} from '$lib/functional' +import {series, collect} from '$src/functional' test('all items succeed returns results with no errors', async () => { - const result = await safeMap([1, 2, 3], x => x * 2) + const result = await series([1, 2, 3], x => x * 2) expect(result).toEqual({results: [2, 4, 6], errors: [], failure: null}) }) test('failFast stops on first error with partial results', async () => { const bang = new Error('bang') - const result = await safeMap([1, 2, 3], x => { + const result = await series([1, 2, 3], x => { if (x === 2) throw bang return x * 10 - }) + }, {strategy: 'failFast'}) expect(result.results).toEqual([10]) expect(result.failure).toEqual({item: 2, error: bang}) expect(result.errors).toEqual([]) }) -test('skip continues past errors and collects them', async () => { - const bang = new Error('bang') - const result = await safeMap([1, 2, 3], x => { - if (x === 2) - throw bang - return x * 10 - }, {onError: skip}) - expect(result.results).toEqual([10, 30]) - expect(result.errors).toEqual([{item: 2, error: bang}]) - expect(result.failure).toBeNull() -}) - test('collect continues past errors same as skip', async () => { const bang = new Error('bang') - const result = await safeMap([1, 2, 3], x => { + const result = await series([1, 2, 3], x => { if (x === 2) throw bang return x * 10 - }, {onError: collect}) + }, {strategy: collect}) expect(result.results).toEqual([10, 30]) expect(result.errors).toEqual([{item: 2, error: bang}]) expect(result.failure).toBeNull() }) test('async mapping functions work', async () => { - const result = await safeMap([1, 2], x => Promise.resolve(x + 100)) + const result = await series([1, 2], x => Promise.resolve(x + 100)) expect(result.results).toEqual([101, 102]) }) test('passes index as second arg to fn', async () => { const indices = [] - await safeMap([10, 20, 30], (_item, index) => { + await series([10, 20, 30], (_item, index) => { indices.push(index) return index }) @@ -59,17 +45,17 @@ test('passes index as second arg to fn', async () => { }) test('empty array returns empty result shape', async () => { - const result = await safeMap([], x => x) + const result = await series([], x => x) expect(result).toEqual({results: [], errors: [], failure: null}) }) test('curried form returns a function', () => { - const fn = safeMap(x => x * 2) + const fn = series(x => x * 2) expect(typeof fn).toBe('function') }) test('curried form executes when called with items', async () => { - const double = safeMap(x => x * 2) + const double = series(x => x * 2) const result = await double([1, 2, 3]) expect(result).toEqual({results: [2, 4, 6], errors: [], failure: null}) }) diff --git a/tests/try-catch.test.js b/tests/try-catch.test.js index 3dcfb43..db4c27a 100644 --- a/tests/try-catch.test.js +++ b/tests/try-catch.test.js @@ -1,5 +1,5 @@ import {test, expect, vi} from 'vitest' -import {tryCatch} from '$lib/functional' +import {tryCatch} from '$src/functional' test('returns fn result on success', async () => { const wrapped = tryCatch(() => Promise.resolve(42)) @@ -14,9 +14,8 @@ test('returns null on error when no onError provided', async () => { test('returns onError result on error', async () => { const wrapped = tryCatch( () => Promise.reject(new Error('boom')), - {onError: () => 'fallback'}, ) - await expect(wrapped()).resolves.toBe('fallback') + await expect(wrapped()).resolves.toBeNull() }) test('passes error to onError', async () => { diff --git a/vitest.config.js b/vitest.config.js index 290d52d..1e97769 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -5,12 +5,14 @@ export default defineConfig({ resolve: { alias: { $src: resolve(import.meta.dirname, 'src'), - $lib: resolve(import.meta.dirname, 'src'), }, }, test: { environment: 'node', globals: true, + testTimeout: 800, + hookTimeout: 1200, + teardownTimeout: 1200, reporters: ['verbose'], }, }) diff --git a/yarn.lock b/yarn.lock index 0717d13..0d1ca9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1376,21 +1376,6 @@ __metadata: languageName: node linkType: hard -"lib-template@workspace:.": - version: 0.0.0-use.local - resolution: "lib-template@workspace:." - dependencies: - "@eslint/compat": "npm:2.0.3" - "@eslint/js": "npm:9.39.4" - "@stylistic/eslint-plugin-js": "npm:3.1.0" - "@vitest/eslint-plugin": "npm:1.6.12" - eslint: "npm:9.39.4" - eslint-nostandard: "npm:0.5.0" - globals: "npm:15.15.0" - vitest: "npm:4.1.0" - languageName: unknown - linkType: soft - "lightningcss-android-arm64@npm:1.32.0": version: 1.32.0 resolution: "lightningcss-android-arm64@npm:1.32.0" @@ -1826,6 +1811,21 @@ __metadata: languageName: node linkType: hard +"pipelean@workspace:.": + version: 0.0.0-use.local + resolution: "pipelean@workspace:." + dependencies: + "@eslint/compat": "npm:2.0.3" + "@eslint/js": "npm:9.39.4" + "@stylistic/eslint-plugin-js": "npm:3.1.0" + "@vitest/eslint-plugin": "npm:1.6.12" + eslint: "npm:9.39.4" + eslint-nostandard: "npm:0.5.0" + globals: "npm:15.15.0" + vitest: "npm:4.1.0" + languageName: unknown + linkType: soft + "postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8"