diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6afbc47..0e2cbf6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,5 +52,12 @@ jobs:
files: coverage/cobertura-coverage.xml
fail_ci_if_error: true
+ - name: Upload test results to Codecov
+ if: ${{ !cancelled() }}
+ uses: codecov/test-results-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: test-report.junit.xml
+
- name: Lint (Biome)
run: npm run check
diff --git a/README.md b/README.md
index 27cc9e3..6fc82cd 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
[](https://www.npmjs.com/package/tsentials)
[](https://www.npmjs.com/package/tsentials)
[](https://bundlephobia.com/package/tsentials)
-[](./tests)
+[](./tests)
[](https://github.com/senrecep/tsentials/actions)
[](./LICENSE)
[](https://www.typescriptlang.org/)
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..1d7be51
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,15 @@
+coverage:
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 0%
+ base: auto
+
+comment:
+ layout: "diff, flags, files"
+ behavior: default
+ require_changes: false
+ require_base: false
+ require_head: true
+ hide_project_coverage: false
diff --git a/docs/index.html b/docs/index.html
index c18ec64..c4a1f7b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -191,7 +191,7 @@
tsentials
-
+
diff --git a/tests/clone/cloneable.test.ts b/tests/clone/cloneable.test.ts
index e9c4e85..2776a85 100644
--- a/tests/clone/cloneable.test.ts
+++ b/tests/clone/cloneable.test.ts
@@ -419,4 +419,119 @@ describe('deepClone fallback (without native structuredClone)', () => {
expect(copy.value.valueOf()).toBe(BigInt(99));
expect(copy.value).not.toBe(obj.value);
});
+
+ it('fallback: clones Error with object cause (recursive clone of cause)', () => {
+ const cause = { reason: 'network', code: 500 };
+ const err = new Error('request failed');
+ (err as Error & { cause?: unknown }).cause = cause;
+ const copy = deepClone(err);
+ expect(copy.message).toBe('request failed');
+ const copyCause = (copy as Error & { cause?: unknown }).cause as typeof cause;
+ expect(copyCause).toEqual(cause);
+ expect(copyCause).not.toBe(cause);
+ });
+
+ it('fallback: clones Error with primitive cause (string)', () => {
+ const err = new Error('bad state');
+ (err as Error & { cause?: unknown }).cause = 'timeout';
+ const copy = deepClone(err);
+ expect(copy.message).toBe('bad state');
+ expect((copy as Error & { cause?: unknown }).cause).toBe('timeout');
+ });
+
+ it('fallback: clones DataView', () => {
+ const buffer = new ArrayBuffer(8);
+ new Uint8Array(buffer).set([1, 2, 3, 4, 5, 6, 7, 8]);
+ const view = new DataView(buffer, 2, 4);
+ const copy = deepClone(view);
+ expect(copy).toBeInstanceOf(DataView);
+ expect(copy.byteLength).toBe(4);
+ expect(copy.buffer).not.toBe(buffer);
+ expect(copy.getUint8(0)).toBe(3);
+ });
+
+ it('fallback: clones Boolean wrapper object', () => {
+ const obj = Object(true);
+ const copy = deepClone(obj);
+ expect(copy.valueOf()).toBe(true);
+ expect(copy).not.toBe(obj);
+ });
+
+ it('fallback: clones Number wrapper object', () => {
+ const obj = Object(42);
+ const copy = deepClone(obj);
+ expect(copy.valueOf()).toBe(42);
+ expect(copy).not.toBe(obj);
+ });
+
+ it('fallback: clones String wrapper object', () => {
+ const obj = Object('hello');
+ const copy = deepClone(obj);
+ expect(copy.valueOf()).toBe('hello');
+ expect(copy).not.toBe(obj);
+ });
+
+ it('fallback: clones Error with custom enumerable properties', () => {
+ const err = new Error('bad input');
+ (err as Error & { code?: string; statusCode?: number }).code = 'INVALID';
+ (err as Error & { code?: string; statusCode?: number }).statusCode = 400;
+ const copy = deepClone(err);
+ expect(copy.message).toBe('bad input');
+ expect((copy as Error & { code?: string }).code).toBe('INVALID');
+ expect((copy as Error & { statusCode?: number }).statusCode).toBe(400);
+ });
+
+ it('fallback: deep clones Array values', () => {
+ const arr = [1, 'hello', { x: 42 }];
+ const copy = deepClone(arr);
+ expect(copy).not.toBe(arr);
+ expect(copy).toEqual(arr);
+ expect(copy[2]).not.toBe(arr[2]);
+ });
+
+ it('fallback: preserves sparse array holes in Array case', () => {
+ // biome-ignore lint/suspicious/noSparseArray: intentionally testing sparse array behavior
+ const sparse = [10, , 30] as unknown[];
+ const copy = deepClone(sparse);
+ expect(copy).toHaveLength(3);
+ expect(0 in copy).toBe(true);
+ expect(1 in copy).toBe(false);
+ expect(2 in copy).toBe(true);
+ expect(copy[0]).toBe(10);
+ expect(copy[2]).toBe(30);
+ });
+
+ it('fallback: deep clones RegExp', () => {
+ const re = /foo\d+/gi;
+ const copy = deepClone(re);
+ expect(copy).not.toBe(re);
+ expect(copy.source).toBe('foo\\d+');
+ expect(copy.flags).toBe('gi');
+ });
+
+ it('fallback: returns same reference for SharedArrayBuffer', () => {
+ const sab = new SharedArrayBuffer(4);
+ const copy = deepClone(sab);
+ expect(copy).toBe(sab);
+ });
+
+ it('fallback: clones TypedArray backed by SharedArrayBuffer (non-ArrayBuffer path)', () => {
+ const sab = new SharedArrayBuffer(4);
+ const view = new Uint8Array(sab);
+ view[0] = 7;
+ view[1] = 8;
+ const copy = deepClone(view);
+ expect(copy).toBeInstanceOf(Uint8Array);
+ expect(copy[0]).toBe(7);
+ expect(copy[1]).toBe(8);
+ });
+
+ it('fallback: clones DataView backed by SharedArrayBuffer (non-ArrayBuffer path)', () => {
+ const sab = new SharedArrayBuffer(4);
+ const view = new DataView(sab);
+ view.setUint8(0, 42);
+ const copy = deepClone(view);
+ expect(copy).toBeInstanceOf(DataView);
+ expect(copy.getUint8(0)).toBe(42);
+ });
});
diff --git a/tests/entity/entity-base.test.ts b/tests/entity/entity-base.test.ts
index a273099..cf429b7 100644
--- a/tests/entity/entity-base.test.ts
+++ b/tests/entity/entity-base.test.ts
@@ -1,5 +1,5 @@
import type { DomainEvent } from '../../src/entity/domain-event.js';
-import { createEntityBase } from '../../src/entity/entity-base.js';
+import { createEntityBase, createEntityBaseWithId } from '../../src/entity/entity-base.js';
import { createSoftDeletable } from '../../src/entity/soft-deletable.js';
interface UserCreatedEvent extends DomainEvent {
@@ -125,6 +125,50 @@ describe('createEntityBase', () => {
});
});
+describe('createEntityBaseWithId', () => {
+ it('initializes with a provided id', () => {
+ const entity = createEntityBaseWithId('abc-123');
+ expect(entity.id).toBe('abc-123');
+ });
+
+ it('initializes with a numeric id', () => {
+ const entity = createEntityBaseWithId(42);
+ expect(entity.id).toBe(42);
+ });
+
+ it('throws when id is not set and accessed', () => {
+ const entity = createEntityBaseWithId();
+ expect(() => entity.id).toThrow('Entity ID has not been set.');
+ });
+
+ it('sets id via setId', () => {
+ const entity = createEntityBaseWithId();
+ entity.setId('new-id');
+ expect(entity.id).toBe('new-id');
+ });
+
+ it('overwrites id via setId', () => {
+ const entity = createEntityBaseWithId('original');
+ entity.setId('updated');
+ expect(entity.id).toBe('updated');
+ });
+
+ it('initializes createdAt to epoch via base', () => {
+ const entity = createEntityBaseWithId('init');
+ expect(entity.createdAt).toEqual(new Date(0));
+ });
+
+ it('initializes updatedAt to undefined via base', () => {
+ const entity = createEntityBaseWithId('init');
+ expect(entity.updatedAt).toBeUndefined();
+ });
+
+ it('initializes domainEvents to empty via base', () => {
+ const entity = createEntityBaseWithId('init');
+ expect(entity.domainEvents).toHaveLength(0);
+ });
+});
+
describe('createSoftDeletable', () => {
it('initializes as not deleted', () => {
const sd = createSoftDeletable();
diff --git a/tests/http/request-builder.test.ts b/tests/http/request-builder.test.ts
index fee2499..05e0e82 100644
--- a/tests/http/request-builder.test.ts
+++ b/tests/http/request-builder.test.ts
@@ -338,4 +338,64 @@ describe('RequestBuilder fluent API', () => {
expect(result.value.name).toBe('Alice');
}
});
+
+ it('sends POST without body (null body fallback)', async () => {
+ const fetchSpy = vi.fn(() =>
+ Promise.resolve(
+ new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
+ ),
+ );
+ vi.stubGlobal('fetch', fetchSpy);
+
+ await RequestBuilder.post('https://api.example.com/users').send();
+
+ const init = fetchSpy.mock.calls[0]![1] as RequestInit;
+ expect(init.method).toBe('POST');
+ });
+
+ it('sends PUT without body (null body fallback)', async () => {
+ const fetchSpy = vi.fn(() =>
+ Promise.resolve(
+ new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
+ ),
+ );
+ vi.stubGlobal('fetch', fetchSpy);
+
+ await RequestBuilder.put('https://api.example.com/users/1').send();
+
+ const init = fetchSpy.mock.calls[0]![1] as RequestInit;
+ expect(init.method).toBe('PUT');
+ });
+
+ it('sends PATCH without body (null body fallback)', async () => {
+ const fetchSpy = vi.fn(() =>
+ Promise.resolve(
+ new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
+ ),
+ );
+ vi.stubGlobal('fetch', fetchSpy);
+
+ await RequestBuilder.patch('https://api.example.com/users/1').send();
+
+ const init = fetchSpy.mock.calls[0]![1] as RequestInit;
+ expect(init.method).toBe('PATCH');
+ });
+
+ it('falls back to GET for unknown HTTP method (default branch)', async () => {
+ const fetchSpy = vi.fn(() =>
+ Promise.resolve(
+ new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
+ ),
+ );
+ vi.stubGlobal('fetch', fetchSpy);
+
+ // Bypass private constructor to trigger the default case in send()
+ const builder = Reflect.construct(RequestBuilder, [
+ 'OPTIONS',
+ 'https://api.example.com/test',
+ ]) as RequestBuilder;
+ await builder.send();
+
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ });
});
diff --git a/tests/json/json.test.ts b/tests/json/json.test.ts
index 3022845..48f8381 100644
--- a/tests/json/json.test.ts
+++ b/tests/json/json.test.ts
@@ -1,3 +1,5 @@
+import { vi } from 'vitest';
+import * as jsonGuards from '../../src/json/json-guards.js';
import { isJson, isJsonArray, isJsonObject, isJsonPrimitive } from '../../src/json/json-guards.js';
import { parseAndValidate, safeJsonParse, safeJsonStringify } from '../../src/json/json-utils.js';
import { Result } from '../../src/result/result.js';
@@ -155,3 +157,83 @@ describe('parseAndValidate', () => {
expect(Result.firstError(validateError)?.code).toBe('Json.TypeValidationError');
});
});
+
+// ─── safeJsonParse — ValidationError branch ──────────────────────────────────
+
+describe('safeJsonParse (ValidationError branch)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns Json.ValidationError when isJson returns false for parsed value', () => {
+ vi.spyOn(jsonGuards, 'isJson').mockReturnValue(false);
+ const result = safeJsonParse('{"key":"value"}');
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errors[0]?.code).toBe('Json.ValidationError');
+ }
+ });
+
+ it('uses fallback message when JSON.parse throws a non-Error value', () => {
+ vi.spyOn(JSON, 'parse').mockImplementation(() => {
+ throw 'string error';
+ });
+ const result = safeJsonParse('anything');
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errors[0]?.code).toBe('Json.SyntaxError');
+ expect(result.errors[0]?.description).toBe('JSON syntax error');
+ }
+ });
+});
+
+// ─── safeJsonStringify — error branch ────────────────────────────────────────
+
+describe('safeJsonStringify (error branch)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns Json.StringifyFailed on circular reference', () => {
+ const circular: Record = {};
+ circular.self = circular;
+ const result = safeJsonStringify(circular as never);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errors[0]?.code).toBe('Json.StringifyFailed');
+ }
+ });
+
+ it('uses fallback message when JSON.stringify throws a non-Error value', () => {
+ vi.spyOn(JSON, 'stringify').mockImplementation(() => {
+ throw 'string error';
+ });
+ const result = safeJsonStringify({ a: 1 });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errors[0]?.code).toBe('Json.StringifyFailed');
+ expect(result.errors[0]?.description).toBe('Failed to stringify JSON value.');
+ }
+ });
+});
+
+// ─── parseAndValidate — non-Error catch branch ───────────────────────────────
+
+describe('parseAndValidate (non-Error catch branch)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('uses fallback message when JSON.parse throws a non-Error value', () => {
+ vi.spyOn(JSON, 'parse').mockImplementation(() => {
+ throw 'string error';
+ });
+ const isNumber = (v: unknown): v is number => typeof v === 'number';
+ const result = parseAndValidate('anything', isNumber);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errors[0]?.code).toBe('Json.SyntaxError');
+ expect(result.errors[0]?.description).toBe('JSON syntax error');
+ }
+ });
+});
diff --git a/tests/maybe/maybe.test.ts b/tests/maybe/maybe.test.ts
index 7ed64be..8e485f5 100644
--- a/tests/maybe/maybe.test.ts
+++ b/tests/maybe/maybe.test.ts
@@ -456,3 +456,79 @@ describe('Maybe new methods', () => {
if (m.hasValue) expect(m.value).toBe(30);
});
});
+
+describe('Maybe utility methods', () => {
+ // tryGet
+ it('tryGet returns [true, value] for Some', () => {
+ const [hasValue, value] = Maybe.tryGet(Maybe.some(42));
+ expect(hasValue).toBe(true);
+ expect(value).toBe(42);
+ });
+
+ it('tryGet returns [false, undefined] for None', () => {
+ const [hasValue, value] = Maybe.tryGet(Maybe.none());
+ expect(hasValue).toBe(false);
+ expect(value).toBeUndefined();
+ });
+
+ // switch
+ it('switch calls onSome when Some', () => {
+ const seen: number[] = [];
+ Maybe.switch(
+ Maybe.some(5),
+ (n) => seen.push(n),
+ () => seen.push(-1),
+ );
+ expect(seen).toEqual([5]);
+ });
+
+ it('switch calls onNone when None', () => {
+ const seen: number[] = [];
+ Maybe.switch(
+ Maybe.none(),
+ (n) => seen.push(n),
+ () => seen.push(-1),
+ );
+ expect(seen).toEqual([-1]);
+ });
+
+ // flatten
+ it('flatten returns inner Maybe when outer is Some', () => {
+ const m = Maybe.flatten(Maybe.some(Maybe.some(42)));
+ expect(m.hasValue).toBe(true);
+ if (m.hasValue) expect(m.value).toBe(42);
+ });
+
+ it('flatten returns None when outer is Some of None', () => {
+ const m = Maybe.flatten(Maybe.some(Maybe.none()));
+ expect(m.hasValue).toBe(false);
+ });
+
+ it('flatten returns None when outer is None', () => {
+ const m = Maybe.flatten(Maybe.none>());
+ expect(m.hasValue).toBe(false);
+ });
+
+ // toArray
+ it('toArray returns single-element array for Some', () => {
+ const arr = Maybe.toArray(Maybe.some(7));
+ expect(arr).toEqual([7]);
+ });
+
+ it('toArray returns empty array for None', () => {
+ const arr = Maybe.toArray(Maybe.none());
+ expect(arr).toEqual([]);
+ });
+
+ // getOrThrowFactory
+ it('getOrThrowFactory returns value when Some', () => {
+ const value = Maybe.getOrThrowFactory(Maybe.some(99), () => new Error('fail'));
+ expect(value).toBe(99);
+ });
+
+ it('getOrThrowFactory throws factory error when None', () => {
+ expect(() =>
+ Maybe.getOrThrowFactory(Maybe.none(), () => new Error('domain error')),
+ ).toThrow('domain error');
+ });
+});
diff --git a/tests/result/result-async.test.ts b/tests/result/result-async.test.ts
index 81567a7..06be18a 100644
--- a/tests/result/result-async.test.ts
+++ b/tests/result/result-async.test.ts
@@ -418,3 +418,301 @@ describe('ResultAsync pipeline chaining', () => {
if (r.ok) expect(r.value).toBe(6);
});
});
+
+describe('ResultAsync.compensate with Promise>', () => {
+ it('recovers with Promise>', async () => {
+ const r = await fromAsync(failAsync()).compensate(() =>
+ Promise.resolve(Result.success(77)),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(77);
+ });
+});
+
+describe('ResultAsync.bindIf', () => {
+ it('applies fn when boolean condition is true', async () => {
+ const r = await fromAsync(okAsync(5)).bindIf(true, (n) => Result.success(n * 2));
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(10);
+ });
+
+ it('skips fn when boolean condition is false', async () => {
+ const r = await fromAsync(okAsync(5)).bindIf(false, (n) => Result.success(n * 2));
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(5);
+ });
+
+ it('applies fn when function condition returns true', async () => {
+ const r = await fromAsync(okAsync(10)).bindIf(
+ (n) => n > 5,
+ (n) => Result.success(n + 1),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(11);
+ });
+
+ it('skips fn when function condition returns false', async () => {
+ const r = await fromAsync(okAsync(3)).bindIf(
+ (n) => n > 5,
+ (n) => Result.success(n + 1),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(3);
+ });
+
+ it('short-circuits on failure', async () => {
+ const called: boolean[] = [];
+ const r = await fromAsync(failAsync()).bindIf(true, (n) => {
+ called.push(true);
+ return Result.success(n);
+ });
+ expect(r.ok).toBe(false);
+ expect(called).toHaveLength(0);
+ });
+
+ it('works with ResultAsync return', async () => {
+ const r = await fromAsync(okAsync(5)).bindIf(true, (n) => ResultAsync.success(n * 3));
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(15);
+ });
+
+ it('works with Promise return', async () => {
+ const r = await fromAsync(okAsync(5)).bindIf(true, (n) =>
+ Promise.resolve(Result.success(n * 4)),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(20);
+ });
+
+ it('propagates failure from fn', async () => {
+ const r = await fromAsync(okAsync(5)).bindIf(true, () => Result.failure(err));
+ expect(r.ok).toBe(false);
+ });
+});
+
+describe('ResultAsync.tapIf', () => {
+ it('runs effect when boolean condition is true', async () => {
+ const seen: number[] = [];
+ const r = await fromAsync(okAsync(7)).tapIf(true, (n) => {
+ seen.push(n);
+ });
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(7);
+ expect(seen).toEqual([7]);
+ });
+
+ it('skips effect when boolean condition is false', async () => {
+ const seen: number[] = [];
+ await fromAsync(okAsync(7)).tapIf(false, (n) => {
+ seen.push(n);
+ });
+ expect(seen).toHaveLength(0);
+ });
+
+ it('runs effect when function condition returns true', async () => {
+ const seen: number[] = [];
+ await fromAsync(okAsync(10)).tapIf(
+ (n) => n > 5,
+ (n) => {
+ seen.push(n);
+ },
+ );
+ expect(seen).toEqual([10]);
+ });
+
+ it('skips effect when function condition returns false', async () => {
+ const seen: number[] = [];
+ await fromAsync(okAsync(3)).tapIf(
+ (n) => n > 5,
+ (n) => {
+ seen.push(n);
+ },
+ );
+ expect(seen).toHaveLength(0);
+ });
+
+ it('short-circuits on failure', async () => {
+ const seen: number[] = [];
+ const r = await fromAsync(failAsync()).tapIf(true, (n) => {
+ seen.push(n);
+ });
+ expect(r.ok).toBe(false);
+ expect(seen).toHaveLength(0);
+ });
+
+ it('supports async effect', async () => {
+ const seen: number[] = [];
+ await fromAsync(okAsync(9)).tapIf(true, async (n) => {
+ seen.push(n);
+ });
+ expect(seen).toEqual([9]);
+ });
+});
+
+describe('ResultAsync.compensateFirst', () => {
+ it('recovers using first error with Result', async () => {
+ const r = await fromAsync(failAsync()).compensateFirst((firstErr) =>
+ Result.success(firstErr.code.length),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe('Test.Invalid'.length);
+ });
+
+ it('recovers using first error with ResultAsync', async () => {
+ const r = await fromAsync(failAsync()).compensateFirst(() => ResultAsync.success(42));
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(42);
+ });
+
+ it('recovers using first error with Promise>', async () => {
+ const r = await fromAsync(failAsync()).compensateFirst(() =>
+ Promise.resolve(Result.success(55)),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(55);
+ });
+
+ it('passes through success', async () => {
+ const r = await fromAsync(okAsync(10)).compensateFirst(() => Result.success(99));
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(10);
+ });
+
+ it('can return failure from recovery fn', async () => {
+ const r = await fromAsync(failAsync()).compensateFirst(() =>
+ Result.failure(notFound),
+ );
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound');
+ });
+});
+
+describe('ResultAsync.recover', () => {
+ it('recovers when predicate matches', async () => {
+ const r = await fromAsync(failAsync()).recover(
+ (e) => e.code === 'Test.Invalid',
+ () => Result.success(100),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(100);
+ });
+
+ it('does not recover when predicate does not match', async () => {
+ const r = await fromAsync(failAsync()).recover(
+ (e) => e.code === 'Other.Code',
+ () => Result.success(100),
+ );
+ expect(r.ok).toBe(false);
+ });
+
+ it('passes through success', async () => {
+ const r = await fromAsync(okAsync(5)).recover(
+ () => true,
+ () => Result.success(99),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(5);
+ });
+
+ it('recovers with ResultAsync', async () => {
+ const r = await fromAsync(failAsync()).recover(
+ (e) => e.code === 'Test.Invalid',
+ () => ResultAsync.success(77),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(77);
+ });
+
+ it('recovers with Promise>', async () => {
+ const r = await fromAsync(failAsync()).recover(
+ (e) => e.code === 'Test.Invalid',
+ () => Promise.resolve(Result.success(66)),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(66);
+ });
+});
+
+describe('ResultAsync.always', () => {
+ it('receives success result', async () => {
+ const val = await fromAsync(okAsync(10)).always((r) => (r.ok ? r.value * 2 : -1));
+ expect(val).toBe(20);
+ });
+
+ it('receives failure result', async () => {
+ const val = await fromAsync(failAsync()).always((r) =>
+ r.ok ? r.value : r.errors[0]!.code,
+ );
+ expect(val).toBe('Test.Invalid');
+ });
+
+ it('supports async fn', async () => {
+ const val = await fromAsync(okAsync(3)).always(async (r) => (r.ok ? r.value + 1 : 0));
+ expect(val).toBe(4);
+ });
+});
+
+describe('ResultAsync.sequence', () => {
+ it('collects all successes', async () => {
+ const r = await ResultAsync.sequence([
+ ResultAsync.success(1),
+ ResultAsync.success(2),
+ ResultAsync.success(3),
+ ]);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toEqual([1, 2, 3]);
+ });
+
+ it('collects all errors on failure', async () => {
+ const r = await ResultAsync.sequence([
+ ResultAsync.success(1),
+ ResultAsync.failure(err),
+ ResultAsync.failure(notFound),
+ ]);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors).toHaveLength(2);
+ });
+
+ it('returns empty array for empty input', async () => {
+ const r = await ResultAsync.sequence([]);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toEqual([]);
+ });
+});
+
+describe('ResultAsync.partition', () => {
+ it('separates successes and failures', async () => {
+ const { ok, err: errs } = await ResultAsync.partition([
+ ResultAsync.success(1),
+ ResultAsync.failure(err),
+ ResultAsync.success(3),
+ ResultAsync.failure(notFound),
+ ]);
+ expect(ok).toEqual([1, 3]);
+ expect(errs).toHaveLength(2);
+ });
+
+ it('returns all successes when no failures', async () => {
+ const { ok, err: errs } = await ResultAsync.partition([
+ ResultAsync.success(10),
+ ResultAsync.success(20),
+ ]);
+ expect(ok).toEqual([10, 20]);
+ expect(errs).toHaveLength(0);
+ });
+
+ it('returns all failures when no successes', async () => {
+ const { ok, err: errs } = await ResultAsync.partition([
+ ResultAsync.failure(err),
+ ResultAsync.failure(notFound),
+ ]);
+ expect(ok).toHaveLength(0);
+ expect(errs).toHaveLength(2);
+ });
+
+ it('handles empty input', async () => {
+ const { ok, err: errs } = await ResultAsync.partition([]);
+ expect(ok).toEqual([]);
+ expect(errs).toEqual([]);
+ });
+});
diff --git a/tests/result/result-chain.test.ts b/tests/result/result-chain.test.ts
index fca9dad..8a3c384 100644
--- a/tests/result/result-chain.test.ts
+++ b/tests/result/result-chain.test.ts
@@ -335,6 +335,128 @@ describe('ResultChain async API', () => {
});
});
+describe('ResultChain sync extras', () => {
+ it('bindIf binds when condition true', () => {
+ const r = chain(Result.success(5))
+ .bindIf(true, (n) => Result.success(n * 3))
+ .unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(15);
+ });
+
+ it('bindIf skips when condition false', () => {
+ const called: boolean[] = [];
+ const r = chain(Result.success(5))
+ .bindIf(false, (n) => {
+ called.push(true);
+ return Result.success(n * 3);
+ })
+ .unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(5);
+ expect(called).toHaveLength(0);
+ });
+
+ it('bindIf supports function condition', () => {
+ const r = chain(Result.success(5))
+ .bindIf(
+ (n) => n > 3,
+ (n) => Result.success(n + 1),
+ )
+ .unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(6);
+ });
+
+ it('tapIf runs side effect when condition true', () => {
+ const seen: number[] = [];
+ chain(Result.success(7)).tapIf(true, (n) => seen.push(n));
+ expect(seen).toEqual([7]);
+ });
+
+ it('tapIf skips side effect when condition false', () => {
+ const seen: number[] = [];
+ chain(Result.success(7)).tapIf(false, (n) => seen.push(n));
+ expect(seen).toHaveLength(0);
+ });
+
+ it('tapErrorIf runs on failure when condition true', () => {
+ const seen: string[] = [];
+ chain(Result.failure(err)).tapErrorIf(true, (errs) => seen.push(errs[0]!.code));
+ expect(seen).toEqual(['Test.Invalid']);
+ });
+
+ it('tapErrorIf skips on failure when condition false', () => {
+ const seen: string[] = [];
+ chain(Result.failure(err)).tapErrorIf(false, (errs) => seen.push(errs[0]!.code));
+ expect(seen).toHaveLength(0);
+ });
+
+ it('tapErrorIf supports function condition', () => {
+ const seen: string[] = [];
+ chain(Result.failure(err)).tapErrorIf(
+ (errs) => errs.length > 0,
+ (errs) => seen.push(errs[0]!.code),
+ );
+ expect(seen).toEqual(['Test.Invalid']);
+ });
+
+ it('compensateFirst recovers using first error', () => {
+ const r = chain(Result.failure(err))
+ .compensateFirst((firstErr) => {
+ expect(firstErr.code).toBe('Test.Invalid');
+ return Result.success(55);
+ })
+ .unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(55);
+ });
+
+ it('compensateFirst passes through success', () => {
+ const called: boolean[] = [];
+ const r = chain(Result.success(3))
+ .compensateFirst(() => {
+ called.push(true);
+ return Result.success(0);
+ })
+ .unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(3);
+ expect(called).toHaveLength(0);
+ });
+
+ it('recover recovers matching error', () => {
+ const r = chain(Result.failure(err))
+ .recover(
+ (e) => e.code === 'Test.Invalid',
+ () => Result.success(88),
+ )
+ .unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(88);
+ });
+
+ it('recover skips non-matching error', () => {
+ const r = chain(Result.failure(err))
+ .recover(
+ (e) => e.code === 'Other.Code',
+ () => Result.success(88),
+ )
+ .unwrap();
+ expect(r.ok).toBe(false);
+ });
+
+ it('always runs fn unconditionally on success', () => {
+ const v = chain(Result.success(42)).always((r) => (r.ok ? 'yes' : 'no'));
+ expect(v).toBe('yes');
+ });
+
+ it('always runs fn unconditionally on failure', () => {
+ const v = chain(Result.failure(err)).always((r) => (r.ok ? 'yes' : 'no'));
+ expect(v).toBe('no');
+ });
+});
+
describe('ResultChain.elseWith', () => {
it('returns value when chain is success', () => {
const val = chain(Result.success(42)).elseWith(() => 0);
@@ -372,4 +494,80 @@ describe('ResultChain async API continued', () => {
);
expect(v).toBe('Test.Invalid');
});
+
+ it('alwaysAsync runs fn with result on success', async () => {
+ const v = await chain(Result.success(10)).alwaysAsync(async (r) => (r.ok ? 'yes' : 'no'));
+ expect(v).toBe('yes');
+ });
+
+ it('alwaysAsync runs fn with result on failure', async () => {
+ const v = await chain(Result.failure(err)).alwaysAsync(async (r) => (r.ok ? 'yes' : 'no'));
+ expect(v).toBe('no');
+ });
+
+ it('bindIfAsync binds when condition is true', async () => {
+ const c = await chain(Result.success(5)).bindIfAsync(true, async (n) => Result.success(n * 2));
+ const r = c.unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(10);
+ });
+
+ it('bindIfAsync skips when condition is false', async () => {
+ const called: boolean[] = [];
+ const c = await chain(Result.success(5)).bindIfAsync(false, async (n) => {
+ called.push(true);
+ return Result.success(n * 2);
+ });
+ const r = c.unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(5);
+ expect(called).toHaveLength(0);
+ });
+
+ it('compensateFirstAsync recovers using first error', async () => {
+ const c = await chain(Result.failure(err)).compensateFirstAsync(async (firstErr) => {
+ expect(firstErr.code).toBe('Test.Invalid');
+ return Result.success(99);
+ });
+ const r = c.unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(99);
+ });
+
+ it('compensateFirstAsync passes through success', async () => {
+ const called: boolean[] = [];
+ const c = await chain(Result.success(7)).compensateFirstAsync(async () => {
+ called.push(true);
+ return Result.success(0);
+ });
+ const r = c.unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(7);
+ expect(called).toHaveLength(0);
+ });
+
+ it('recoverAsync recovers matching error', async () => {
+ const c = await chain(Result.failure(err)).recoverAsync(
+ (e) => e.code === 'Test.Invalid',
+ async () => Result.success(42),
+ );
+ const r = c.unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(42);
+ });
+
+ it('recoverAsync skips non-matching error', async () => {
+ const c = await chain(Result.failure(err)).recoverAsync(
+ (e) => e.code === 'Other.Code',
+ async () => Result.success(42),
+ );
+ expect(c.unwrap().ok).toBe(false);
+ });
+
+ it('elseAsync calls function fallback on failure', async () => {
+ const c = await chain(Result.failure(err)).elseAsync(async (errs) => errs.length * 10);
+ const r = c.unwrap();
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(10);
+ });
});
diff --git a/tests/result/result.test.ts b/tests/result/result.test.ts
index 88c98c7..b2729c6 100644
--- a/tests/result/result.test.ts
+++ b/tests/result/result.test.ts
@@ -930,3 +930,400 @@ describe('Result.elseWith', () => {
expect(Result.firstError(r)?.code).toBe('Result.Or.Empty');
});
});
+
+// ─── Coverage: uncovered functions and branches ──────────────────────────────
+
+describe('Result.tryGet', () => {
+ it('returns [true, value, undefined] on success', () => {
+ const [ok, value, errors] = Result.tryGet(Result.success(42));
+ expect(ok).toBe(true);
+ expect(value).toBe(42);
+ expect(errors).toBeUndefined();
+ });
+
+ it('returns [false, undefined, errors] on failure', () => {
+ const [ok, value, errors] = Result.tryGet(Result.failure(validationError));
+ expect(ok).toBe(false);
+ expect(value).toBeUndefined();
+ expect(errors).toHaveLength(1);
+ });
+});
+
+describe('Result.switch', () => {
+ it('calls onSuccess for success result', () => {
+ const seen: number[] = [];
+ Result.switch(
+ Result.success(42),
+ (v) => seen.push(v),
+ () => seen.push(-1),
+ );
+ expect(seen).toEqual([42]);
+ });
+
+ it('calls onError for failure result', () => {
+ const seen: string[] = [];
+ Result.switch(
+ Result.failure(validationError),
+ () => seen.push('ok'),
+ (errs) => seen.push(errs[0]!.code),
+ );
+ expect(seen).toEqual(['Test.Invalid']);
+ });
+});
+
+describe('Result.matchFirst', () => {
+ it('returns success branch value', () => {
+ const msg = Result.matchFirst(
+ Result.success(42),
+ (v) => `Got ${v}`,
+ (err) => err.code,
+ );
+ expect(msg).toBe('Got 42');
+ });
+
+ it('returns first error branch value', () => {
+ const msg = Result.matchFirst(
+ Result.failureFrom([validationError, notFoundError]),
+ () => 'OK',
+ (err) => err.code,
+ );
+ expect(msg).toBe('Test.Invalid');
+ });
+
+ it('returns fallback for empty errors (edge)', () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const msg = Result.matchFirst(
+ fakeFailure,
+ () => 'OK',
+ (err) => err.code,
+ );
+ expect(msg).toBe('Result.Empty');
+ });
+});
+
+describe('Result.matchLast', () => {
+ it('returns success branch value', () => {
+ const msg = Result.matchLast(
+ Result.success(42),
+ (v) => `Got ${v}`,
+ (err) => err.code,
+ );
+ expect(msg).toBe('Got 42');
+ });
+
+ it('returns last error branch value', () => {
+ const msg = Result.matchLast(
+ Result.failureFrom([validationError, notFoundError]),
+ () => 'OK',
+ (err) => err.code,
+ );
+ expect(msg).toBe('Test.NotFound');
+ });
+
+ it('returns fallback for empty errors (edge)', () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const msg = Result.matchLast(
+ fakeFailure,
+ () => 'OK',
+ (err) => err.code,
+ );
+ expect(msg).toBe('Result.Empty');
+ });
+});
+
+describe('Result.switchFirst', () => {
+ it('calls onSuccess for success result', () => {
+ const seen: number[] = [];
+ Result.switchFirst(
+ Result.success(42),
+ (v) => seen.push(v),
+ () => seen.push(-1),
+ );
+ expect(seen).toEqual([42]);
+ });
+
+ it('calls onFirstError for failure result', () => {
+ const seen: string[] = [];
+ Result.switchFirst(
+ Result.failureFrom([validationError, notFoundError]),
+ () => seen.push('ok'),
+ (err) => seen.push(err.code),
+ );
+ expect(seen).toEqual(['Test.Invalid']);
+ });
+
+ it('calls onFirstError with fallback for empty errors (edge)', () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const seen: string[] = [];
+ Result.switchFirst(
+ fakeFailure,
+ () => seen.push('ok'),
+ (err) => seen.push(err.code),
+ );
+ expect(seen).toEqual(['Result.Empty']);
+ });
+});
+
+describe('Result.switchLast', () => {
+ it('calls onSuccess for success result', () => {
+ const seen: number[] = [];
+ Result.switchLast(
+ Result.success(42),
+ (v) => seen.push(v),
+ () => seen.push(-1),
+ );
+ expect(seen).toEqual([42]);
+ });
+
+ it('calls onLastError for failure result', () => {
+ const seen: string[] = [];
+ Result.switchLast(
+ Result.failureFrom([validationError, notFoundError]),
+ () => seen.push('ok'),
+ (err) => seen.push(err.code),
+ );
+ expect(seen).toEqual(['Test.NotFound']);
+ });
+
+ it('calls onLastError with fallback for empty errors (edge)', () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const seen: string[] = [];
+ Result.switchLast(
+ fakeFailure,
+ () => seen.push('ok'),
+ (err) => seen.push(err.code),
+ );
+ expect(seen).toEqual(['Result.Empty']);
+ });
+});
+
+describe('Result.tapErrorFirst', () => {
+ it('runs fn with first error on failure', () => {
+ const seen: string[] = [];
+ const r = Result.tapErrorFirst(Result.failureFrom([validationError, notFoundError]), (err) =>
+ seen.push(err.code),
+ );
+ expect(r.ok).toBe(false);
+ expect(seen).toEqual(['Test.Invalid']);
+ });
+
+ it('skips fn on success', () => {
+ const seen: string[] = [];
+ const r = Result.tapErrorFirst(Result.success(42), (err) => seen.push(err.code));
+ expect(r.ok).toBe(true);
+ expect(seen).toHaveLength(0);
+ });
+
+ it('skips fn on failure with empty errors (edge)', () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const seen: string[] = [];
+ const r = Result.tapErrorFirst(fakeFailure, (err) => seen.push(err.code));
+ expect(r.ok).toBe(false);
+ expect(seen).toHaveLength(0);
+ });
+});
+
+describe('Result.ensureNotNull', () => {
+ it('passes through non-null success', () => {
+ const r = Result.ensureNotNull(Result.success(42), validationError);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(42);
+ });
+
+ it('fails on null value', () => {
+ const r = Result.ensureNotNull(Result.success(null), validationError);
+ expect(r.ok).toBe(false);
+ });
+
+ it('fails on undefined value', () => {
+ const r = Result.ensureNotNull(Result.success(undefined), validationError);
+ expect(r.ok).toBe(false);
+ });
+
+ it('passes through existing failure', () => {
+ const r = Result.ensureNotNull(Result.failure(notFoundError), validationError);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound');
+ });
+});
+
+describe('Result.failWhen', () => {
+ it('fails when predicate is true', () => {
+ const r = Result.failWhen(Result.success(3), (n) => n < 5, validationError);
+ expect(r.ok).toBe(false);
+ });
+
+ it('passes through when predicate is false', () => {
+ const r = Result.failWhen(Result.success(10), (n) => n < 5, validationError);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(10);
+ });
+
+ it('supports function error factory', () => {
+ const r = Result.failWhen(
+ Result.success(3),
+ (n) => n < 5,
+ (n) => Err.validation('Value.TooSmall', `Value ${n} is too small`),
+ );
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.description).toBe('Value 3 is too small');
+ });
+
+ it('passes through existing failure', () => {
+ const r = Result.failWhen(Result.failure(notFoundError), () => true, validationError);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound');
+ });
+});
+
+describe('Result.tryCatch', () => {
+ it('maps value on success', () => {
+ const r = Result.tryCatch(Result.success('{"a":1}'), (s) => JSON.parse(s) as unknown);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toEqual({ a: 1 });
+ });
+
+ it('catches thrown error with default handler', () => {
+ const r = Result.tryCatch(Result.success('{invalid}'), (s) => JSON.parse(s) as unknown);
+ expect(r.ok).toBe(false);
+ });
+
+ it('catches thrown error with static AppError', () => {
+ const r = Result.tryCatch(
+ Result.success('{invalid}'),
+ (s) => JSON.parse(s) as unknown,
+ Err.validation('JSON.Bad', 'Bad JSON'),
+ );
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('JSON.Bad');
+ });
+
+ it('catches thrown error with function error factory', () => {
+ const r = Result.tryCatch(
+ Result.success('{invalid}'),
+ (s) => JSON.parse(s) as unknown,
+ (e) => Err.validation('JSON.Custom', String(e)),
+ );
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('JSON.Custom');
+ });
+
+ it('passes through existing failure', () => {
+ const r = Result.tryCatch(Result.failure(notFoundError), (s) => s.toUpperCase());
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound');
+ });
+});
+
+describe('Result.tryCatchAsync', () => {
+ it('maps value on success', async () => {
+ const r = await Result.tryCatchAsync(Result.success(5), async (n) => n * 2);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(10);
+ });
+
+ it('catches thrown error with default handler', async () => {
+ const r = await Result.tryCatchAsync(Result.success(1), async () => {
+ throw new Error('boom');
+ });
+ expect(r.ok).toBe(false);
+ });
+
+ it('catches thrown error with static AppError', async () => {
+ const r = await Result.tryCatchAsync(
+ Result.success(1),
+ async () => {
+ throw new Error('boom');
+ },
+ Err.validation('Async.Bad', 'Bad async'),
+ );
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Async.Bad');
+ });
+
+ it('catches thrown error with function error factory', async () => {
+ const r = await Result.tryCatchAsync(
+ Result.success(1),
+ async () => {
+ throw new Error('async boom');
+ },
+ (e) => Err.validation('Async.Custom', String(e)),
+ );
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Async.Custom');
+ });
+
+ it('passes through existing failure', async () => {
+ const r = await Result.tryCatchAsync(Result.failure(notFoundError), async (n) => n * 2);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound');
+ });
+});
+
+describe('Result.fromValue', () => {
+ it('creates a success result from a value', () => {
+ const r = Result.fromValue(42);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe(42);
+ });
+});
+
+describe('Result.fromError', () => {
+ it('creates a failure result from an error', () => {
+ const r = Result.fromError(validationError);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errors[0]!.code).toBe('Test.Invalid');
+ });
+});
+
+describe('Result.ensure passes through failure', () => {
+ it('returns the failure without calling predicate', () => {
+ const called: boolean[] = [];
+ const r = Result.ensure(
+ Result.failure(validationError),
+ () => {
+ called.push(true);
+ return true;
+ },
+ notFoundError,
+ );
+ expect(r.ok).toBe(false);
+ expect(called).toHaveLength(0);
+ });
+});
+
+describe('Result.ensureAsync passes through failure', () => {
+ it('returns the failure without calling predicate', async () => {
+ const called: boolean[] = [];
+ const r = await Result.ensureAsync(
+ Result.failure(validationError),
+ async () => {
+ called.push(true);
+ return true;
+ },
+ notFoundError,
+ );
+ expect(r.ok).toBe(false);
+ expect(called).toHaveLength(0);
+ });
+});
+
+describe('Result.compensateFirst with empty errors (edge)', () => {
+ it('uses fallback error for empty errors array', () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const r = Result.compensateFirst(fakeFailure, (err) => Result.success(err.code));
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe('Result.Empty');
+ });
+});
+
+describe('Result.compensateFirstAsync with empty errors (edge)', () => {
+ it('uses fallback error for empty errors array', async () => {
+ const fakeFailure = { ok: false as const, errors: [] as const };
+ const r = await Result.compensateFirstAsync(fakeFailure, async (err) =>
+ Result.success(err.code),
+ );
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.value).toBe('Result.Empty');
+ });
+});
diff --git a/tests/rules/rule-engine.test.ts b/tests/rules/rule-engine.test.ts
index 2b670ac..a9f549b 100644
--- a/tests/rules/rule-engine.test.ts
+++ b/tests/rules/rule-engine.test.ts
@@ -244,6 +244,175 @@ describe('RuleEngine async', () => {
});
});
+describe('RuleEngine.linearTyped', () => {
+ const toAge = (ctx: UserContext): Result => Result.success(ctx.age);
+ const failTyped = (_ctx: UserContext): Result =>
+ Result.failure(Err.validation('Typed.Fail', 'typed fail'));
+
+ it('stops on first failure', () => {
+ const rule = RuleEngine.linearTyped(failTyped, toAge);
+ const result = rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors[0]!.code).toBe('Typed.Fail');
+ });
+
+ it('returns RuleEngine.Empty fallback when all rules pass', () => {
+ const rule = RuleEngine.linearTyped(toAge, toAge);
+ const result = rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty');
+ });
+
+ it('returns RuleEngine.Empty for empty rules', () => {
+ const rule = RuleEngine.linearTyped();
+ const result = rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty');
+ });
+});
+
+describe('RuleEngine.linearTypedAsync', () => {
+ const toAgeAsync = async (ctx: UserContext): Promise> => Result.success(ctx.age);
+ const failTypedAsync = async (_ctx: UserContext): Promise> =>
+ Result.failure(Err.validation('Typed.Fail', 'typed fail'));
+
+ it('stops on first failure', async () => {
+ const rule = RuleEngine.linearTypedAsync(failTypedAsync, toAgeAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors[0]!.code).toBe('Typed.Fail');
+ });
+
+ it('returns RuleEngine.Empty fallback when all rules pass', async () => {
+ const rule = RuleEngine.linearTypedAsync(toAgeAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty');
+ });
+
+ it('returns RuleEngine.Empty for empty rules', async () => {
+ const rule = RuleEngine.linearTypedAsync();
+ const result = await rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty');
+ });
+});
+
+describe('RuleEngine.andTyped', () => {
+ const toAge = (ctx: UserContext): Result => Result.success(ctx.age);
+ const failA = (_ctx: UserContext): Result =>
+ Result.failure(Err.validation('A.Fail', 'a'));
+ const failB = (_ctx: UserContext): Result =>
+ Result.failure(Err.validation('B.Fail', 'b'));
+
+ it('returns last value when all pass', () => {
+ const rule = RuleEngine.andTyped(toAge, toAge);
+ const result = rule(validUser);
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.value).toBe(25);
+ });
+
+ it('collects ALL errors (no short-circuit)', () => {
+ const rule = RuleEngine.andTyped(failA, failB);
+ const result = rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors).toHaveLength(2);
+ });
+
+ it('collects errors while tracking last success value', () => {
+ const rule = RuleEngine.andTyped(toAge, failA);
+ const result = rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors).toHaveLength(1);
+ });
+});
+
+describe('RuleEngine.andTypedAsync', () => {
+ const toAgeAsync = async (ctx: UserContext): Promise> => Result.success(ctx.age);
+ const failAAsync = async (_ctx: UserContext): Promise> =>
+ Result.failure(Err.validation('A.Fail', 'a'));
+ const failBAsync = async (_ctx: UserContext): Promise> =>
+ Result.failure(Err.validation('B.Fail', 'b'));
+
+ it('returns last value when all pass', async () => {
+ const rule = RuleEngine.andTypedAsync(toAgeAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.value).toBe(25);
+ });
+
+ it('collects ALL errors', async () => {
+ const rule = RuleEngine.andTypedAsync(failAAsync, failBAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors).toHaveLength(2);
+ });
+
+ it('collects errors while tracking last success value', async () => {
+ const rule = RuleEngine.andTypedAsync(toAgeAsync, failAAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors).toHaveLength(1);
+ });
+});
+
+describe('RuleEngine.orTyped', () => {
+ const toAge = (ctx: UserContext): Result => Result.success(ctx.age);
+ const failA = (_ctx: UserContext): Result =>
+ Result.failure(Err.validation('A.Fail', 'a'));
+ const failB = (_ctx: UserContext): Result =>
+ Result.failure(Err.validation('B.Fail', 'b'));
+
+ it('returns first success', () => {
+ const rule = RuleEngine.orTyped(failA, toAge);
+ const result = rule(validUser);
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.value).toBe(25);
+ });
+
+ it('collects all errors when all fail', () => {
+ const rule = RuleEngine.orTyped(failA, failB);
+ const result = rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors).toHaveLength(2);
+ });
+});
+
+describe('RuleEngine.orTypedAsync', () => {
+ const toAgeAsync = async (ctx: UserContext): Promise> => Result.success(ctx.age);
+ const failAAsync = async (_ctx: UserContext): Promise> =>
+ Result.failure(Err.validation('A.Fail', 'a'));
+ const failBAsync = async (_ctx: UserContext): Promise> =>
+ Result.failure(Err.validation('B.Fail', 'b'));
+
+ it('returns first success', async () => {
+ const rule = RuleEngine.orTypedAsync(failAAsync, toAgeAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.value).toBe(25);
+ });
+
+ it('collects all errors when all fail', async () => {
+ const rule = RuleEngine.orTypedAsync(failAAsync, failBAsync);
+ const result = await rule(validUser);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errors).toHaveLength(2);
+ });
+});
+
+describe('RuleEngine.ifAsync (no onFalse)', () => {
+ it('returns ok when condition fails and no onFalse provided', async () => {
+ const asyncAdult = RuleEngine.fromPredicateAsync(async (u) => u.age >= 18, ageErr);
+ const asyncEmail = RuleEngine.fromPredicateAsync(
+ async (u) => u.email.includes('@'),
+ emailErr,
+ );
+ const rule = RuleEngine.ifAsync(asyncAdult, asyncEmail);
+ const result = await rule(minorUser);
+ expect(result.ok).toBe(true);
+ });
+});
+
describe('RuleEngine.evaluate', () => {
it('delegates to rule function', () => {
const result = RuleEngine.evaluate(isAdult, validUser);
@@ -264,4 +433,14 @@ describe('RuleEngine.evaluate', () => {
expect(Result.isFailure(result)).toBe(true);
expect(Result.firstError(result)?.code).toBe('Rule.EvaluationFailed');
});
+
+ it('evaluateAsync handles non-Error thrown values', async () => {
+ const throwingRule = async (_ctx: unknown): Promise => {
+ throw 'string error';
+ };
+ const result = await RuleEngine.evaluateAsync(throwingRule, {});
+ expect(Result.isFailure(result)).toBe(true);
+ expect(Result.firstError(result)?.code).toBe('Rule.EvaluationFailed');
+ expect(Result.firstError(result)?.description).toBe('string error');
+ });
});
diff --git a/vitest.config.ts b/vitest.config.ts
index be009b5..1a07ad2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,6 +4,10 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
+ reporters: ['default', 'junit'],
+ outputFile: {
+ junit: 'test-report.junit.xml',
+ },
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'cobertura'],