diff --git a/.swp b/.swp new file mode 100644 index 000000000..3962a105f Binary files /dev/null and b/.swp differ diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 973157258..2eb581696 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -3,7 +3,7 @@ import { assign, defaultTo, invariant, - optionalFunctionValue, + dynamicValue, Nullable, } from 'vest-utils'; @@ -72,7 +72,7 @@ export function createCascade>( const out = assign( {}, parentContext ? parentContext : {}, - optionalFunctionValue(init, value, parentContext) ?? value, + dynamicValue(init, value, parentContext) ?? value, ) as T; return ctx.run(Object.freeze(out), fn) as R; diff --git a/packages/n4s/.npmignore b/packages/n4s/.npmignore index 6aa0dfccc..a3aceb4dc 100644 --- a/packages/n4s/.npmignore +++ b/packages/n4s/.npmignore @@ -4,11 +4,8 @@ src !types/ !dist/ tsconfig.json -!schema/ !isURL/ !email/ !date/ -!compounds/ -!compose/ # Manual Section. Edit at will. \ No newline at end of file diff --git a/packages/n4s/CHANGELOG.md b/packages/n4s/CHANGELOG.md deleted file mode 100644 index da69181c6..000000000 --- a/packages/n4s/CHANGELOG.md +++ /dev/null @@ -1,75 +0,0 @@ -# n4s - Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - -## 4.0.0 2021-12-24 - -- 7daf6d2 rule(n4s): add isNullish rule, improve isBlank (ealush) -- ddceca7 patch(n4s): improve partial support (ealush) -- 0370fc1 n4s: extract non-required modules (ealush) -- 7baedf2 n4s: use named export in entry (ealush) -- 780c5b7 types(n4s): Allow adding types for custom matchers (ealush) -- 149aab3 add(n4s): enforce.condition (ealush) -- d786b34 fix(n4s): make sure oneOf correctly validates single case (ealush) -- 49e601a feat(n4s): enforce.compose (ealush) -- 1dddaf4 patch(n4s): improve types for rule chaining (undefined) -- b5ce72d feat(n4s): context propagation within enforce (undefined) -- 32fe8a5 feat(n4s): add shape and loose validations (undefined) -- 39b92f1 patch: enable enforce in es5 environments (undefined) -- 75306ff fix(n4s): make enforce compound runners fall back to correct response (ealush) -- 4751584 fix(n4s): make enforce chaining work (ealush) - -## 3.1.0 - 2021-08-06 - -### Added - -- bf432a1 added(n4s): add custom message support to lazy enforcements (undefined) -- c093a7f added(n4s): partial rule modifier (undefined) -- 045bc72 feat(n4s): context propagation within enforce (undefined) -- 443df9d feat(n4s): optional (undefined) -- 86172ca feat(n4s): isArrayOf (undefined) -- f48f38b feat(n4s): add shape and loose validations (undefined) - -### Fixed and improved - -- fdcf20b genscript (undefined) -- b12614f chore: cleanup residue (undefined) -- 4da20b4 chore: remove duplicate types (undefined) -- c66298d tests(n4s): add paritla tests (undefined) -- packages/anyone/package.json -- a8c1e67 patch: add nodejs exports (undefined) -- 83efb86 patch: remove unused exports (undefined) -- 9546b9e patch(n4s): improve types for rule chaining (undefined) -- packages/anyone/package.json -- 7bab926 patch: simplify proxy test/run assignment (undefined) -- ed5c903 patch: enable enforce in es5 environments (undefined) -- 3494de6 chore: reduce complexity, remove all lint errors (undefined) -- packages/anyone/.npmignore -- .github/PULL_REQUEST_TEMPLATE.md -- aec6cd6 chore: cleanup unused code (ealush) -- 0103b38 lint: handling lint of all packages (ealush) -- .gitignore -- 03cf487 patch(n4s): add ruleReturn default values (ealush) -- 76e8c98 fix(n4s): make enforce compound runners fall back to correct response (ealush) -- ff91bd2 fix(n4s): make enforce chaining work (ealush) -- c3fd912 chore: some lint fixes (ealush) -- 49b6b84 Vest 4 Infra Setup (ealush) - -## 1.1.14 - 2021-07-02 - -### Fixed and improved - -- 34e0414 improved conditions (undefined) -- 26c28c6 all tests pass (undefined) -- 33f4e46 release (undefined) -- 6fe40c7 better bundle (undefined) -- c2cfb65 better typing (undefined) -- c6387ab before ts settings (undefined) -- c0e9708 generate correct d.ts file (undefined) -- 8e01b8e x (undefined) -- afb3960 x (undefined) -- e0a8463 add changelog support (undefined) -- cc46c38 current (undefined) -- b6db1c6 transform any to unknowns (ealush) diff --git a/packages/n4s/LICENSE b/packages/n4s/LICENSE index d802be83a..ef9b1786d 100644 --- a/packages/n4s/LICENSE +++ b/packages/n4s/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 ealush +Copyright (c) 2025 ealush Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/packages/n4s/README.md b/packages/n4s/README.md index c37bbf815..1bb0666e6 100644 --- a/packages/n4s/README.md +++ b/packages/n4s/README.md @@ -1,45 +1,3 @@ -# Enforce - n4s +# n4s -[![Join Discord](https://badgen.net/discord/online-members/WmADZpJnSe?icon=discord&label=Discord)](https://discord.gg/WmADZpJnSe) [![Version](https://badgen.net/npm/v/vest?&icon=npm)](https://www.npmjs.com/package/n4s) [![Downloads](https://badgen.net/npm/dt/n4s?label=Downloads)](https://www.npmjs.com/package/n4s) [![bundlephobia](https://badgen.net/bundlephobia/minzip/n4s)](https://bundlephobia.com/package/n4s) [![Status](https://badgen.net/github/status/ealush/vest)](https://github.com/ealush/vest/actions) - -Enforce is a validations assertions library. It provides rules that you can test your data against. - -By default, enforce throws an error when your validations fail. These errors should be caught by a validation testing framework such as [Vest](https://github.com/ealush/vest). - -You can extend Enforce per need, and you can add your custom validation rules in your app. - -```js -import { enforce } from 'n4s'; - -enforce(4).isNumber(); -// passes - -enforce(4).isNumber().greaterThan(2); -// passes - -enforce(4) - .lessThan(2) // throws an error, will not carry on to the next rule - .greaterThan(3); -``` - -## Installation - -``` -npm i n4s -``` - -## Non throwing validations - -> This functionality replaces the no-longer supported ensure export, as it performs the same functionality with better performance. - -If you wish to use enforce's functionality safely with a boolean return interface, you can use its lazy validation interface: - -```js -enforce.isArray().longerThan(3).test([1, 2, 3]); -``` - -[Read the docs](https://ealush.github.io/n4s) - -- [List of enforce rules](https://ealush.github.io/n4s/#/rules) -- [Schema validation](https://ealush.github.io/n4s/#/shape) -- [Custom enforce rules](https://ealush.github.io/n4s/#/custom) +typed schema validation version of enforce diff --git a/packages/n4s/package.json b/packages/n4s/package.json index 47a802ba1..d26f7d075 100644 --- a/packages/n4s/package.json +++ b/packages/n4s/package.json @@ -1,68 +1,24 @@ { - "version": "5.0.28", + "name": "n4s", + "version": "5.10.10", + "description": "typed schema validation version of enforce", "license": "MIT", + "author": "ealush", + "private": true, "main": "./dist/cjs/n4s.js", + "module": "./dist/es/n4s.production.js", + "unpkg": "./dist/umd/n4s.production.js", + "jsdelivr": "./dist/umd/n4s.production.js", "types": "./types/n4s.d.ts", - "name": "n4s", - "author": "ealush", - "description": "Assertion library for form validations", - "keywords": [ - "vest", - "enforce", - "n4s", - "validation", - "assertion", - "schema validation", - "form validation", - "validation library" - ], - "scripts": { - "test": "vx test", - "release": "vx release" - }, - "dependencies": { - "context": "^3.0.33", - "vest-utils": "^1.3.3" + "repository": { + "type": "git", + "url": "https://github.com/ealush/vest.git", + "directory": "packages/n4s" }, - "devDependencies": { - "@types/validator": "^13.11.10", - "validator": "13.9.0" + "bugs": { + "url": "https://github.com/ealush/vest.git/issues" }, - "vxAllowResolve": [ - "validator" - ], - "module": "./dist/es/n4s.production.js", "exports": { - "./schema": { - "production": { - "types": "./types/schema.d.ts", - "browser": "./dist/es/schema.production.js", - "umd": "./dist/umd/schema.production.js", - "import": "./dist/es/schema.production.js", - "require": "./dist/cjs/schema.production.js", - "node": "./dist/cjs/schema.production.js", - "module": "./dist/es/schema.production.js", - "default": "./dist/cjs/schema.production.js" - }, - "development": { - "types": "./types/schema.d.ts", - "browser": "./dist/es/schema.development.js", - "umd": "./dist/umd/schema.development.js", - "import": "./dist/es/schema.development.js", - "require": "./dist/cjs/schema.development.js", - "node": "./dist/cjs/schema.development.js", - "module": "./dist/es/schema.development.js", - "default": "./dist/cjs/schema.development.js" - }, - "types": "./types/schema.d.ts", - "browser": "./dist/es/schema.production.js", - "umd": "./dist/umd/schema.production.js", - "import": "./dist/es/schema.production.js", - "require": "./dist/cjs/schema.production.js", - "node": "./dist/cjs/schema.production.js", - "module": "./dist/es/schema.production.js", - "default": "./dist/cjs/schema.production.js" - }, "./isURL": { "production": { "types": "./types/isURL.d.ts", @@ -153,66 +109,6 @@ "module": "./dist/es/date.production.js", "default": "./dist/cjs/date.production.js" }, - "./compounds": { - "production": { - "types": "./types/compounds.d.ts", - "browser": "./dist/es/compounds.production.js", - "umd": "./dist/umd/compounds.production.js", - "import": "./dist/es/compounds.production.js", - "require": "./dist/cjs/compounds.production.js", - "node": "./dist/cjs/compounds.production.js", - "module": "./dist/es/compounds.production.js", - "default": "./dist/cjs/compounds.production.js" - }, - "development": { - "types": "./types/compounds.d.ts", - "browser": "./dist/es/compounds.development.js", - "umd": "./dist/umd/compounds.development.js", - "import": "./dist/es/compounds.development.js", - "require": "./dist/cjs/compounds.development.js", - "node": "./dist/cjs/compounds.development.js", - "module": "./dist/es/compounds.development.js", - "default": "./dist/cjs/compounds.development.js" - }, - "types": "./types/compounds.d.ts", - "browser": "./dist/es/compounds.production.js", - "umd": "./dist/umd/compounds.production.js", - "import": "./dist/es/compounds.production.js", - "require": "./dist/cjs/compounds.production.js", - "node": "./dist/cjs/compounds.production.js", - "module": "./dist/es/compounds.production.js", - "default": "./dist/cjs/compounds.production.js" - }, - "./compose": { - "production": { - "types": "./types/compose.d.ts", - "browser": "./dist/es/compose.production.js", - "umd": "./dist/umd/compose.production.js", - "import": "./dist/es/compose.production.js", - "require": "./dist/cjs/compose.production.js", - "node": "./dist/cjs/compose.production.js", - "module": "./dist/es/compose.production.js", - "default": "./dist/cjs/compose.production.js" - }, - "development": { - "types": "./types/compose.d.ts", - "browser": "./dist/es/compose.development.js", - "umd": "./dist/umd/compose.development.js", - "import": "./dist/es/compose.development.js", - "require": "./dist/cjs/compose.development.js", - "node": "./dist/cjs/compose.development.js", - "module": "./dist/es/compose.development.js", - "default": "./dist/cjs/compose.development.js" - }, - "types": "./types/compose.d.ts", - "browser": "./dist/es/compose.production.js", - "umd": "./dist/umd/compose.production.js", - "import": "./dist/es/compose.production.js", - "require": "./dist/cjs/compose.production.js", - "node": "./dist/cjs/compose.production.js", - "module": "./dist/es/compose.production.js", - "default": "./dist/cjs/compose.production.js" - }, ".": { "development": { "types": "./types/n4s.d.ts", @@ -236,14 +132,15 @@ "./package.json": "./package.json", "./*": "./*" }, - "repository": { - "type": "git", - "url": "https://github.com/ealush/vest.git", - "directory": "packages/n4s" + "dependencies": { + "context": "^3.0.33", + "vest-utils": "^1.3.3" }, - "bugs": { - "url": "https://github.com/ealush/vest.git/issues" + "devDependencies": { + "@types/validator": "^13.11.10", + "validator": "13.9.0" }, - "unpkg": "./dist/umd/n4s.production.js", - "jsdelivr": "./dist/umd/n4s.production.js" + "vxAllowResolve": [ + "validator" + ] } diff --git a/packages/n4s/src/__tests__/compose.test.ts b/packages/n4s/src/__tests__/compose.test.ts new file mode 100644 index 000000000..a9811b311 --- /dev/null +++ b/packages/n4s/src/__tests__/compose.test.ts @@ -0,0 +1,584 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { compose, enforce } from 'n4s'; + +describe('compose() - Rule Composition', () => { + describe('Basic composition', () => { + it('Should create AND relationship between rules', () => { + const NumberAboveTen = compose( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + + expect(() => NumberAboveTen(5)).toThrow(); + expect(() => NumberAboveTen('11')).toThrow(); + expect(() => NumberAboveTen(10)).toThrow(); + expect(() => NumberAboveTen(11)).not.toThrow(); + }); + + it('Should fail if any composed rule fails', () => { + const StringLongerThanFive = compose( + enforce.isString(), + enforce.isString().longerThan(5), + ); + + expect(() => StringLongerThanFive(123)).toThrow(); // Not a string + expect(() => StringLongerThanFive('hi')).toThrow(); // Too short + expect(() => StringLongerThanFive('hello world')).not.toThrow(); + }); + + it('Should pass if all composed rules pass', () => { + const ValidEmail = compose( + enforce.isString(), + enforce.isString().matches(/@/), + enforce.isString().longerThan(5), + ); + + expect(() => ValidEmail('user@example.com')).not.toThrow(); + }); + }); + + describe('Lazy evaluation', () => { + it('Should support .run() method', () => { + const NumericStringBetweenThreeAndFive = compose( + enforce.isNumeric(), + enforce.isString(), + enforce.isNumeric().greaterThan(3), + enforce.isNumeric().lessThan(5), + ); + + expect(NumericStringBetweenThreeAndFive.run('4').pass).toBe(true); + expect(NumericStringBetweenThreeAndFive.run('3').pass).toBe(false); + expect(NumericStringBetweenThreeAndFive.run(5).pass).toBe(false); + }); + + it('Should support .test() method', () => { + const NumericStringBetweenThreeAndFive = compose( + enforce.isNumeric(), + enforce.isString(), + enforce.isNumeric().greaterThan(3), + enforce.isNumeric().lessThan(5), + ); + + expect(NumericStringBetweenThreeAndFive.test('4')).toBe(true); + expect(NumericStringBetweenThreeAndFive.test('3')).toBe(false); + expect(NumericStringBetweenThreeAndFive.test(5)).toBe(false); + }); + + it('Should return detailed results with .run()', () => { + const PositiveNumber = compose( + enforce.isNumber(), + enforce.isNumber().greaterThan(0), + ); + + const passingResult = PositiveNumber.run(5); + expect(passingResult.pass).toBe(true); + expect(passingResult.message).toBeUndefined(); + + const failingResult = PositiveNumber.run(-5); + expect(failingResult.pass).toBe(false); + // Message may or may not be defined depending on the rule + }); + }); + + describe('Composition with schema rules', () => { + it('Should compose shape validations', () => { + const Name = compose( + enforce.shape({ + first: enforce.isString().longerThan(0), + last: enforce.isString().longerThan(0), + middle: enforce.optional(enforce.isString().longerThan(0)), + }), + ); + + expect(() => + Name({ + first: 'John', + last: 'Doe', + }), + ).not.toThrow(); + + expect(() => + Name({ + first: 'John', + last: 'Doe', + middle: '', + }), + ).toThrow(); + }); + + it('Should work as part of larger shape', () => { + const Name = compose( + enforce.shape({ + first: enforce.isString().longerThan(0), + last: enforce.isString().longerThan(0), + }), + ); + + expect( + enforce + .shape({ + name: Name, + age: enforce.isNumber(), + }) + .run({ + name: { + first: 'John', + last: 'Doe', + }, + age: 30, + }).pass, + ).toBe(true); + + expect( + enforce + .shape({ + name: Name, + age: enforce.isNumber(), + }) + .run({ + name: { + first: 'John', + last: '', + }, + age: 30, + }).pass, + ).toBe(false); + }); + + it('Should compose loose validations', () => { + const Entity = compose( + enforce.loose({ + id: enforce.isNumeric(), + }), + ); + + expect(() => + Entity({ + id: '123', + extra: 'field', + }), + ).not.toThrow(); + + expect(() => + Entity({ + id: 'not-numeric', + }), + ).toThrow(); + }); + }); + + describe('Composing compositions', () => { + it('Should allow nested composition', () => { + const Name = compose( + enforce.loose({ + name: enforce.shape({ + first: enforce.isString().longerThan(0), + last: enforce.isString().longerThan(0), + middle: enforce.optional(enforce.isString().longerThan(0)), + }), + }), + ); + + const Entity = compose( + enforce.loose({ + id: enforce.isNumeric(), + }), + ); + + const User = compose(Name, Entity); + + expect( + User.run({ + id: '1', + name: { + first: 'John', + middle: 'M', + last: 'Doe', + }, + }).pass, + ).toBe(true); + + expect(() => + User({ + id: '1', + name: { + first: 'John', + middle: 'M', + last: 'Doe', + }, + }), + ).not.toThrow(); + + expect( + User.run({ + id: '_', + name: { + first: 'John', + }, + }).pass, + ).toBe(false); + + expect(() => + User({ + name: { + first: 'John', + }, + id: '__', + }), + ).toThrow(); + }); + + it('Should compose multiple composites', () => { + const HasId = compose(enforce.loose({ id: enforce.isNumber() })); + const HasName = compose(enforce.loose({ name: enforce.isString() })); + const HasEmail = compose( + enforce.loose({ email: enforce.isString().matches(/@/) }), + ); + + const User = compose(HasId, HasName, HasEmail); + + expect( + User.run({ + id: 1, + name: 'John', + email: 'john@example.com', + }).pass, + ).toBe(true); + + expect( + User.run({ + id: 1, + name: 'John', + email: 'invalid', + }).pass, + ).toBe(false); + }); + }); + + describe('Composition with arrays', () => { + it('Should compose array validations', () => { + const NumberArray = compose( + enforce.isArray(), + enforce.isArrayOf(enforce.isNumber()), + enforce.isArray().longerThan(0), + ); + + expect(() => NumberArray([1, 2, 3])).not.toThrow(); + expect(() => NumberArray([])).toThrow(); // Empty array fails longerThan(0) + expect(() => NumberArray([1, '2', 3])).toThrow(); // Not all numbers + }); + + it('Should compose complex array of objects', () => { + const User = compose( + enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString(), + }), + ); + + const Users = compose( + enforce.isArray(), + enforce.isArrayOf(User), + enforce.isArray().longerThan(0), + ); + + expect( + Users.run([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]).pass, + ).toBe(true); + + expect( + Users.run([ + { id: 1, name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]).pass, + ).toBe(false); + }); + }); + + describe('Composition with compound rules', () => { + it('Should compose with anyOf', () => { + const StringOrNumber = compose( + enforce.anyOf(enforce.isString(), enforce.isNumber()), + enforce.isNotEmpty(), + ); + + expect(StringOrNumber.test('hello')).toBe(true); + expect(StringOrNumber.test(123)).toBe(true); + expect(StringOrNumber.test('')).toBe(false); + expect(StringOrNumber.test(true)).toBe(false); + }); + + it('Should compose with allOf', () => { + const ValidPassword = compose( + enforce.allOf( + enforce.isString(), + enforce.isString().longerThan(7), + enforce.isString().matches(/[A-Z]/), + enforce.isString().matches(/[0-9]/), + ), + ); + + expect(ValidPassword.test('Password1')).toBe(true); + expect(ValidPassword.test('password1')).toBe(false); // No uppercase + expect(ValidPassword.test('Password')).toBe(false); // No number + expect(ValidPassword.test('Pass1')).toBe(false); // Too short + }); + }); + + describe('Reusability', () => { + it('Should allow reusing composed rules', () => { + const PositiveInteger = compose( + enforce.isNumber(), + enforce.isNumber().greaterThan(0), + ); + + expect(PositiveInteger.test(5)).toBe(true); + expect(PositiveInteger.test(10)).toBe(true); + expect(PositiveInteger.test(0)).toBe(false); + expect(PositiveInteger.test(-5)).toBe(false); + // Note: n4st have isInteger, so we can't test decimals + }); + + it('Should allow building validators library', () => { + const validators = { + email: compose( + enforce.isString(), + enforce.isString().matches(/@/), + enforce.isString().longerThan(5), + ), + phone: compose( + enforce.isString(), + enforce.isString().matches(/^\+?[\d\s-()]+$/), + ), + url: compose( + enforce.isString(), + enforce.isString().matches(/^https?:\/\//), + ), + }; + + expect(validators.email.test('user@example.com')).toBe(true); + expect(validators.phone.test('+1-234-567-8900')).toBe(true); + expect(validators.url.test('https://example.com')).toBe(true); + }); + }); + + describe('Custom rules in composition', () => { + beforeEach(() => { + enforce.extend({ + isEven: (value: number) => value % 2 === 0, + isPositive: (value: number) => value > 0, + }); + }); + + it('Should compose custom rules', () => { + const PositiveEven = compose( + enforce.isNumber(), + enforce.isNumber().isPositive(), + enforce.isNumber().isEven(), + ); + + expect(PositiveEven.test(4)).toBe(true); + expect(PositiveEven.test(2)).toBe(true); + expect(PositiveEven.test(3)).toBe(false); // Not even + expect(PositiveEven.test(-2)).toBe(false); // Not positive + }); + }); + + describe('Error handling', () => { + it('Should throw on first failing rule in eager mode', () => { + const Validator = compose( + enforce.isString(), + enforce.isString().longerThan(5), + enforce.isString().matches(/test/), + ); + + expect(() => Validator(123)).toThrow(); // Fails on isString + expect(() => Validator('hi')).toThrow(); // Fails on longerThan + expect(() => Validator('hello world')).toThrow(); // Fails on matches + }); + + it('Should provide meaningful error in lazy mode', () => { + const Validator = compose( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + + const result = Validator.run('not a number'); + expect(result.pass).toBe(false); + // Message may or may not be defined depending on the rule + }); + }); + + describe('Edge cases', () => { + it('Should handle single rule composition', () => { + const JustNumber = compose(enforce.isNumber()); + + expect(JustNumber.test(123)).toBe(true); + expect(JustNumber.test('123')).toBe(false); + }); + + it('Should handle empty composition gracefully', () => { + const Empty = compose(); + + // Empty composition should always pass + expect(Empty.run('anything').pass).toBe(true); + expect(() => Empty('anything')).not.toThrow(); + }); + + it('Should work with falsy values', () => { + const AcceptsFalsy = compose(enforce.equals(false)); + + expect(AcceptsFalsy.test(false)).toBe(true); + expect(AcceptsFalsy.test(0)).toBe(false); + expect(AcceptsFalsy.test('')).toBe(false); + }); + }); + + describe('Real-world use cases', () => { + it('Should validate user registration data', () => { + const Username = compose( + enforce.isString(), + enforce.isString().longerThan(3), + enforce.isString().shorterThan(20), + enforce.isString().matches(/^[a-zA-Z0-9_]+$/), + ); + + const Email = compose( + enforce.isString(), + enforce.isString().matches(/@/), + enforce.isString().matches(/\./), + ); + + const Password = compose( + enforce.isString(), + enforce.isString().longerThan(7), + enforce.isString().matches(/[A-Z]/), + enforce.isString().matches(/[a-z]/), + enforce.isString().matches(/[0-9]/), + ); + + const UserRegistration = compose( + enforce.shape({ + username: Username, + email: Email, + password: Password, + }), + ); + + expect( + UserRegistration.test({ + username: 'john_doe', + email: 'john@example.com', + password: 'SecurePass123', + }), + ).toBe(true); + + expect( + UserRegistration.test({ + username: 'ab', // Too short + email: 'john@example.com', + password: 'SecurePass123', + }), + ).toBe(false); + }); + + it('Should validate API response structure', () => { + const User = compose( + enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString(), + email: enforce.isString().matches(/@/), + }), + ); + + const ApiResponse = compose( + enforce.shape({ + data: User, + status: enforce.equals(200), + timestamp: enforce.isNumber(), + }), + ); + + expect( + ApiResponse.test({ + data: { + id: 1, + name: 'John', + email: 'john@example.com', + }, + status: 200, + timestamp: Date.now(), + }), + ).toBe(true); + }); + + it('Should create domain-specific validators', () => { + const Money = compose( + enforce.isNumber(), + enforce.isNumber().greaterThanOrEquals(0), + ); + + const Product = compose( + enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString().longerThan(0), + price: Money, + quantity: compose( + enforce.isNumber(), + enforce.isNumber().greaterThanOrEquals(0), + ), + }), + ); + + expect( + Product.test({ + id: 1, + name: 'Widget', + price: 19.99, + quantity: 100, + }), + ).toBe(true); + + expect( + Product.test({ + id: 1, + name: 'Widget', + price: -5, // Invalid: negative price + quantity: 100, + }), + ).toBe(false); + }); + }); + + describe('Type inference compatibility', () => { + it('Should work with inferred types from compositions', () => { + const StringRule = enforce.isString(); + const NumberRule = enforce.isNumber(); + + const StringOrNumberComposite = compose( + enforce.anyOf(StringRule, NumberRule), + ); + + expect(StringOrNumberComposite.test('hello')).toBe(true); + expect(StringOrNumberComposite.test(123)).toBe(true); + expect(StringOrNumberComposite.test(true)).toBe(false); + }); + + it('Should preserve type information through compositions', () => { + const PositiveNumber = compose( + enforce.isNumber(), + enforce.isNumber().greaterThan(0), + ); + + // Type should be inferred as number + type InferredType = typeof PositiveNumber.infer; + + const value: InferredType = 5; + expect(PositiveNumber.test(value)).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/context.test.ts b/packages/n4s/src/__tests__/context.test.ts new file mode 100644 index 000000000..5edb3de4a --- /dev/null +++ b/packages/n4s/src/__tests__/context.test.ts @@ -0,0 +1,782 @@ +import { has } from 'lodash'; +import { isNullish } from 'vest-utils'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('enforce.context() API', () => { + describe('Basic context access', () => { + beforeEach(() => { + enforce.extend({ + hasContext: () => { + const context = enforce.context(); + return !isNullish(context); + }, + }); + }); + + it('should provide context within custom rules', () => { + expect(() => enforce('test').hasContext()).not.toThrow(); + }); + + // Context is not available in lazy mode (when building rule chain) + it('should work in lazy mode', () => { + const result = enforce.hasContext().run('test'); + expect(result.pass).toBe(true); + }); + }); + + describe('Context structure', () => { + beforeEach(() => { + enforce.extend({ + checkContextStructure: () => { + const context = enforce.context(); + return ( + context !== null && + typeof context === 'object' && + 'value' in context && + 'meta' in context && + 'parent' in context + ); + }, + }); + }); + + it('should have value, meta, and parent properties', () => { + expect(() => enforce('test').checkContextStructure()).not.toThrow(); + }); + }); + + describe('Context value property', () => { + beforeEach(() => { + enforce.extend({ + checkValue: (value: any) => { + const context = enforce.context(); + return context?.value === value; + }, + }); + }); + + it('should contain the current value being validated', () => { + expect(() => enforce('test').checkValue()).not.toThrow(); + expect(() => enforce(123).checkValue()).not.toThrow(); + expect(() => enforce({ key: 'value' }).checkValue()).not.toThrow(); + }); + + it('should work with different data types', () => { + const testCases = [ + 'string', + 123, + true, + null, + undefined, + { key: 'value' }, + [1, 2, 3], + ]; + + testCases.forEach(testCase => { + expect(() => enforce(testCase).checkValue()).not.toThrow(); + }); + }); + }); + + describe('Context meta property within shape', () => { + beforeEach(() => { + enforce.extend({ + checkMetaName: (value: any, expectedName: string) => { + const context = enforce.context(); + return context?.meta?.key === expectedName; + }, + }); + }); + + // Context meta within schema rules is not yet fully implemented + it('should contain the property name when used in shape', () => { + expect(() => + enforce({ + username: 'johndoe', + }).shape({ + username: enforce.isString().checkMetaName('username'), + }), + ).not.toThrow(); + }); + + // Context meta within schema rules is not yet fully implemented + it('should work with multiple properties', () => { + expect(() => + enforce({ + firstName: 'John', + lastName: 'Doe', + age: 30, + }).shape({ + firstName: enforce.isString().checkMetaName('firstName'), + lastName: enforce.isString().checkMetaName('lastName'), + age: enforce.isNumber().checkMetaName('age'), + }), + ).not.toThrow(); + }); + + // Context is not available in lazy mode + it('should work in lazy mode', () => { + const schema = enforce.shape({ + username: enforce.isString().checkMetaName('username'), + }); + + const result = schema.run({ username: 'johndoe' }); + expect(result.pass).toBe(true); + }); + }); + + describe('Context meta property within isArrayOf', () => { + beforeEach(() => { + enforce.extend({ + checkMetaIndex: (value: any, expectedIndex: number) => { + const context = enforce.context(); + return context?.meta?.index === expectedIndex; + }, + hasMetaIndex: () => { + const context = enforce.context(); + return typeof context?.meta?.index === 'number'; + }, + }); + }); + + it('should contain the array index when used in isArrayOf', () => { + expect(() => + enforce({ + items: ['first', 'second', 'third'], + }).shape({ + items: enforce.isArrayOf(enforce.isString().hasMetaIndex()), + }), + ).not.toThrow(); + }); + + it('should have correct index values', () => { + // Note: This test checks that meta.index exists, but checking specific + // index values would require multiple custom rules or advanced logic + expect(() => + enforce(['a', 'b', 'c']).isArrayOf(enforce.isString().hasMetaIndex()), + ).not.toThrow(); + }); + }); + + describe('Context parent traversal', () => { + beforeEach(() => { + enforce.extend({ + hasParent: () => { + const context = enforce.context(); + return typeof context?.parent === 'function'; + }, + canAccessParent: () => { + const context = enforce.context(); + const parent = context?.parent(); + return parent !== null; + }, + }); + }); + + it('should provide parent function in context', () => { + expect(() => + enforce({ + nested: { + value: 'test', + }, + }).shape({ + nested: enforce.shape({ + value: enforce.isString().hasParent(), + }), + }), + ).not.toThrow(); + }); + + it('should allow accessing parent context', () => { + expect(() => + enforce({ + nested: { + value: 'test', + }, + }).shape({ + nested: enforce.shape({ + value: enforce.isString().canAccessParent(), + }), + }), + ).not.toThrow(); + }); + }); + + describe('Accessing parent values - single level', () => { + beforeEach(() => { + enforce.extend({ + matchesParentUsername: (value: string) => { + const context = enforce.context(); + const parent = context?.parent(); + return value === parent?.value.username; + }, + differentFromParentUsername: (value: string) => { + const context = enforce.context(); + const parent = context?.parent(); + return value !== parent?.value.username; + }, + }); + }); + + it('should access parent value for validation', () => { + expect(() => + enforce({ + username: 'johndoe', + displayName: 'johndoe', + }).shape({ + username: enforce.isString(), + displayName: enforce.isString().matchesParentUsername(), + }), + ).not.toThrow(); + }); + + it('should fail when parent value does not match', () => { + expect(() => + enforce({ + username: 'johndoe', + displayName: 'different', + }).shape({ + username: enforce.isString(), + displayName: enforce.isString().matchesParentUsername(), + }), + ).toThrow(); + }); + + it('should validate that value is different from parent property', () => { + expect(() => + enforce({ + username: 'johndoe', + displayName: 'different', + }).shape({ + username: enforce.isString(), + displayName: enforce.isString().differentFromParentUsername(), + }), + ).not.toThrow(); + }); + }); + + describe('Accessing parent values - multiple levels (Documentation example)', () => { + beforeEach(() => { + enforce.extend({ + isFriendTheSameAsUser: (value: string) => { + const context = enforce.context(); + + if (value === context?.parent()?.parent()?.value.username) { + return { pass: false }; + } + + return true; + }, + }); + }); + + it('should traverse two levels up to access username', () => { + expect(() => + enforce({ + username: 'johndoe', + friends: ['Mike', 'Jim'], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).not.toThrow(); + }); + + it('should fail when friend name matches username', () => { + expect(() => + enforce({ + username: 'johndoe', + friends: ['Mike', 'Jim', 'johndoe'], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).toThrow(); + }); + + it('should work with empty friends array', () => { + expect(() => + enforce({ + username: 'johndoe', + friends: [], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).not.toThrow(); + }); + }); + + describe('Complex nested object validation with context', () => { + beforeEach(() => { + enforce.extend({ + emailMatchesUsername: (value: string) => { + const context = enforce.context(); + const parent = context?.parent(); + const username = parent?.value.username; + // Email should start with username + return value.startsWith(username + '@'); + }, + }); + }); + + it('should validate email based on username in parent', () => { + expect(() => + enforce({ + username: 'john', + email: 'john@example.com', + }).shape({ + username: enforce.isString(), + email: enforce.isString().emailMatchesUsername(), + }), + ).not.toThrow(); + }); + + it('should fail when email does not match username pattern', () => { + expect(() => + enforce({ + username: 'john', + email: 'jane@example.com', + }).shape({ + username: enforce.isString(), + email: enforce.isString().emailMatchesUsername(), + }), + ).toThrow(); + }); + }); + + describe('Deeply nested context traversal', () => { + beforeEach(() => { + enforce.extend({ + matchesTopLevelId: (value: string) => { + const context = enforce.context(); + // Traverse up to the top-level (four levels from leaf) + const topLevel = context?.parent()?.parent()?.parent()?.parent() + ?.value.id; + return value === topLevel; + }, + }); + }); + + it('should access deeply nested parent values', () => { + expect(() => + enforce({ + id: 'top-level-id', + level1: { + level2: { + level3: { + reference: 'top-level-id', + }, + }, + }, + }).shape({ + id: enforce.isString(), + level1: enforce.shape({ + level2: enforce.shape({ + level3: enforce.shape({ + reference: enforce.isString().matchesTopLevelId(), + }), + }), + }), + }), + ).not.toThrow(); + }); + + it('should fail when deeply nested value does not match', () => { + expect(() => + enforce({ + id: 'top-level-id', + level1: { + level2: { + level3: { + reference: 'different-id', + }, + }, + }, + }).shape({ + id: enforce.isString(), + level1: enforce.shape({ + level2: enforce.shape({ + level3: enforce.shape({ + reference: enforce.isString().matchesTopLevelId(), + }), + }), + }), + }), + ).toThrow(); + }); + }); + + describe('Context with custom error messages', () => { + beforeEach(() => { + enforce.extend({ + notSameAsSibling: (value: string, siblingKey: string) => { + const context = enforce.context(); + const parent = context?.parent(); + const siblingValue = parent?.value[siblingKey]; + + if (value === siblingValue) { + return { + pass: false, + message: () => + `Value "${value}" cannot be the same as ${siblingKey}`, + }; + } + + return true; + }, + }); + }); + + it('should provide custom error message with context info', () => { + expect(() => + enforce({ + password: 'secret', + username: 'different', + }).shape({ + password: enforce.isString(), + username: enforce.isString().notSameAsSibling('password'), + }), + ).not.toThrow(); + }); + + it('should show custom error when values match', () => { + expect(() => + enforce({ + password: 'same', + username: 'same', + }).shape({ + password: enforce.isString(), + username: enforce.isString().notSameAsSibling('password'), + }), + ).toThrow('Value "same" cannot be the same as password'); + }); + }); + + describe('Context in array validation scenarios', () => { + beforeEach(() => { + enforce.extend({ + uniqueInArray: (value: any) => { + const context = enforce.context(); + const parent = context?.parent(); + const array = parent?.value; + + if (!Array.isArray(array)) return true; + + const occurrences = array.filter( + (item: any) => item === value, + ).length; + return occurrences === 1; + }, + }); + }); + + it('should validate uniqueness within array using context', () => { + expect(() => + enforce({ + tags: ['javascript', 'typescript', 'node'], + }).shape({ + tags: enforce.isArrayOf(enforce.isString().uniqueInArray()), + }), + ).not.toThrow(); + }); + + it('should fail when array has duplicates', () => { + expect(() => + enforce({ + tags: ['javascript', 'typescript', 'javascript'], + }).shape({ + tags: enforce.isArrayOf(enforce.isString().uniqueInArray()), + }), + ).toThrow(); + }); + }); + + describe('Context with password confirmation pattern', () => { + beforeEach(() => { + enforce.extend({ + passwordsMatch: (passConfirm: string) => { + const context = enforce.context(); + const parent = context?.parent(); + const password = parent?.value.password; + + return passConfirm === password; + }, + }); + }); + + it('should validate password confirmation matches password', () => { + expect(() => + enforce({ + password: 'SecurePass123', + confirmPassword: 'SecurePass123', + }).shape({ + password: enforce.isString(), + confirmPassword: enforce.isString().passwordsMatch(), + }), + ).not.toThrow(); + }); + + it('should fail when passwords do not match', () => { + expect(() => + enforce({ + password: 'SecurePass123', + confirmPassword: 'Different123', + }).shape({ + password: enforce.isString(), + confirmPassword: enforce.isString().passwordsMatch(), + }), + ).toThrow(); + }); + + // Context is not available in lazy mode + it('should work in lazy mode', () => { + const schema = enforce.shape({ + password: enforce.isString(), + confirmPassword: enforce.isString().passwordsMatch(), + }); + + expect( + schema.run({ + password: 'SecurePass123', + confirmPassword: 'SecurePass123', + }).pass, + ).toBe(true); + + expect( + schema.run({ + password: 'SecurePass123', + confirmPassword: 'Different123', + }).pass, + ).toBe(false); + }); + }); + + describe('Context at top level (no parent)', () => { + beforeEach(() => { + enforce.extend({ + checkNoParent: () => { + const context = enforce.context(); + const parent = context?.parent(); + // At top level, parent should return null + return parent === null; + }, + }); + }); + + it('should return null when calling parent at top level', () => { + // This test verifies the documentation statement: + // "When no levels left, parent will return null" + expect(() => enforce('test').checkNoParent()).not.toThrow(); + }); + }); + + describe('Context with conditional validation', () => { + beforeEach(() => { + enforce.extend({ + requiredIfOtherFieldPresent: (value: any, otherField: string) => { + const context = enforce.context(); + const parent = context?.parent(); + const otherFieldValue = parent?.value[otherField]; + + // If other field is present, this field is required + if (otherFieldValue !== undefined && otherFieldValue !== null) { + return value !== undefined && value !== null && value !== ''; + } + + return true; + }, + }); + }); + + it('should require field when other field is present', () => { + expect(() => + enforce({ + hasShipping: true, + shippingAddress: '123 Main St', + }).loose({ + hasShipping: enforce.isBoolean(), + shippingAddress: enforce + .isString() + .requiredIfOtherFieldPresent('hasShipping'), + }), + ).not.toThrow(); + }); + + it('should not require field when other field is absent', () => { + expect(() => + enforce({ + hasShipping: false, + }).loose({ + hasShipping: enforce.optional(enforce.isBoolean()), + shippingAddress: enforce + .optional(enforce.isString()) + .requiredIfOtherFieldPresent('i_am_missing'), + }), + ).not.toThrow(); + }); + }); + + describe('Real-world form validation scenarios', () => { + beforeEach(() => { + enforce.extend({ + differentFromUsername: (value: string) => { + const context = enforce.context(); + const parent = context?.parent(); + return value !== parent?.value.username; + }, + minAgeIfCountry: (value: number, country: string, minAge: number) => { + const context = enforce.context(); + const parent = context?.parent(); + if (parent?.value.country === country) { + return value >= minAge; + } + return true; + }, + }); + }); + + it('should validate user registration form', () => { + expect(() => + enforce({ + username: 'johndoe', + email: 'john@example.com', + displayName: 'John Doe', + }).shape({ + username: enforce.isString().longerThan(3), + email: enforce.isString().matches(/@/), + displayName: enforce.isString().differentFromUsername(), + }), + ).not.toThrow(); + }); + + it('should validate age based on country context', () => { + expect(() => + enforce({ + country: 'US', + age: 21, + }).shape({ + country: enforce.isString(), + age: enforce.isNumber().minAgeIfCountry('US', 21), + }), + ).not.toThrow(); + }); + + it('should fail age validation for specific country', () => { + expect(() => + enforce({ + country: 'US', + age: 18, + }).shape({ + country: enforce.isString(), + age: enforce.isNumber().minAgeIfCountry('US', 21), + }), + ).toThrow(); + }); + }); + + describe('Context with loose schema', () => { + beforeEach(() => { + enforce.extend({ + matchesParentId: (value: string) => { + const context = enforce.context(); + const parent = context?.parent(); + return value === parent?.value.id; + }, + }); + }); + + it('should work with loose schema allowing extra properties', () => { + expect(() => + enforce({ + id: '123', + reference: '123', + extra: 'field', + }).loose({ + id: enforce.isString(), + reference: enforce.isString().matchesParentId(), + }), + ).not.toThrow(); + }); + }); + + describe('Edge cases and error handling', () => { + beforeEach(() => { + enforce.extend({ + safeParentAccess: () => { + const context = enforce.context(); + // Try to access parent safely + try { + const parent = context?.parent(); + return parent !== undefined; + } catch (e) { + return false; + } + }, + }); + }); + + it('should handle safe parent access', () => { + expect(() => + enforce({ + nested: { + value: 'test', + }, + }).shape({ + nested: enforce.shape({ + value: enforce.isString().safeParentAccess(), + }), + }), + ).not.toThrow(); + }); + }); + + describe('Integration with other schema rules', () => { + beforeEach(() => { + enforce.extend({ + arrayLengthMatchesCount: (value: any[]) => { + const context = enforce.context(); + const parent = context?.parent(); + const count = parent?.value.count; + return value.length === count; + }, + }); + }); + + it('should validate array length based on sibling property', () => { + expect(() => + enforce({ + count: 3, + items: ['a', 'b', 'c'], + }).shape({ + count: enforce.isNumber(), + items: enforce + .isArrayOf(enforce.isString()) + .arrayLengthMatchesCount(), + }), + ).not.toThrow(); + }); + + it('should fail when array length does not match count', () => { + expect(() => + enforce({ + count: 2, + items: ['a', 'b', 'c'], + }).shape({ + count: enforce.isNumber(), + items: enforce + .isArrayOf(enforce.isString()) + .arrayLengthMatchesCount(), + }), + ).toThrow(); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/documentation-examples.test.ts b/packages/n4s/src/__tests__/documentation-examples.test.ts new file mode 100644 index 000000000..8c6400f7a --- /dev/null +++ b/packages/n4s/src/__tests__/documentation-examples.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce, compose, ctx } from '../n4s'; + +describe('Documentation Examples', () => { + describe('compose examples', () => { + it('should validate username with composed rule', () => { + const isValidUsername = compose( + enforce + .isString() + .longerThan(3) + .shorterThan(20) + .matches(/^[a-zA-Z0-9_]+$/), + ); + + expect(isValidUsername.test('john_doe')).toBe(true); + expect(isValidUsername.test('ab')).toBe(false); // too short + expect(isValidUsername.test('john doe')).toBe(false); // contains space + }); + + it('should use composed rule in schema validation', () => { + const isValidUsername = compose( + enforce + .isString() + .longerThan(3) + .shorterThan(20) + .matches(/^[a-zA-Z0-9_]+$/), + ); + + const result = enforce({ username: 'john_doe' }).shape({ + username: isValidUsername, + }); + + expect(result.pass).toBe(true); + }); + }); + + describe('enforce examples', () => { + it('should validate with eager API', () => { + expect(() => { + enforce('hello').isString().longerThan(3); + }).not.toThrow(); + + expect(() => { + enforce('hi').isString().longerThan(3); + }).toThrow(); + }); + + it('should validate with lazy API', () => { + const stringRule = enforce.isString(); + expect(stringRule.test('hello')).toBe(true); + + const result = stringRule.run('hello'); + expect(result.pass).toBe(true); + expect(result.type).toBe('hello'); + }); + + it('should support custom messages', () => { + expect(() => { + enforce('').message('Field is required').isNotEmpty(); + }).toThrow('Field is required'); + }); + + it('should validate with schema', () => { + const result = enforce({ name: 'John', age: 30 }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + expect(result.pass).toBe(true); + }); + }); + + describe('enforce.context examples', () => { + it('should access validation context', () => { + const stringRule = enforce.isString(); + const result = stringRule.run('test'); + + // Context is only available during rule execution + expect(result.pass).toBe(true); + }); + }); + + describe('enforce.extend examples', () => { + it('should extend with custom rules', () => { + enforce.extend({ + isPositive: (value: number) => value > 0, + isBetween: (value: number, min: number, max: number) => + value >= min && value <= max, + }); + + // Eager API + expect(() => { + (enforce(5) as any).isPositive(); + }).not.toThrow(); + + expect(() => { + (enforce(-5) as any).isPositive(); + }).toThrow(); + + // Lazy API + const rule = (enforce as any).isPositive(); + expect(rule.test(5)).toBe(true); + expect(rule.test(-3)).toBe(false); + }); + }); + + describe('RuleInstance examples', () => { + it('should work with RuleInstance', () => { + const stringRule = enforce.isString(); + + // test method returns boolean + expect(stringRule.test('hello')).toBe(true); + expect(stringRule.test(123)).toBe(false); + + // run method returns RuleRunReturn + const result = stringRule.run('hello'); + expect(result.pass).toBe(true); + expect(result.type).toBe('hello'); + }); + + it('should chain rules', () => { + const rule = enforce.isString().longerThan(3); + expect(rule.test('hello')).toBe(true); + expect(rule.test('hi')).toBe(false); + }); + }); + + describe('RuleRunReturn examples', () => { + it('should create passing result', () => { + const rule = enforce.isString(); + const result = rule.run('hello'); + + expect(result.pass).toBe(true); + expect(result.type).toBe('hello'); + expect(result.message).toBeUndefined(); + }); + + it('should create failing result with message', () => { + const rule = enforce.isString().message('Must be a string'); + const result = rule.run(123); + + expect(result.pass).toBe(false); + expect(result.message).toBe('Must be a string'); + }); + }); + + describe('schema rule examples', () => { + it('should validate with shape', () => { + const userSchema = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + email: enforce.isString(), + }); + + expect( + userSchema.test({ + name: 'John', + age: 30, + email: 'john@example.com', + }), + ).toBe(true); + + expect( + userSchema.test({ + name: 'John', + age: 'thirty', // wrong type + email: 'john@example.com', + }), + ).toBe(false); + }); + + it('should validate with loose', () => { + const schema = enforce.loose({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + // Allows extra properties + expect( + schema.test({ + name: 'John', + age: 30, + extra: 'allowed', + }), + ).toBe(true); + }); + + it('should validate with partial', () => { + const schema = enforce.partial({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + // All properties optional + expect(schema.test({})).toBe(true); + expect(schema.test({ name: 'John' })).toBe(true); + expect(schema.test({ age: 30 })).toBe(true); + }); + + it('should validate with optional', () => { + const schema = enforce.shape({ + name: enforce.isString(), + age: enforce.optional(enforce.isNumber()), + }); + + expect(schema.test({ name: 'John' })).toBe(true); + expect(schema.test({ name: 'John', age: 30 })).toBe(true); + expect(schema.test({ name: 'John', age: undefined })).toBe(true); + }); + + it('should validate with isArrayOf', () => { + const numbersRule = enforce.isArrayOf(enforce.isNumber()); + + expect(numbersRule.test([1, 2, 3])).toBe(true); + expect(numbersRule.test([1, 'two', 3])).toBe(false); + }); + }); + + describe('compound rule examples', () => { + it('should validate with allOf', () => { + const rule = enforce.allOf( + enforce.isNumber().greaterThan(0).lessThan(100), + ); + + expect(rule.test(50)).toBe(true); + expect(rule.test(150)).toBe(false); + }); + + it('should validate with anyOf', () => { + const rule = enforce.anyOf(enforce.isString(), enforce.isNumber()); + + expect(rule.test('hello')).toBe(true); + expect(rule.test(123)).toBe(true); + expect(rule.test(true)).toBe(false); + }); + + it('should validate with noneOf', () => { + const rule = enforce.noneOf(enforce.isNull(), enforce.isUndefined()); + + expect(rule.test('hello')).toBe(true); + expect(rule.test(null)).toBe(false); + expect(rule.test(undefined)).toBe(false); + }); + + it('should validate with oneOf', () => { + const rule = enforce.oneOf(enforce.isString(), enforce.isNumber()); + + expect(rule.test('hello')).toBe(true); + expect(rule.test(123)).toBe(true); + }); + }); + + describe('type validation examples', () => { + it('should validate arrays', () => { + const rule = enforce.isArray(); + expect(rule.test([1, 2, 3])).toBe(true); + expect(rule.test('not array')).toBe(false); + }); + + it('should validate strings', () => { + const rule = enforce.isString(); + expect(rule.test('hello')).toBe(true); + expect(rule.test(123)).toBe(false); + }); + + it('should validate numbers', () => { + const rule = enforce.isNumber(); + expect(rule.test(123)).toBe(true); + expect(rule.test('123')).toBe(false); + }); + + it('should validate booleans', () => { + const rule = enforce.isBoolean(); + expect(rule.test(true)).toBe(true); + expect(rule.test('true')).toBe(false); + }); + + it('should validate null', () => { + const rule = enforce.isNull(); + expect(rule.test(null)).toBe(true); + expect(rule.test(undefined)).toBe(false); + }); + + it('should validate undefined', () => { + const rule = enforce.isUndefined(); + expect(rule.test(undefined)).toBe(true); + expect(rule.test(null)).toBe(false); + }); + + it('should validate nullish', () => { + const rule = enforce.isNullish(); + expect(rule.test(null)).toBe(true); + expect(rule.test(undefined)).toBe(true); + expect(rule.test(0)).toBe(false); + }); + + it('should validate numeric', () => { + const rule = enforce.isNumeric(); + expect(rule.test(123)).toBe(true); + expect(rule.test('123')).toBe(true); + expect(rule.test('abc')).toBe(false); + }); + }); + + describe('extendEnforce examples', () => { + it('should extend with custom rules and use in both APIs', () => { + enforce.extend({ + isEven: (value: number) => value % 2 === 0, + }); + + // Eager API + expect(() => { + (enforce(10) as any).isEven(); + }).not.toThrow(); + + expect(() => { + (enforce(11) as any).isEven(); + }).toThrow(); + + // Lazy API + const evenRule = (enforce as any).isEven(); + expect(evenRule.test(10)).toBe(true); + expect(evenRule.test(11)).toBe(false); + }); + + it('should combine custom rules with built-in rules', () => { + enforce.extend({ + isPositiveNumber: (value: number) => value > 0, + isBetweenRange: (value: number, min: number, max: number) => + value >= min && value <= max, + }); + + const schema = enforce.shape({ + age: (enforce as any) + .isNumber() + .isPositiveNumber() + .isBetweenRange(18, 100), + score: (enforce as any).isNumber().isEven(), + }); + + expect(schema.test({ age: 25, score: 100 })).toBe(true); + expect(schema.test({ age: -5, score: 100 })).toBe(false); + expect(schema.test({ age: 25, score: 99 })).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/enforce.test.ts b/packages/n4s/src/__tests__/enforce.test.ts deleted file mode 100644 index c4941e299..000000000 --- a/packages/n4s/src/__tests__/enforce.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -import { enforce } from 'enforce'; - -import * as ruleReturn from 'ruleReturn'; - -describe(`enforce`, () => { - describe('eager assertions', () => { - it('Should throw an error when invalid', () => { - expect(() => enforce('4').isNumber()).toThrow(); - expect(() => enforce('4').isNumber().isNotNumeric()).toThrow(); - }); - - it('Should return silently when rule passes', () => { - enforce(1).isNumber(); - enforce(1).greaterThan(0); - enforce(1).greaterThan(0).lessThan(10); - }); - - describe('Custom Assertions', () => { - beforeEach(() => { - enforce.extend({ - startsWithUnderscore: (value: any) => ({ - pass: value.startsWith('_'), - message: value + ' does not start with underscore', - }), - }); - }); - - it('should return silently when rule passes', () => { - enforce('_').startsWithUnderscore(); - enforce('_').startsWithUnderscore().isString(); - }); - - it('should throw message string when rule fails', () => { - expect(() => enforce(':(').startsWithUnderscore()).toThrow( - ':( does not start with underscore', - ); - expect(() => - enforce(':(').isString().startsWithUnderscore().isNumber(), - ).toThrow(':( does not start with underscore'); - }); - }); - }); - - describe('enforce..test for boolean return', () => { - it('Should return true when valid', () => { - expect(enforce.isNumber().test(1)).toBe(true); - expect(enforce.isArray().test([])).toBe(true); - expect(enforce.greaterThan(5).test(6)).toBe(true); - expect(enforce.greaterThan(5).lessThan(7).test(6)).toBe(true); - }); - - it('Should return false when invalid', () => { - expect(enforce.isNumber().test('1')).toBe(false); - expect(enforce.isArray().test({})).toBe(false); - expect(enforce.greaterThan(6).test(5)).toBe(false); - expect(enforce.greaterThan(7).lessThan(5).test(6)).toBe(false); - }); - }); - - describe('enforce..run for structured return', () => { - it('Should return pass:true when valid', () => { - expect(enforce.isNumber().run(1)).toEqual(ruleReturn.passing()); - expect(enforce.isArray().run([])).toEqual(ruleReturn.passing()); - expect(enforce.greaterThan(5).run(6)).toEqual(ruleReturn.passing()); - expect(enforce.greaterThan(5).lessThan(7).run(6)).toEqual( - ruleReturn.passing(), - ); - }); - - it('Should return pass:false when invalid', () => { - expect(enforce.isNumber().run('1')).toEqual(ruleReturn.failing()); - expect(enforce.isArray().run({})).toEqual(ruleReturn.failing()); - expect(enforce.greaterThan(6).run(5)).toEqual(ruleReturn.failing()); - expect(enforce.greaterThan(7).lessThan(5).run(6)).toEqual( - ruleReturn.failing(), - ); - }); - }); - - describe('enforce.extend for custom validators', () => { - beforeEach(() => { - enforce.extend({ - isEmail(value: string) { - return { - pass: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value), - message: () => value + ' is not a valid email address', - }; - }, - }); - }); - describe('enforce..test for boolean return', () => { - it('Should return true when valid', () => { - expect(enforce.isEmail().test('example@gmail.com')).toBe(true); - expect(enforce.isEmail().isString().test('example@gmail.com')).toBe( - true, - ); - }); - - it('Should return false when invalid', () => { - expect(enforce.isEmail().test('example!gmail.com')).toBe(false); - expect(enforce.isEmail().isString().test('example!gmail.com')).toBe( - false, - ); - }); - }); - - describe('enforce..run for structured return', () => { - it('Should return pass:true when valid', () => { - expect(enforce.isEmail().run('example@gmail.com')).toEqual({ - pass: true, - }); - - expect(enforce.isEmail().isString().run('example@gmail.com')).toEqual({ - pass: true, - }); - }); - - it('Should return pass:false with message when invalid', () => { - expect(enforce.isEmail().run('example!gmail.com')).toEqual({ - pass: false, - message: 'example!gmail.com is not a valid email address', - }); - - expect(enforce.isEmail().isString().run('example!gmail.com')).toEqual({ - pass: false, - message: 'example!gmail.com is not a valid email address', - }); - }); - }); - - describe('When accessing a rule that does not exist', () => { - it('Should return undefined', () => { - expect(enforce.doesNotExist).toBeUndefined(); - }); - }); - }); - - describe('Test enforce().message', () => { - it('Is enforce().message a function?', () => { - expect(enforce('').message).toBeInstanceOf(Function); - }); - }); -}); diff --git a/packages/n4s/src/__tests__/enforceEager.test.ts b/packages/n4s/src/__tests__/enforceEager.test.ts deleted file mode 100644 index 9f0970f3d..000000000 --- a/packages/n4s/src/__tests__/enforceEager.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; - -import enforceEager from 'enforceEager'; - -describe(`enforce eager`, () => { - it('should throw when rule fails', () => { - expect(() => enforceEager([]).isString()).toThrow(); - expect(() => enforceEager(1).greaterThan(1)).toThrow(); - expect(() => enforceEager(1).greaterThan(1).lessThan(0)).toThrow(); - }); - - it('Should return silently when rule passes', () => { - enforceEager(1).isNumber(); - enforceEager(1).greaterThan(0); - enforceEager(1).greaterThan(0).lessThan(10); - }); -}); diff --git a/packages/n4s/src/__tests__/extend.test.ts b/packages/n4s/src/__tests__/extend.test.ts new file mode 100644 index 000000000..a96c1f1a5 --- /dev/null +++ b/packages/n4s/src/__tests__/extend.test.ts @@ -0,0 +1,1329 @@ +// @ts-nocheck +/* eslint-disable sort-keys, @typescript-eslint/no-unused-vars, no-unused-vars, no-useless-escape */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('enforce.extend', () => { + let enforce: any; + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + ({ enforce } = await import('n4s')); + }); + + describe('Basic functionality', () => { + describe('Boolean return values', () => { + beforeEach(() => { + enforce.extend({ + isValidEmail: (value: string) => value.indexOf('@') > -1, + hasKey: (value: Record, key: string) => + value.hasOwnProperty(key), + }); + }); + + it('Should allow custom rule with boolean return value (true)', () => { + expect(() => enforce('test@example.com').isValidEmail()).not.toThrow(); + }); + + it('Should allow custom rule with boolean return value (false)', () => { + expect(() => enforce('invalid-email').isValidEmail()).toThrow(); + }); + + it('Should work with multiple arguments', () => { + expect(() => enforce({ name: 'John' }).hasKey('name')).not.toThrow(); + expect(() => enforce({ name: 'John' }).hasKey('age')).toThrow(); + }); + + it('Should work in lazy mode with .run()', () => { + expect(enforce.isValidEmail().run('test@example.com').pass).toBe(true); + expect(enforce.isValidEmail().run('invalid-email').pass).toBe(false); + }); + + it('Should work in lazy mode with .run() returning full result', () => { + expect(enforce.isValidEmail().run('test@example.com')).toEqual({ + pass: true, + type: 'test@example.com', + }); + expect(enforce.isValidEmail().run('invalid-email')).toEqual({ + pass: false, + type: 'invalid-email', + }); + }); + }); + + describe('Object return values with pass and message', () => { + beforeEach(() => { + enforce.extend({ + isValidEmail: (value: string) => ({ + pass: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + message: () => `${value} is not a valid email address`, + }), + isWithinRange: (value: number, floor: number, ceiling: number) => { + const pass = value >= floor && value <= ceiling; + return { + pass, + message: () => + pass + ? `expected ${value} not to be within range ${floor} - ${ceiling}` + : `expected ${value} to be within range ${floor} - ${ceiling}`, + }; + }, + }); + }); + + it('Should handle object return with pass: true', () => { + expect(() => enforce('test@example.com').isValidEmail()).not.toThrow(); + }); + + it('Should handle object return with pass: false and use custom message', () => { + expect(() => enforce('invalid').isValidEmail()).toThrow( + 'invalid is not a valid email address', + ); + }); + + it('Should work with multiple arguments', () => { + expect(() => enforce(5).isWithinRange(1, 10)).not.toThrow(); + expect(() => enforce(15).isWithinRange(1, 10)).toThrow( + 'expected 15 to be within range 1 - 10', + ); + }); + + it('Should work in lazy mode with .run()', () => { + expect(enforce.isValidEmail().run('test@example.com').pass).toBe(true); + expect(enforce.isValidEmail().run('invalid').pass).toBe(false); + }); + + it('Should work in lazy mode with .run() returning full result', () => { + expect(enforce.isValidEmail().run('test@example.com')).toEqual({ + pass: true, + type: 'test@example.com', + }); + expect(enforce.isValidEmail().run('invalid')).toEqual({ + pass: false, + type: 'invalid', + message: 'invalid is not a valid email address', + }); + }); + }); + + describe('Object return with just message string', () => { + beforeEach(() => { + enforce.extend({ + customRule: () => ({ + pass: false, + message: 'Static error message', + }), + }); + }); + + it('Should handle message as string instead of function', () => { + expect(() => enforce('value').customRule()).toThrow( + 'Static error message', + ); + }); + + it('Should work with .run()', () => { + expect(enforce.customRule().run('value')).toEqual({ + pass: false, + type: 'value', + message: 'Static error message', + }); + }); + }); + }); + + describe('Chaining custom rules', () => { + beforeEach(() => { + enforce.extend({ + startsWithUnderscore: (value: string) => ({ + pass: value.startsWith('_'), + message: () => `${value} does not start with underscore`, + }), + hasLength: (value: string, length: number) => value.length === length, + isLowerCase: (value: string) => value === value.toLowerCase(), + }); + }); + + it('Should allow chaining custom rules with built-in rules', () => { + expect(() => + enforce('_test').startsWithUnderscore().isString(), + ).not.toThrow(); + }); + + it('Should allow chaining multiple custom rules', () => { + expect(() => + enforce('_test').startsWithUnderscore().hasLength(5).isLowerCase(), + ).not.toThrow(); + }); + + it('Should throw on first failed rule in chain', () => { + expect(() => enforce('test').startsWithUnderscore().hasLength(4)).toThrow( + 'test does not start with underscore', + ); + }); + + it('Should work with lazy evaluation', () => { + expect( + enforce.startsWithUnderscore().hasLength(5).isLowerCase().run('_test') + .pass, + ).toBe(true); + expect( + enforce.startsWithUnderscore().hasLength(5).isLowerCase().run('_TEST') + .pass, + ).toBe(false); + }); + }); + + describe('Edge cases and error handling', () => { + it('Should handle custom rule that returns undefined', () => { + enforce.extend({ + returnsUndefined: () => undefined as any, + }); + expect(() => enforce('test').returnsUndefined()).toThrow(); + }); + + it('Should handle custom rule that returns null', () => { + enforce.extend({ + returnsNull: () => null as any, + }); + expect(() => enforce('test').returnsNull()).toThrow(); + }); + + it('Should handle custom rule that throws an error', () => { + enforce.extend({ + throwsError: () => { + throw new Error('Custom error'); + }, + }); + expect(() => enforce('test').throwsError()).toThrow('Custom error'); + }); + + it('Should handle custom rule with no arguments', () => { + enforce.extend({ + alwaysTrue: () => true, + alwaysFalse: () => false, + }); + expect(() => enforce('test').alwaysTrue()).not.toThrow(); + expect(() => enforce('test').alwaysFalse()).toThrow(); + }); + + it('Should handle custom rule receiving different value types', () => { + enforce.extend({ + checkType: (value: any) => ({ + pass: true, + message: () => `Type: ${typeof value}`, + }), + }); + expect(() => enforce(123).checkType()).not.toThrow(); + expect(() => enforce('string').checkType()).not.toThrow(); + expect(() => enforce(null).checkType()).not.toThrow(); + expect(() => enforce(undefined).checkType()).not.toThrow(); + expect(() => enforce({}).checkType()).not.toThrow(); + expect(() => enforce([]).checkType()).not.toThrow(); + }); + + it('Should handle custom rules with many arguments', () => { + enforce.extend({ + sumEquals: (value: number, ...args: number[]) => + value === args.reduce((sum, n) => sum + n, 0), + }); + expect(() => enforce(10).sumEquals(1, 2, 3, 4)).not.toThrow(); + expect(() => enforce(10).sumEquals(1, 2, 3)).toThrow(); + }); + }); + + describe('Multiple extend calls', () => { + it('Should allow multiple extend calls to add different rules', () => { + enforce.extend({ + customRule1: () => true, + }); + enforce.extend({ + customRule2: () => true, + }); + expect(() => enforce('test').customRule1().customRule2()).not.toThrow(); + }); + + it('Should allow overriding existing custom rules', () => { + enforce.extend({ + toggleRule: () => true, + }); + expect(() => enforce('test').toggleRule()).not.toThrow(); + + enforce.extend({ + toggleRule: () => false, + }); + expect(() => enforce('test').toggleRule()).toThrow(); + }); + + it('Should not affect built-in rules', () => { + enforce.extend({ + customRule: () => true, + }); + // Built-in rules should still work + expect(() => enforce('test').isString()).not.toThrow(); + expect(() => enforce(123).isNumber()).not.toThrow(); + }); + }); + + describe('Integration with schema rules', () => { + beforeEach(() => { + enforce.extend({ + isEmail: (value: string) => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + isAdult: (value: number) => value >= 18, + }); + }); + + it('Should work within shape()', () => { + const schema = enforce.shape({ + email: enforce.isString().isEmail(), + age: enforce.isNumber().isAdult(), + }); + + expect(schema.run({ email: 'test@example.com', age: 25 }).pass).toBe( + true, + ); + expect(schema.run({ email: 'invalid', age: 16 }).pass).toBe(false); + }); + + it('Should work within loose()', () => { + const schema = enforce.loose({ + email: enforce.isString().isEmail(), + }); + expect( + schema.run({ email: 'test@example.com', extra: 'field' }).pass, + ).toBe(true); + }); + + it('Should work within isArrayOf()', () => { + const rule = enforce.isArrayOf(enforce.isString().isEmail()); + expect(rule.run(['test@example.com', 'another@example.com']).pass).toBe( + true, + ); + expect(rule.run(['test@example.com', 'invalid']).pass).toBe(false); + }); + + it('Should work within optional()', () => { + const schema = enforce.shape({ + email: enforce.optional(enforce.isString().isEmail()), + }); + expect(schema.run({ email: 'test@example.com' }).pass).toBe(true); + expect(schema.run({}).pass).toBe(true); + }); + }); + + describe('Integration with compound rules', () => { + beforeEach(() => { + enforce.extend({ + isPositive: (value: number) => value > 0, + isEven: (value: number) => value % 2 === 0, + isOdd: (value: number) => value % 2 !== 0, + }); + }); + + it('Should work within allOf()', () => { + const ok = enforce + .allOf(enforce.isNumber(), enforce.isPositive(), enforce.isEven()) + .run(4).pass; + expect(ok).toBe(true); + + const notOk = enforce + .allOf(enforce.isNumber(), enforce.isPositive(), enforce.isEven()) + .run(-4).pass; + expect(notOk).toBe(false); + }); + + it('Should work within anyOf()', () => { + const res = enforce.anyOf(enforce.isEven(), enforce.isOdd()).run(3).pass; + expect(res).toBe(true); + }); + + it('Should work within noneOf()', () => { + const res = enforce + .noneOf(enforce.isNumber(), enforce.isPositive()) + .run('string').pass; + expect(res).toBe(true); + }); + + it('Should work within oneOf()', () => { + expect(() => + enforce(3).oneOf(enforce.isEven(), enforce.isOdd(), enforce.isNumber()), + ).toThrow(); // Fails because two rules pass (isOdd and isNumber) + }); + }); + + describe('Custom rules with enforce.context()', () => { + beforeEach(() => { + enforce.extend({ + contextAware: (value: string) => { + const context = enforce.context(); + return !!context; + }, + accessParent: (value: string) => { + const context = enforce.context(); + return context?.parent !== undefined; + }, + accessMeta: (value: string) => { + const context = enforce.context(); + return context?.meta !== undefined; + }, + }); + }); + + it('Should have access to context', () => { + expect(() => enforce('test').contextAware()).not.toThrow(); + }); + + it('Should have access to parent in context', () => { + expect(() => enforce('test').accessParent()).not.toThrow(); + }); + + it('Should have access to meta in context', () => { + expect(() => enforce('test').accessMeta()).not.toThrow(); + }); + + it('Should allow traversing parent values', () => { + enforce.extend({ + isFriendTheSameAsUser: (value: string) => { + const context = enforce.context(); + if (value === context?.parent()?.parent()?.value.username) { + return { + pass: false, + message: () => 'Friend cannot be the same as username', + }; + } + return true; + }, + }); + + expect(() => + enforce({ + username: 'johndoe', + friends: ['Mike', 'Jim'], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).not.toThrow(); + + expect(() => + enforce({ + username: 'johndoe', + friends: ['Mike', 'Jim', 'johndoe'], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).toThrow(/Friend cannot be the same as username|enforce/); + }); + }); + + describe('Custom rules with enforce.message()', () => { + beforeEach(() => { + enforce.extend({ + ruleWithMessage: () => ({ + pass: false, + message: () => 'Original message', + }), + ruleWithoutMessage: () => false, + }); + }); + + it('Should allow overriding custom rule message', () => { + expect(() => + enforce('test').message('Custom message').ruleWithMessage(), + ).toThrow('Custom message'); + }); + + it('Should allow overriding message on rules without explicit message', () => { + expect(() => + enforce('test').message('Custom message').ruleWithoutMessage(), + ).toThrow('Custom message'); + }); + + it('Should work with message as function', () => { + expect(() => + enforce('test') + .message(() => 'Custom message') + .ruleWithMessage(), + ).toThrow('Custom message'); + }); + }); + + describe('Type coercion and edge cases', () => { + beforeEach(() => { + enforce.extend({ + checksEquality: (value: any, expected: any) => value === expected, + checksLooseEquality: (value: any, expected: any) => value == expected, + }); + }); + + it('Should handle strict equality', () => { + expect(() => enforce(1).checksEquality(1)).not.toThrow(); + expect(() => enforce(1).checksEquality('1')).toThrow(); + expect(() => enforce(true).checksEquality(1)).toThrow(); + expect(() => enforce(null).checksEquality(undefined)).toThrow(); + }); + + it('Should handle loose equality', () => { + expect(() => enforce(1).checksLooseEquality('1')).not.toThrow(); + expect(() => enforce(true).checksLooseEquality(1)).not.toThrow(); + expect(() => enforce(null).checksLooseEquality(undefined)).not.toThrow(); + }); + + it('Should handle falsy values correctly', () => { + enforce.extend({ + isFalsy: (value: any) => !value, + isTruthy: (value: any) => !!value, + }); + + expect(() => enforce(0).isFalsy()).not.toThrow(); + expect(() => enforce('').isFalsy()).not.toThrow(); + expect(() => enforce(false).isFalsy()).not.toThrow(); + expect(() => enforce(null).isFalsy()).not.toThrow(); + expect(() => enforce(undefined).isFalsy()).not.toThrow(); + + expect(() => enforce(1).isTruthy()).not.toThrow(); + expect(() => enforce('text').isTruthy()).not.toThrow(); + expect(() => enforce(true).isTruthy()).not.toThrow(); + expect(() => enforce({}).isTruthy()).not.toThrow(); + expect(() => enforce([]).isTruthy()).not.toThrow(); + }); + }); + + describe('Complex real-world scenarios', () => { + it('Should support password validation rules', () => { + enforce.extend({ + hasMinLength: (value: string, length: number) => value.length >= length, + hasUpperCase: (value: string) => /[A-Z]/.test(value), + hasLowerCase: (value: string) => /[a-z]/.test(value), + hasNumber: (value: string) => /[0-9]/.test(value), + hasSpecialChar: (value: string) => /[!@#$%^&*(),.?":{}|<>]/.test(value), + }); + + const password = 'SecureP@ss123'; + expect(() => + enforce(password) + .hasMinLength(8) + .hasUpperCase() + .hasLowerCase() + .hasNumber() + .hasSpecialChar(), + ).not.toThrow(); + + expect(() => enforce('weak').hasMinLength(8)).toThrow(); + expect(() => enforce('alllowercase').hasUpperCase()).toThrow(); + }); + + it('Should support conditional validation', () => { + enforce.extend({ + passwordsMatch: (passConfirm: string, password: string) => + passConfirm === password, + isValidIf: (value: any, condition: boolean, validator: () => boolean) => + !condition || validator(), + }); + + expect(() => enforce('pass123').passwordsMatch('pass123')).not.toThrow(); + expect(() => enforce('pass123').passwordsMatch('different')).toThrow(); + }); + + it('Should support date range validation', () => { + enforce.extend({ + isAfter: (value: Date, date: Date) => value > date, + isBefore: (value: Date, date: Date) => value < date, + isBetween: (value: Date, start: Date, end: Date) => + value >= start && value <= end, + }); + + const now = new Date(); + const yesterday = new Date(now.getTime() - 86400000); + const tomorrow = new Date(now.getTime() + 86400000); + + expect(() => enforce(now).isAfter(yesterday)).not.toThrow(); + expect(() => enforce(now).isBefore(tomorrow)).not.toThrow(); + expect(() => enforce(now).isBetween(yesterday, tomorrow)).not.toThrow(); + }); + }); + + describe('Performance and stress tests', () => { + it('Should handle many custom rules', () => { + const rules: Record boolean> = {}; + for (let i = 0; i < 100; i++) { + rules[`rule${i}`] = () => true; + } + enforce.extend(rules); + + expect(() => enforce('test').rule0().rule50().rule99()).not.toThrow(); + }); + + it('Should handle deeply nested validation with custom rules', () => { + enforce.extend({ + isEmail: (value: string) => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + isAdult: (value: number) => value >= 18, + }); + + const schema = enforce.shape({ + user: enforce.shape({ + profile: enforce.shape({ + contact: enforce.shape({ + email: enforce.isString().isEmail(), + }), + age: enforce.isNumber().isAdult(), + }), + }), + }); + expect( + schema.run({ + user: { + profile: { contact: { email: 'test@example.com' }, age: 25 }, + }, + }).pass, + ).toBe(true); + }); + }); + + describe('Error message customization', () => { + it('Should support dynamic error messages based on input', () => { + enforce.extend({ + isInRange: (value: number, min: number, max: number) => ({ + pass: value >= min && value <= max, + message: () => + `Value ${value} is outside the allowed range of ${min}-${max}`, + }), + }); + + expect(() => enforce(15).isInRange(1, 10)).toThrow( + 'Value 15 is outside the allowed range of 1-10', + ); + }); + + it('Should support error messages with context', () => { + enforce.extend({ + notSameAsField: (value: string, fieldName: string) => { + const context = enforce.context(); + const parentValue = context?.parent()?.value; + return { + pass: value !== parentValue?.[fieldName], + message: () => + `Value cannot be the same as ${fieldName}: ${parentValue?.[fieldName]}`, + }; + }, + }); + + expect(() => + enforce({ + username: 'johndoe', + displayName: 'johndoe', + }).shape({ + username: enforce.isString(), + displayName: enforce.isString().notSameAsField('username'), + }), + ).toThrow(/Value cannot be the same as username|enforce/); + }); + }); + + describe('Async behavior considerations', () => { + it('Should handle custom rules that might be used async (returning promises should fail sync)', () => { + enforce.extend({ + // This simulates someone accidentally returning a promise + asyncRule: () => Promise.resolve(true) as any, + }); + + // In n4spath, invalid return values should throw + expect(() => enforce('test').asyncRule()).toThrow(); + }); + }); + + describe('Cleanup and isolation', () => { + it('Should not leak custom rules between test runs', () => { + // This test ensures that custom rules don't persist unexpectedly + // Note: In actual implementation, this might require cleanup between tests + + enforce.extend({ + temporaryRule: () => true, + }); + + expect((enforce as any).temporaryRule).toBeDefined(); + }); + }); + + describe('Integration with condition()', () => { + it('Should work alongside enforce.condition()', () => { + enforce.extend({ + isEven: (value: number) => value % 2 === 0, + }); + + expect(enforce.condition(() => true).run(4).pass).toBe(true); + expect(enforce.condition(() => false).run(3).pass).toBe(false); + }); + }); + + describe('Comprehensive Lazy API tests', () => { + describe('Boolean return values with lazy API', () => { + beforeEach(() => { + enforce.extend({ + isValidEmail: (value: string) => value.indexOf('@') > -1, + hasKey: (value: Record, key: string) => + value.hasOwnProperty(key), + isPositive: (value: number) => value > 0, + isEven: (value: number) => value % 2 === 0, + isLengthBetween: (value: string, min: number, max: number) => + value.length >= min && value.length <= max, + }); + }); + + it('Should work with single argument - passing case', () => { + const result = enforce.isValidEmail().run('test@example.com'); + expect(result).toEqual({ + pass: true, + type: 'test@example.com', + }); + }); + + it('Should work with single argument - failing case', () => { + const result = enforce.isValidEmail().run('invalid-email'); + expect(result).toEqual({ + pass: false, + type: 'invalid-email', + }); + }); + + it('Should work with multiple arguments - passing case', () => { + const result = enforce.hasKey('name').run({ name: 'John', age: 30 }); + expect(result).toEqual({ + pass: true, + type: { name: 'John', age: 30 }, + }); + }); + + it('Should work with multiple arguments - failing case', () => { + const result = enforce.hasKey('email').run({ name: 'John', age: 30 }); + expect(result).toEqual({ + pass: false, + type: { name: 'John', age: 30 }, + }); + }); + + it('Should work with numeric validations', () => { + expect(enforce.isPositive().run(5)).toEqual({ + pass: true, + type: 5, + }); + expect(enforce.isPositive().run(-5)).toEqual({ + pass: false, + type: -5, + }); + expect(enforce.isEven().run(4)).toEqual({ + pass: true, + type: 4, + }); + expect(enforce.isEven().run(3)).toEqual({ + pass: false, + type: 3, + }); + }); + + it('Should work with multiple arguments and complex types', () => { + expect(enforce.isLengthBetween(3, 10).run('hello')).toEqual({ + pass: true, + type: 'hello', + }); + expect(enforce.isLengthBetween(3, 10).run('hi')).toEqual({ + pass: false, + type: 'hi', + }); + expect(enforce.isLengthBetween(3, 10).run('this is too long')).toEqual({ + pass: false, + type: 'this is too long', + }); + }); + + it('Should handle different data types', () => { + enforce.extend({ + alwaysTrue: () => true, + alwaysFalse: () => false, + }); + + // String + expect(enforce.alwaysTrue().run('string')).toEqual({ + pass: true, + type: 'string', + }); + + // Number + expect(enforce.alwaysTrue().run(42)).toEqual({ + pass: true, + type: 42, + }); + + // Boolean + expect(enforce.alwaysTrue().run(true)).toEqual({ + pass: true, + type: true, + }); + + // Object + const obj = { key: 'value' }; + expect(enforce.alwaysTrue().run(obj)).toEqual({ + pass: true, + type: obj, + }); + + // Array + const arr = [1, 2, 3]; + expect(enforce.alwaysTrue().run(arr)).toEqual({ + pass: true, + type: arr, + }); + + // Null + expect(enforce.alwaysTrue().run(null)).toEqual({ + pass: true, + type: null, + }); + + // Undefined + expect(enforce.alwaysTrue().run(undefined)).toEqual({ + pass: true, + type: undefined, + }); + }); + }); + + describe('Object return values with lazy API', () => { + beforeEach(() => { + enforce.extend({ + isValidEmailWithMessage: (value: string) => ({ + pass: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + message: () => `${value} is not a valid email address`, + }), + isWithinRange: (value: number, floor: number, ceiling: number) => { + const pass = value >= floor && value <= ceiling; + return { + pass, + message: () => + pass + ? `expected ${value} not to be within range ${floor} - ${ceiling}` + : `expected ${value} to be within range ${floor} - ${ceiling}`, + }; + }, + hasExactLength: (value: string, length: number) => ({ + pass: value.length === length, + message: () => `Expected length ${length}, got ${value.length}`, + }), + }); + }); + + it('Should work with object return - passing case', () => { + const result = enforce + .isValidEmailWithMessage() + .run('test@example.com'); + expect(result).toEqual({ + pass: true, + type: 'test@example.com', + }); + }); + + it('Should work with object return - failing case', () => { + const result = enforce.isValidEmailWithMessage().run('invalid'); + expect(result).toEqual({ + pass: false, + type: 'invalid', + message: 'invalid is not a valid email address', + }); + }); + + it('Should work with multiple arguments and object return', () => { + expect(enforce.isWithinRange(1, 10).run(5)).toEqual({ + pass: true, + type: 5, + }); + expect(enforce.isWithinRange(1, 10).run(15)).toEqual({ + pass: false, + type: 15, + message: 'expected 15 to be within range 1 - 10', + }); + }); + + it('Should work with complex validation scenarios', () => { + expect(enforce.hasExactLength(5).run('hello')).toEqual({ + pass: true, + type: 'hello', + }); + expect(enforce.hasExactLength(5).run('hi')).toEqual({ + pass: false, + type: 'hi', + message: 'Expected length 5, got 2', + }); + }); + }); + + describe('Chaining with lazy API', () => { + beforeEach(() => { + enforce.extend({ + startsWithUnderscore: (value: string) => value.startsWith('_'), + hasMinLength: (value: string, length: number) => + value.length >= length, + isLowerCase: (value: string) => value === value.toLowerCase(), + isAlphanumeric: (value: string) => /^[a-zA-Z0-9]+$/.test(value), + }); + }); + + it('Should work with chained custom rules', () => { + const result = enforce + .startsWithUnderscore() + .hasMinLength(5) + .isLowerCase() + .run('_test'); + expect(result).toEqual({ + pass: true, + type: '_test', + }); + }); + + it('Should fail on first failed rule in chain', () => { + const result = enforce + .startsWithUnderscore() + .hasMinLength(5) + .isLowerCase() + .run('test'); + expect(result).toEqual({ + pass: false, + type: 'test', + }); + }); + + it('Should work with mix of custom and built-in rules', () => { + const result = enforce + .isString() + .startsWithUnderscore() + .hasMinLength(3) + .run('_ab'); + expect(result).toEqual({ + pass: true, + type: '_ab', + }); + }); + + it('Should work with complex chaining scenarios', () => { + expect( + enforce.isString().hasMinLength(1).isAlphanumeric().run('test123'), + ).toEqual({ + pass: true, + type: 'test123', + }); + + expect( + enforce.isString().hasMinLength(1).isAlphanumeric().run('test-123'), + ).toEqual({ + pass: false, + type: 'test-123', + }); + }); + }); + + describe('Error handling in lazy API', () => { + beforeEach(() => { + enforce.extend({ + throwsError: () => { + throw new Error('Custom validation error'); + }, + returnsUndefined: () => undefined as any, + returnsNull: () => null as any, + returnsInvalidObject: () => ({ invalid: true }) as any, + }); + }); + + it('Should handle rules that throw errors', () => { + expect(() => enforce.throwsError().run('test')).toThrow( + 'Custom validation error', + ); + }); + + it('Should handle rules that return undefined', () => { + const result = enforce.returnsUndefined().run('test'); + expect(result).toEqual({ + pass: false, + type: 'test', + }); + }); + + it('Should handle rules that return null', () => { + const result = enforce.returnsNull().run('test'); + expect(result).toEqual({ + pass: false, + type: 'test', + }); + }); + + it('Should handle rules that return invalid objects', () => { + const result = enforce.returnsInvalidObject().run('test'); + expect(result).toEqual({ + pass: false, + type: 'test', + }); + }); + }); + + describe('Type coercion and edge cases in lazy API', () => { + beforeEach(() => { + enforce.extend({ + strictEquals: (value: any, expected: any) => value === expected, + looseEquals: (value: any, expected: any) => value == expected, + isTruthy: (value: any) => !!value, + isFalsy: (value: any) => !value, + typeCheck: (value: any, expectedType: string) => + typeof value === expectedType, + }); + }); + + it('Should handle strict equality comparisons', () => { + expect(enforce.strictEquals(1).run(1)).toEqual({ + pass: true, + type: 1, + }); + expect(enforce.strictEquals(1).run('1')).toEqual({ + pass: false, + type: '1', + }); + expect(enforce.strictEquals(true).run(1)).toEqual({ + pass: false, + type: 1, + }); + }); + + it('Should handle loose equality comparisons', () => { + expect(enforce.looseEquals(1).run('1')).toEqual({ + pass: true, + type: '1', + }); + expect(enforce.looseEquals(true).run(1)).toEqual({ + pass: true, + type: 1, + }); + expect(enforce.looseEquals(null).run(undefined)).toEqual({ + pass: true, + type: undefined, + }); + }); + + it('Should handle truthy/falsy checks', () => { + // Truthy values + expect(enforce.isTruthy().run(1)).toEqual({ pass: true, type: 1 }); + expect(enforce.isTruthy().run('text')).toEqual({ + pass: true, + type: 'text', + }); + expect(enforce.isTruthy().run(true)).toEqual({ + pass: true, + type: true, + }); + expect(enforce.isTruthy().run({})).toEqual({ pass: true, type: {} }); + expect(enforce.isTruthy().run([])).toEqual({ pass: true, type: [] }); + + // Falsy values + expect(enforce.isFalsy().run(0)).toEqual({ pass: true, type: 0 }); + expect(enforce.isFalsy().run('')).toEqual({ pass: true, type: '' }); + expect(enforce.isFalsy().run(false)).toEqual({ + pass: true, + type: false, + }); + expect(enforce.isFalsy().run(null)).toEqual({ pass: true, type: null }); + expect(enforce.isFalsy().run(undefined)).toEqual({ + pass: true, + type: undefined, + }); + }); + + it('Should handle type checking', () => { + expect(enforce.typeCheck('string').run('hello')).toEqual({ + pass: true, + type: 'hello', + }); + expect(enforce.typeCheck('number').run(42)).toEqual({ + pass: true, + type: 42, + }); + expect(enforce.typeCheck('boolean').run(true)).toEqual({ + pass: true, + type: true, + }); + expect(enforce.typeCheck('object').run({})).toEqual({ + pass: true, + type: {}, + }); + expect(enforce.typeCheck('object').run(null)).toEqual({ + pass: true, + type: null, + }); + expect(enforce.typeCheck('undefined').run(undefined)).toEqual({ + pass: true, + type: undefined, + }); + + // Type mismatches + expect(enforce.typeCheck('string').run(42)).toEqual({ + pass: false, + type: 42, + }); + expect(enforce.typeCheck('number').run('hello')).toEqual({ + pass: false, + type: 'hello', + }); + }); + }); + + describe('Complex scenarios with lazy API', () => { + beforeEach(() => { + enforce.extend({ + isEmail: (value: string) => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + isStrongPassword: (value: string) => { + const hasLength = value.length >= 8; + const hasUpper = /[A-Z]/.test(value); + const hasLower = /[a-z]/.test(value); + const hasNumber = /[0-9]/.test(value); + const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value); + return hasLength && hasUpper && hasLower && hasNumber && hasSpecial; + }, + isPhoneNumber: (value: string) => + /^\+?[\d\s\-\(\)]{10,}$/.test(value), + isValidAge: (value: number) => value >= 0 && value <= 150, + isValidDate: (value: Date) => + value instanceof Date && !isNaN(value.getTime()), + }); + }); + + it('Should work with email validation', () => { + expect(enforce.isEmail().run('test@example.com')).toEqual({ + pass: true, + type: 'test@example.com', + }); + expect(enforce.isEmail().run('invalid-email')).toEqual({ + pass: false, + type: 'invalid-email', + }); + }); + + it('Should work with password validation', () => { + expect(enforce.isStrongPassword().run('StrongP@ss123')).toEqual({ + pass: true, + type: 'StrongP@ss123', + }); + expect(enforce.isStrongPassword().run('weak')).toEqual({ + pass: false, + type: 'weak', + }); + }); + + it('Should work with phone number validation', () => { + expect(enforce.isPhoneNumber().run('+1234567890')).toEqual({ + pass: true, + type: '+1234567890', + }); + expect(enforce.isPhoneNumber().run('123')).toEqual({ + pass: false, + type: '123', + }); + }); + + it('Should work with age validation', () => { + expect(enforce.isValidAge().run(25)).toEqual({ + pass: true, + type: 25, + }); + expect(enforce.isValidAge().run(-5)).toEqual({ + pass: false, + type: -5, + }); + expect(enforce.isValidAge().run(200)).toEqual({ + pass: false, + type: 200, + }); + }); + + it('Should work with date validation', () => { + const validDate = new Date('2023-01-01'); + const invalidDate = new Date('invalid'); + + expect(enforce.isValidDate().run(validDate)).toEqual({ + pass: true, + type: validDate, + }); + expect(enforce.isValidDate().run(invalidDate)).toEqual({ + pass: false, + type: invalidDate, + }); + }); + }); + + describe('Integration with schema rules in lazy API', () => { + beforeEach(() => { + enforce.extend({ + isEmail: (value: string) => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + isAdult: (value: number) => value >= 18, + hasValidPassword: (value: string) => { + return ( + value.length >= 8 && /[A-Z]/.test(value) && /[0-9]/.test(value) + ); + }, + }); + }); + + it('Should work within shape() using lazy API', () => { + const schema = enforce.shape({ + email: enforce.isString().isEmail(), + age: enforce.isNumber().isAdult(), + password: enforce.isString().hasValidPassword(), + }); + + const validData = { + email: 'test@example.com', + age: 25, + password: 'SecurePass123', + }; + + const invalidData = { + email: 'invalid-email', + age: 16, + password: 'weak', + }; + + expect(schema.run(validData)).toEqual({ + pass: true, + type: validData, + }); + const res = schema.run(invalidData); + expect(res).toMatchObject({ pass: false, type: invalidData }); + }); + + it('Should work within isArrayOf() using lazy API', () => { + const emailArrayRule = enforce.isArrayOf(enforce.isString().isEmail()); + + const validEmails = ['test@example.com', 'another@example.com']; + const invalidEmails = ['test@example.com', 'invalid-email']; + + expect(emailArrayRule.run(validEmails)).toEqual({ + pass: true, + type: validEmails, + }); + const res2 = emailArrayRule.run(invalidEmails); + expect(res2).toMatchObject({ pass: false }); + }); + + it('Should work within optional() using lazy API', () => { + const schema = enforce.shape({ + email: enforce.optional(enforce.isString().isEmail()), + age: enforce.optional(enforce.isNumber().isAdult()), + }); + + expect(schema.run({ email: 'test@example.com', age: 25 })).toEqual({ + pass: true, + type: { email: 'test@example.com', age: 25 }, + }); + expect(schema.run({ email: 'test@example.com' })).toEqual({ + pass: true, + type: { email: 'test@example.com' }, + }); + expect(schema.run({})).toEqual({ + pass: true, + type: {}, + }); + }); + }); + + describe('Performance and edge cases in lazy API', () => { + it('Should handle many custom rules with lazy API', () => { + const rules: Record boolean> = {}; + for (let i = 0; i < 50; i++) { + rules[`rule${i}`] = () => true; + } + enforce.extend(rules); + + const result = enforce.rule0().rule25().rule49().run('test'); + expect(result).toEqual({ + pass: true, + type: 'test', + }); + }); + + it('Should handle rules with many arguments in lazy API', () => { + enforce.extend({ + sumEquals: (value: number, ...args: number[]) => + value === args.reduce((sum, n) => sum + n, 0), + concatenatesTo: (value: string, ...parts: string[]) => + value === parts.join(''), + }); + + expect(enforce.sumEquals(1, 2, 3, 4).run(10)).toEqual({ + pass: true, + type: 10, + }); + expect(enforce.sumEquals(1, 2, 3).run(10)).toEqual({ + pass: false, + type: 10, + }); + + expect( + enforce.concatenatesTo('hello', 'world').run('helloworld'), + ).toEqual({ + pass: true, + type: 'helloworld', + }); + expect( + enforce.concatenatesTo('hello', 'world').run('hello world'), + ).toEqual({ + pass: false, + type: 'hello world', + }); + }); + + it('Should handle complex nested objects and arrays', () => { + enforce.extend({ + hasProperty: (value: any, prop: string) => + value && value.hasOwnProperty(prop), + arrayContains: (value: any[], item: any) => + value.some(v => + v && item && typeof v === 'object' && typeof item === 'object' + ? JSON.stringify(v) === JSON.stringify(item) + : v === item, + ), + objectDeepEquals: (value: any, expected: any) => + JSON.stringify(value) === JSON.stringify(expected), + }); + + const complexObject = { + nested: { deep: { value: 'test' } }, + array: [1, 2, 3], + mixed: [{ id: 1 }, { id: 2 }], + }; + + expect(enforce.hasProperty('nested').run(complexObject)).toEqual({ + pass: true, + type: complexObject, + }); + + expect( + enforce.arrayContains({ id: 1 }).run(complexObject.mixed), + ).toEqual({ + pass: true, + type: complexObject.mixed, + }); + + const expectedObject = { a: 1, b: 2 }; + expect( + enforce.objectDeepEquals(expectedObject).run({ a: 1, b: 2 }), + ).toEqual({ + pass: true, + type: { a: 1, b: 2 }, + }); + }); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/extend.types.test.ts b/packages/n4s/src/__tests__/extend.types.test.ts new file mode 100644 index 000000000..ccba5fcf9 --- /dev/null +++ b/packages/n4s/src/__tests__/extend.types.test.ts @@ -0,0 +1,125 @@ +/** + * Type tests for custom rule extensions via n4s namespace. + * This file uses TypeScript's type system to ensure proper type safety. + */ + +/* eslint-disable sort-keys, @typescript-eslint/no-namespace, @typescript-eslint/no-unused-vars, no-unused-vars */ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { enforce } from 'n4s'; + +// Declare custom rules in the n4s namespace +declare global { + namespace n4s { + interface ValueFirstRules { + isPositive: (value: number) => boolean; + isEmail: (value: string) => boolean | { pass: boolean; message?: string }; + isBetween: (value: number, min: number, max: number) => boolean; + hasLength: (value: string, length: number) => boolean; + } + } +} + +describe('enforce.extend with n4s namespace typing', () => { + beforeEach(() => { + enforce.extend({ + isPositive: (value: number) => value > 0, + isEmail: (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || { + pass: false, + message: 'Invalid email format', + }, + isBetween: (value: number, min: number, max: number) => + value >= min && value <= max, + hasLength: (value: string, length: number) => value.length === length, + }); + }); + + describe('Eager mode with custom rules', () => { + it('Should work with isPositive (passing)', () => { + expect(() => enforce(5).isPositive()).not.toThrow(); + }); + + it('Should work with isPositive (failing)', () => { + expect(() => enforce(-1).isPositive()).toThrow(); + }); + + it('Should work with isEmail (passing)', () => { + expect(() => enforce('user@example.com').isEmail()).not.toThrow(); + }); + + it('Should work with isEmail (failing)', () => { + expect(() => enforce('invalid-email').isEmail()).toThrow(); + }); + + it('Should work with isBetween (passing)', () => { + expect(() => enforce(5).isBetween(1, 10)).not.toThrow(); + }); + + it('Should work with isBetween (failing)', () => { + expect(() => enforce(15).isBetween(1, 10)).toThrow(); + }); + + it('Should work with hasLength (passing)', () => { + expect(() => enforce('hello').hasLength(5)).not.toThrow(); + }); + + it('Should work with hasLength (failing)', () => { + expect(() => enforce('hello').hasLength(3)).toThrow(); + }); + + it('Should work with custom message', () => { + expect(() => + enforce(-1).message('Value must be positive').isPositive(), + ).toThrow('Value must be positive'); + }); + }); + + describe('Lazy mode with custom rules', () => { + it('Should work with isPositive', () => { + const rule = enforce.isPositive(); + expect(rule.run(5)).toEqual({ pass: true, type: 5 }); + expect(rule.run(-1)).toEqual({ pass: false, type: -1 }); + }); + + it('Should work with isEmail', () => { + const rule = enforce.isEmail(); + expect(rule.run('user@example.com')).toEqual({ + pass: true, + type: 'user@example.com', + }); + expect(rule.run('invalid')).toEqual({ + pass: false, + type: 'invalid', + message: 'Invalid email format', + }); + }); + + it('Should work with isBetween', () => { + const rule = enforce.isBetween(1, 10); + expect(rule.run(5)).toEqual({ pass: true, type: 5 }); + expect(rule.run(15)).toEqual({ pass: false, type: 15 }); + }); + + it('Should work with hasLength', () => { + const rule = enforce.hasLength(5); + expect(rule.run('hello')).toEqual({ pass: true, type: 'hello' }); + expect(rule.run('hi')).toEqual({ pass: false, type: 'hi' }); + }); + }); + + describe('Chaining with built-in rules', () => { + it('Should chain custom rules with built-in rules', () => { + const rule = enforce.isNumber().isPositive().isBetween(1, 100); + expect(rule.run(50)).toEqual({ pass: true, type: 50 }); + expect(rule.run(-5)).toEqual({ pass: false, type: -5 }); + expect(rule.run(150)).toEqual({ pass: false, type: 150 }); + }); + + it('Should chain built-in rules with custom rules', () => { + const rule = enforce.isString().hasLength(5); + expect(rule.run('hello')).toEqual({ pass: true, type: 'hello' }); + expect(rule.run('hi')).toEqual({ pass: false, type: 'hi' }); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/integration.eager.test.ts b/packages/n4s/src/__tests__/integration.eager.test.ts new file mode 100644 index 000000000..973f76e35 --- /dev/null +++ b/packages/n4s/src/__tests__/integration.eager.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('Eager API - Integration Tests', () => { + describe('Basic behavior', () => { + it('throws when a rule fails', () => { + expect(() => enforce([]).isString()).toThrow(); + expect(() => enforce(1).greaterThan(1)).toThrow(); + expect(() => enforce('hi').matches(/[0-9]/)).toThrow(); + }); + + it('returns silently when rule passes', () => { + enforce(1).isNumber(); + enforce(1).greaterThan(0); + enforce('1984').matches(/[0-9]/); + }); + + it('includes a helpful failure message (rule name and value)', () => { + expect(() => enforce('a').greaterThan('b')).toThrow( + /enforce\/greaterThan failed with "a"/, + ); + expect(() => enforce(['x']).shorterThan(0)).toThrow( + /enforce\/shorterThan failed with \["x"\]/, + ); + }); + }); + + describe('Chaining', () => { + it('chains rules of the same type', () => { + // String chaining + enforce('hello') + .isString() + .longerThan(2) + .shorterThan(10) + .matches(/^h/) + .startsWith('he'); + + // Number chaining + enforce(5).isNumber().greaterThan(0).lessThan(10).isOdd(); + + // Array chaining + enforce([1, 2, 3]).isArray().lengthEquals(3).isNotEmpty(); + }); + + it('chains across different rule categories', () => { + // Mix type checks with value checks + enforce('hello').isString().isNotEmpty().longerThan(3); + enforce(42).isNumber().isPositive().isEven(); + enforce([1, 2]).isArray().longerThan(1).includes(1); + }); + + it('stops at the first failing rule in a chain', () => { + // After first failure, a throw occurs; later rules are not evaluated + expect(() => enforce('a').isString().equals('a').lessThan('a')).toThrow(); + + expect(() => + enforce(5).isNumber().greaterThan(10).lessThan(20), + ).toThrow(); // fails at greaterThan(10) + }); + + it('handles complex real-world validation chains', () => { + // Username validation + enforce('john_doe_123') + .isString() + .isNotEmpty() + .longerThan(5) + .shorterThan(20) + .matches(/^[a-zA-Z0-9_]+$/); + + // Price validation + enforce(99.99).isNumber().isPositive().greaterThan(0).lessThan(1000); + + // Email-like string validation + enforce('test@example.com') + .isString() + .isNotEmpty() + .matches(/@/) + .matches(/\./) + .longerThan(5); + }); + }); + + describe('Type coercion and comparisons', () => { + it('handles numeric coercion', () => { + enforce('10').isNumeric().greaterThan(5); + enforce(900).greaterThan('100'); + enforce('42').numberEquals(42); + }); + + it('handles strict equality', () => { + enforce(1).equals(1); + enforce('hello').equals('hello'); + + const a = [1, 2, 3]; + enforce(a).equals(a); + + expect(() => enforce('1').equals(1)).toThrow(); + expect(() => enforce([1, 2, 3]).equals([1, 2, 3])).toThrow(); + }); + }); + + describe('Truthiness and emptiness', () => { + it('validates truthy values', () => { + enforce('hi').isTruthy(); + enforce(1).isTruthy(); + enforce([]).isTruthy(); + enforce({}).isTruthy(); + + expect(() => enforce(0).isTruthy()).toThrow(); + expect(() => enforce('').isTruthy()).toThrow(); + expect(() => enforce(null).isTruthy()).toThrow(); + }); + + it('validates falsy values', () => { + enforce('').isFalsy(); + enforce(0).isFalsy(); + enforce(false).isFalsy(); + enforce(null).isFalsy(); + enforce(undefined).isFalsy(); + enforce(NaN).isFalsy(); + + expect(() => enforce(1).isFalsy()).toThrow(); + expect(() => enforce('hi').isFalsy()).toThrow(); + }); + + it('validates empty and non-empty', () => { + enforce('').isEmpty(); + enforce([]).isEmpty(); + + enforce('text').isNotEmpty(); + enforce([1]).isNotEmpty(); + + expect(() => enforce('text').isEmpty()).toThrow(); + expect(() => enforce('').isNotEmpty()).toThrow(); + }); + }); + + describe('Object membership', () => { + it('validates key membership', () => { + const obj = { a: 1, b: 2, c: 3 }; + enforce('a').isKeyOf(obj); + enforce('z').isNotKeyOf(obj); + }); + + it('validates value membership', () => { + const obj = { a: 1, b: 2, c: 3 } as const; + enforce(1).isValueOf(obj); + enforce(4).isNotValueOf(obj); + }); + }); + + describe('Container membership', () => { + it('validates string contains substring', () => { + enforce('a').inside('cat'); + enforce('at').inside('cat'); + enforce('da').inside('tru dat.'); + + expect(() => enforce('ad').inside('tru dat.')).toThrow(); + expect(() => enforce('x').inside('cat')).toThrow(); + }); + + it('validates array contains element', () => { + enforce('x').inside(['x', 'y', 'z']); + enforce(1).inside([1, 2, 3]); + enforce(['x', 'y']).inside(['x', 'y', 'z']); + + expect(() => enforce('w').inside(['x', 'y', 'z'])).toThrow(); + expect(() => enforce(4).inside([1, 2, 3])).toThrow(); + }); + + it('validates notInside', () => { + enforce('ad').notInside('tru dat.'); + enforce('w').notInside(['x', 'y', 'z']); + enforce(['x', 'w']).notInside(['x', 'y', 'z']); + + expect(() => enforce('x').notInside(['x', 'y', 'z'])).toThrow(); + expect(() => enforce('da').notInside('tru dat.')).toThrow(); + }); + }); + + describe('Array includes', () => { + it('validates array includes element', () => { + enforce([1, 2, 3]).includes(1); + enforce([1, 2, 3]).includes(2); + enforce(['a', 'b', 'c']).includes('b'); + + expect(() => enforce([1, 2, 3]).includes(4)).toThrow(); + expect(() => enforce(['a', 'b']).includes('c')).toThrow(); + expect(() => enforce([]).includes(1)).toThrow(); + }); + }); + + describe('Edge cases with falsy values', () => { + it('handles falsy values correctly', () => { + enforce(0).equals(0); + enforce(false).equals(false); + enforce('').equals(''); + enforce(null).equals(null); + enforce(undefined).equals(undefined); + + enforce(0).isFalsy(); + enforce(false).isFalsy(); + enforce('').isFalsy(); + }); + + it('distinguishes between different falsy types', () => { + enforce(null).isNull(); + enforce(undefined).isUndefined(); + enforce(null).isNullish(); + enforce(undefined).isNullish(); + + expect(() => enforce(0).isNull()).toThrow(); + expect(() => enforce('').isNullish()).toThrow(); + expect(() => enforce(false).isNullish()).toThrow(); + }); + }); + + describe('Type assertions', () => { + it('validates all basic types', () => { + enforce('text').isString(); + enforce(42).isNumber(); + enforce(true).isBoolean(); + enforce([]).isArray(); + enforce(NaN).isNaN(); + + expect(() => enforce('42').isNumber()).toThrow(); + expect(() => enforce(42).isString()).toThrow(); + expect(() => enforce('true').isBoolean()).toThrow(); + expect(() => enforce({}).isArray()).toThrow(); + }); + + it('validates negative type checks', () => { + enforce('text').isNotNumber(); + enforce(42).isNotString(); + enforce(true).isNotArray(); + enforce(42).isNotNaN(); + + expect(() => enforce(42).isNotNumber()).toThrow(); + expect(() => enforce('text').isNotString()).toThrow(); + expect(() => enforce(NaN).isNotNaN()).toThrow(); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/integration.lazy.test.ts b/packages/n4s/src/__tests__/integration.lazy.test.ts new file mode 100644 index 000000000..33870e2fc --- /dev/null +++ b/packages/n4s/src/__tests__/integration.lazy.test.ts @@ -0,0 +1,442 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('Lazy API - Integration Tests', () => { + describe('.run() method', () => { + it('returns detailed result with pass and type', () => { + const passResult = enforce.isNumber().run(5); + expect(passResult).toEqual({ pass: true, type: 5 }); + + const failResult = enforce.isNumber().run('not a number'); + expect(failResult).toEqual({ pass: false, type: 'not a number' }); + }); + + it('includes message on failure when available', () => { + const result = enforce.isNumber().message('Must be a number').run('text'); + expect(result).toMatchObject({ + pass: false, + type: 'text', + message: 'Must be a number', + }); + }); + + it('chains multiple rules', () => { + const result = enforce.isNumber().greaterThan(0).lessThan(10).run(5); + expect(result).toEqual({ pass: true, type: 5 }); + + const failResult = enforce.isNumber().greaterThan(10).lessThan(20).run(5); + expect(failResult.pass).toBe(false); + }); + + it('works with all value types', () => { + // String + expect(enforce.isString().run('hello')).toEqual({ + pass: true, + type: 'hello', + }); + + // Number + expect(enforce.isNumber().run(42)).toEqual({ + pass: true, + type: 42, + }); + + // Boolean + expect(enforce.isBoolean().run(true)).toEqual({ + pass: true, + type: true, + }); + + // Array + const arr = [1, 2, 3]; + expect(enforce.isArray().run(arr)).toEqual({ + pass: true, + type: arr, + }); + + // Object + const obj = { key: 'value' }; + expect(enforce.isKeyOf(obj).run('key')).toEqual({ + pass: true, + type: 'key', + }); + + // Null + expect(enforce.isNull().run(null)).toEqual({ + pass: true, + type: null, + }); + + // Undefined + expect(enforce.isUndefined().run(undefined)).toEqual({ + pass: true, + type: undefined, + }); + }); + }); + + describe('.test() method', () => { + it('returns boolean (true/false)', () => { + expect(enforce.isNumber().test(5)).toBe(true); + expect(enforce.isNumber().test('not a number')).toBe(false); + }); + + it('is equivalent to .run().pass', () => { + const value = 'test'; + const rule = enforce.isString().longerThan(3); + + expect(rule.test(value)).toBe(rule.run(value).pass); + }); + + it('works with chained rules', () => { + expect(enforce.isNumber().greaterThan(0).lessThan(10).test(5)).toBe(true); + expect(enforce.isNumber().greaterThan(10).lessThan(20).test(5)).toBe( + false, + ); + + expect( + enforce.isString().longerThan(2).shorterThan(10).test('hello'), + ).toBe(true); + expect(enforce.isString().longerThan(10).test('short')).toBe(false); + }); + + it('short-circuits on first failure', () => { + // Even if later rules would also fail, returns false on first failure + expect( + enforce.isNumber().greaterThan(100).lessThan(50).test('not a number'), + ).toBe(false); + }); + }); + + describe('Schema rules with lazy API', () => { + it('validates shape with .run()', () => { + const schema = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + expect(schema.run({ name: 'John', age: 30 })).toEqual({ + pass: true, + type: { name: 'John', age: 30 }, + }); + + const failResult = schema.run({ name: 'John', age: '30' }); + expect(failResult.pass).toBe(false); + }); + + it('validates shape with .test()', () => { + const schema = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + expect(schema.test({ name: 'John', age: 30 })).toBe(true); + expect(schema.test({ name: 'John', age: '30' })).toBe(false); + }); + + it('validates loose shape', () => { + const schema = enforce.loose({ + name: enforce.isString(), + }); + + expect(schema.test({ name: 'John', extra: 'field' })).toBe(true); + expect(schema.test({ name: 123 })).toBe(false); + }); + + it('validates isArrayOf', () => { + const rule = enforce.isArrayOf(enforce.isNumber()); + + expect(rule.test([1, 2, 3])).toBe(true); + expect(rule.test([1, '2', 3])).toBe(false); + expect(rule.test([])).toBe(true); // Empty array passes + }); + + it('validates optional fields', () => { + const rule = enforce.optional(enforce.isString()); + + expect(rule.test(undefined)).toBe(true); + expect(rule.test(null)).toBe(true); + expect(rule.test('hello')).toBe(true); + expect(rule.test(123)).toBe(false); + }); + }); + + describe('Compound rules with lazy API', () => { + it('validates anyOf', () => { + const rule = enforce.anyOf(enforce.isString(), enforce.isNumber()); + + expect(rule.test('hello')).toBe(true); + expect(rule.test(123)).toBe(true); + expect(rule.test(true)).toBe(false); + }); + + it('validates allOf', () => { + const rule = enforce.allOf( + enforce.isString(), + enforce.isString().longerThan(3), + ); + + expect(rule.test('hello')).toBe(true); + expect(rule.test('hi')).toBe(false); + }); + + it('validates noneOf', () => { + const rule = enforce.noneOf(enforce.isString()); + + expect(rule.test(123)).toBe(true); + expect(rule.test('hello')).toBe(false); + }); + + it('validates oneOf', () => { + const rule = enforce.oneOf(enforce.isString(), enforce.isNumber()); + + expect(rule.test(123)).toBe(true); + expect(rule.test('hello')).toBe(true); + }); + }); + + describe('Complex nested validation', () => { + it('validates nested objects', () => { + const schema = enforce.shape({ + user: enforce.shape({ + name: enforce.isString(), + email: enforce.isString().matches(/@/), + }), + }); + + expect( + schema.test({ + user: { + name: 'John', + email: 'john@example.com', + }, + }), + ).toBe(true); + + expect( + schema.test({ + user: { + name: 'John', + email: 'invalid-email', + }, + }), + ).toBe(false); + }); + + it('validates array of objects', () => { + const rule = enforce.isArrayOf( + enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString(), + }), + ); + + expect( + rule.test([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]), + ).toBe(true); + + expect( + rule.test([ + { id: 1, name: 'Alice' }, + { id: '2', name: 'Bob' }, // Invalid: id should be number + ]), + ).toBe(false); + }); + + it('validates complex compound validations', () => { + const rule = enforce.anyOf( + enforce.allOf(enforce.isString(), enforce.isString().longerThan(5)), + enforce.allOf(enforce.isNumber(), enforce.isNumber().greaterThan(100)), + ); + + expect(rule.test('hello world')).toBe(true); + expect(rule.test(150)).toBe(true); + expect(rule.test('hi')).toBe(false); + expect(rule.test(50)).toBe(false); + }); + }); + + describe('Reusability', () => { + it('allows reusing validation rules', () => { + const emailValidator = enforce.isString().matches(/@/).longerThan(5); + + expect(emailValidator.test('user@example.com')).toBe(true); + expect(emailValidator.test('user@ex.co')).toBe(true); + expect(emailValidator.test('short')).toBe(false); + + // Can reuse multiple times + expect(emailValidator.test('another@example.com')).toBe(true); + }); + + it('works with stored validators', () => { + const validators = { + positiveNumber: enforce.isNumber().greaterThan(0), + shortString: enforce.isString().shorterThan(10), + validUser: enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + }; + + expect(validators.positiveNumber.test(5)).toBe(true); + expect(validators.positiveNumber.test(-5)).toBe(false); + + expect(validators.shortString.test('hello')).toBe(true); + expect(validators.shortString.test('very long string')).toBe(false); + + expect(validators.validUser.test({ name: 'John', age: 30 })).toBe(true); + expect(validators.validUser.test({ name: 'John', age: '30' })).toBe( + false, + ); + }); + + it('builds reusable validation libraries', () => { + const validators = { + email: enforce.isString().matches(/@/).longerThan(5), + phone: enforce.isString().matches(/^\+?[\d\s-()]+$/), + url: enforce.isString().matches(/^https?:\/\//), + positiveInteger: enforce.isNumber().greaterThan(0), + }; + + expect(validators.email.test('user@example.com')).toBe(true); + expect(validators.phone.test('+1-234-567-8900')).toBe(true); + expect(validators.url.test('https://example.com')).toBe(true); + expect(validators.positiveInteger.test(5)).toBe(true); + }); + }); + + describe('Edge cases', () => { + it('handles falsy values correctly', () => { + expect(enforce.equals(0).test(0)).toBe(true); + expect(enforce.equals(false).test(false)).toBe(true); + expect(enforce.equals('').test('')).toBe(true); + expect(enforce.equals(null).test(null)).toBe(true); + expect(enforce.equals(undefined).test(undefined)).toBe(true); + }); + + it('handles special values', () => { + expect(enforce.isNaN().test(NaN)).toBe(true); + expect(enforce.isNotNaN().test(123)).toBe(true); + expect(enforce.isNull().test(null)).toBe(true); + expect(enforce.isUndefined().test(undefined)).toBe(true); + }); + + it('works with empty arrays and objects', () => { + expect(enforce.isArray().test([])).toBe(true); + expect(enforce.isEmpty().test([])).toBe(true); + expect(enforce.isEmpty().test('')).toBe(true); + }); + }); + + describe('Real-world validation scenarios', () => { + it('validates user registration data', () => { + const Username = enforce + .isString() + .longerThan(3) + .shorterThan(20) + .matches(/^[a-zA-Z0-9_]+$/); + + const Email = enforce.isString().matches(/@/).matches(/\./); + + const Password = enforce + .isString() + .longerThan(7) + .matches(/[A-Z]/) + .matches(/[a-z]/) + .matches(/[0-9]/); + + const UserRegistration = enforce.shape({ + username: Username, + email: Email, + password: Password, + }); + + expect( + UserRegistration.test({ + username: 'john_doe', + email: 'john@example.com', + password: 'SecurePass123', + }), + ).toBe(true); + + expect( + UserRegistration.test({ + username: 'ab', // Too short + email: 'john@example.com', + password: 'SecurePass123', + }), + ).toBe(false); + }); + + it('validates API response structure', () => { + const User = enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString(), + email: enforce.isString().matches(/@/), + }); + + const ApiResponse = enforce.shape({ + data: User, + status: enforce.equals(200), + timestamp: enforce.isNumber(), + }); + + expect( + ApiResponse.test({ + data: { + id: 1, + name: 'John', + email: 'john@example.com', + }, + status: 200, + timestamp: Date.now(), + }), + ).toBe(true); + + expect( + ApiResponse.test({ + data: { + id: 1, + name: 'John', + email: 'john@example.com', + }, + status: 404, // Wrong status + timestamp: Date.now(), + }), + ).toBe(false); + }); + + it('validates domain-specific data', () => { + const Money = enforce.isNumber().greaterThanOrEquals(0); + + const Product = enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString().longerThan(0), + price: Money, + quantity: enforce.isNumber().greaterThanOrEquals(0), + }); + + expect( + Product.test({ + id: 1, + name: 'Widget', + price: 19.99, + quantity: 100, + }), + ).toBe(true); + + expect( + Product.test({ + id: 1, + name: 'Widget', + price: -5, // Invalid: negative price + quantity: 100, + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/message.test.ts b/packages/n4s/src/__tests__/message.test.ts new file mode 100644 index 000000000..c82450ad5 --- /dev/null +++ b/packages/n4s/src/__tests__/message.test.ts @@ -0,0 +1,460 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('enforce().message() - Eager API', () => { + describe('Basic message override', () => { + it('Should return message as a function', () => { + expect(enforce(3).message).toBeInstanceOf(Function); + }); + + it('Should return message after chaining', () => { + expect(enforce(1).equals(1).message).toBeInstanceOf(Function); + }); + + it('Should throw a custom message string', () => { + let error; + try { + enforce(1).message('oogie boogie').equals(2); + } catch (e) { + error = e; + } + expect(error).toBe('oogie boogie'); + }); + + it('Should throw the custom message on failure', () => { + expect(() => { + enforce('').message('octopus').equals('evyatar'); + }).toThrow('octopus'); + }); + + it('Should not throw when validation passes', () => { + expect(() => { + enforce(1).message('should not see this').equals(1); + }).not.toThrow(); + }); + }); + + describe('Message with multiple rules', () => { + it('Should use the last message that failed', () => { + expect(() => { + enforce(10) + .message('must be a number!') + .isNumber() + .message('too high') + .lessThan(8); + }).toThrow('too high'); + }); + + it('Should override message for the next failing rule in chain', () => { + expect(() => { + enforce(5) + .message('First error') + .greaterThan(10) + .message('Second error') + .lessThan(3); + }).toThrow('First error'); // Fails on first rule + }); + + it('Should use latest message when multiple rules fail', () => { + expect(() => { + enforce('abc') + .message('Wrong type') + .isNumber() + .message('Out of range') + .greaterThan(100); + }).toThrow('Wrong type'); // Fails on isNumber first + }); + }); + + describe('Message with custom rules', () => { + beforeEach(() => { + enforce.extend({ + ruleWithFailureMessage: () => ({ + pass: false, + message: 'This should not be seen!', + }), + isEven: (value: number) => value % 2 === 0, + isDivisibleBy: (value: number, divisor: number) => + value % divisor === 0, + }); + }); + + it('Should override custom rule message', () => { + expect(() => { + enforce(5).message('Must be even').isEven(); + }).toThrow('Must be even'); + }); + + it('Should override message from custom rule that returns object', () => { + expect(() => { + enforce(1).message('Custom error message').ruleWithFailureMessage(); + }).toThrow('Custom error message'); + }); + + it('Should work with parameterized custom rules', () => { + expect(() => { + enforce(10).message('Not divisible by 3').isDivisibleBy(3); + }).toThrow('Not divisible by 3'); + }); + }); + + describe('Message with schema rules', () => { + it('Should override message for shape validation', () => { + expect(() => { + enforce({ + name: 'John', + age: 'thirty', // Wrong type + }) + .message('Invalid user data') + .shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + }).toThrow('Invalid user data'); + }); + + it('Should override message for loose validation', () => { + expect(() => { + enforce({ + id: 'not-a-number', + }) + .message('Invalid entity') + .loose({ + id: enforce.isNumber(), + }); + }).toThrow('Invalid entity'); + }); + + it('Should override message for isArrayOf validation', () => { + expect(() => { + enforce([1, '2', 3]) + .message('Array must contain only numbers') + .isArrayOf(enforce.isNumber()); + }).toThrow('Array must contain only numbers'); + }); + + it('Should override message for optional validation', () => { + expect(() => { + enforce({ + name: 'John', + middleName: 123, // Should be string or nullish + }) + .message('Invalid middle name') + .shape({ + name: enforce.isString(), + middleName: enforce.optional(enforce.isString()), + }); + }).toThrow('Invalid middle name'); + }); + }); + + describe('Message with compound rules', () => { + it('Should override message for anyOf', () => { + expect(() => { + enforce(true) + .message('Must be string or number') + .anyOf(enforce.isString(), enforce.isNumber()); + }).toThrow('Must be string or number'); + }); + + it('Should override message for allOf', () => { + expect(() => { + enforce('hi') + .message('Must satisfy all conditions') + .allOf(enforce.isString(), enforce.isString().longerThan(5)); + }).toThrow('Must satisfy all conditions'); + }); + + it('Should override message for oneOf', () => { + expect(() => { + enforce(5) + .message('Must match exactly one condition') + .oneOf(enforce.isNumber().greaterThan(10), enforce.isString()); + }).toThrow('Must match exactly one condition'); + }); + + it('Should override message for noneOf', () => { + expect(() => { + enforce('hello') + .message('Must not be a string') + .noneOf(enforce.isString()); + }).toThrow('Must not be a string'); + }); + }); + + describe('Message with type rules', () => { + it('Should override message for isString', () => { + expect(() => { + enforce(123).message('Value must be a string').isString(); + }).toThrow('Value must be a string'); + }); + + it('Should override message for isNumber', () => { + expect(() => { + enforce('123').message('Value must be a number').isNumber(); + }).toThrow('Value must be a number'); + }); + + it('Should override message for isBoolean', () => { + expect(() => { + enforce('true').message('Value must be a boolean').isBoolean(); + }).toThrow('Value must be a boolean'); + }); + + it('Should override message for isArray', () => { + expect(() => { + enforce({}).message('Value must be an array').isArray(); + }).toThrow('Value must be an array'); + }); + }); + + describe('Message with chained rules', () => { + it('Should override message for string chain', () => { + expect(() => { + enforce('hi') + .message('String validation failed') + .isString() + .message('Length check failed') + .longerThan(10); + }).toThrow('Length check failed'); + }); + + it('Should override message for number chain', () => { + expect(() => { + enforce(5) + .message('Number validation failed') + .isNumber() + .message('Number out of range') + .greaterThan(10) + .lessThan(20); + }).toThrow('Number out of range'); + }); + + it('Should allow multiple message overrides in chain', () => { + expect(() => { + enforce('hello') + .message('First check failed') + .isString() + .message('Length check failed') + .longerThan(10); + }).toThrow('Length check failed'); + }); + }); + + describe('Edge cases', () => { + it('Should handle empty message string', () => { + expect(() => { + enforce(1).message('').equals(2); + }).toThrow(''); + }); + + it('Should handle message with special characters', () => { + const specialMsg = 'Error: Expected , got "{value}"!'; + expect(() => { + enforce('string').message(specialMsg).isNumber(); + }).toThrow(specialMsg); + }); + + it('Should handle very long messages', () => { + const longMsg = 'A'.repeat(1000); + expect(() => { + enforce(1).message(longMsg).equals(2); + }).toThrow(longMsg); + }); + + it('Should work with null and undefined values', () => { + expect(() => { + enforce(null).message('Value cannot be null').isString(); + }).toThrow('Value cannot be null'); + + expect(() => { + enforce(undefined).message('Value cannot be undefined').isNumber(); + }).toThrow('Value cannot be undefined'); + }); + }); + + describe('Real-world usage patterns', () => { + it('Should validate user input with custom messages', () => { + // First message applies to isString (passes), second to longerThan (fails) + expect(() => { + enforce('') + .message('Username type check') + .isString() + .message('Username must be at least 3 characters') + .longerThan(2); + }).toThrow('Username must be at least 3 characters'); + }); + + it('Should validate form data with descriptive errors (requires lazy API .message())', () => { + const formData = { + email: 'notanemail', + age: -5, + }; + + expect(() => { + enforce(formData) + .message('Invalid form data') + .shape({ + email: enforce + .isString() + .message('Email must contain @') + .matches(/@/), + age: enforce + .isNumber() + .message('Age must be positive') + .greaterThan(0), + }); + }).toThrow('Invalid form data'); + }); + + it('Should validate nested objects with specific error messages (requires lazy API .message())', () => { + expect(() => { + enforce({ + user: { + profile: { + name: '', + }, + }, + }) + .message('Invalid user profile') + .shape({ + user: enforce.shape({ + profile: enforce.shape({ + name: enforce + .isString() + .message('Name cannot be empty') + .longerThan(0), + }), + }), + }); + }).toThrow('Invalid user profile'); + }); + + it('Should validate API responses with clear errors (requires lazy API .message())', () => { + const apiResponse = { + status: 404, + data: null, + }; + + expect(() => { + enforce(apiResponse) + .message('Invalid API response') + .shape({ + status: enforce + .isNumber() + .message('Status must be 200') + .equals(200), + data: enforce.isNotNullish(), + }); + }).toThrow('Invalid API response'); + }); + }); + + describe('Message precedence', () => { + it('Should use most recent message before the failing rule', () => { + expect(() => { + enforce(5) + .message('A') + .isNumber() // Passes + .message('B') + .greaterThan(10) // Fails + .message('C') + .lessThan(20); + }).toThrow('B'); + }); + + it('Should clear message after successful validation', () => { + // The message should only apply to the next validation(s) that fail + expect(() => { + enforce(15) + .message('Too small') + .greaterThan(10) // Passes, message unused + .lessThan(5); // Fails without custom message + }).toThrow(); // Should throw default message, not 'Too small' + }); + }); + + describe('Type validation with custom messages', () => { + it('Should work with isNumeric', () => { + expect(() => { + enforce('abc').message('Must be numeric string').isNumeric(); + }).toThrow('Must be numeric string'); + }); + + it('Should work with isNull', () => { + expect(() => { + enforce('not null').message('Must be null').isNull(); + }).toThrow('Must be null'); + }); + + it('Should work with isNullish', () => { + expect(() => { + enforce('not nullish').message('Must be null or undefined').isNullish(); + }).toThrow('Must be null or undefined'); + }); + }); +}); + +// Lazy API message support is now implemented! +describe('enforce.message() - Lazy API', () => { + describe('Basic lazy message override', () => { + it('Should set the failure message in builtin rules', () => { + const result = enforce + .equals(false) + .message('oof. Expected true to be false') + .run(true); + + expect(result.pass).toBe(false); + expect(result.message).toBe('oof. Expected true to be false'); + }); + + it('Should accept message as function', () => { + const result = enforce + .equals(false) + .message(() => 'oof. Expected true to be false') + .run(true); + + expect(result.pass).toBe(false); + expect(result.message).toBe('oof. Expected true to be false'); + }); + }); + + describe('Message callback', () => { + it('Should be passed the rule value as the first argument', () => { + const msg = vi.fn(() => 'some message'); + const arg = {}; + const result = enforce.equals(false).message(msg).run(arg); + + expect(result.pass).toBe(false); + expect(result.message).toBe('some message'); + expect(msg).toHaveBeenCalledWith(arg, undefined); + }); + + it('Should pass original message as second argument if exists', () => { + enforce.extend({ + ruleWithFailureMessage: () => ({ + pass: false, + message: 'This should not be seen!', + }), + }); + + const msg = vi.fn(() => 'some message'); + const arg = {}; + const result = enforce.ruleWithFailureMessage().message(msg).run(arg); + + expect(result.pass).toBe(false); + expect(result.message).toBe('some message'); + expect(msg).toHaveBeenCalledWith(arg, 'This should not be seen!'); + }); + }); + + describe('Lazy message with schema rules', () => { + it('Should override message for equals', () => { + const result = enforce.equals(5).message('Value must equal 5').run(10); + + expect(result.pass).toBe(false); + expect(result.message).toBe('Value must equal 5'); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/ruleResult.test.ts b/packages/n4s/src/__tests__/ruleResult.test.ts new file mode 100644 index 000000000..5ffd83dee --- /dev/null +++ b/packages/n4s/src/__tests__/ruleResult.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; + +import { + transformResult, + enforceMessage, + validateResult, + type RuleDetailedResult, +} from 'ruleResult'; + +describe('ruleResult helpers', () => { + describe('transformResult', () => { + it('returns pass for boolean input (true/false)', () => { + expect(transformResult(true, 'isTrue', 'x')).toEqual({ pass: true }); + expect(transformResult(false, 'isFalse', 'x')).toEqual({ pass: false }); + }); + + it('extracts pass and resolves function message with dynamic args', () => { + const res = transformResult( + { + pass: false, + message: (rule: string, value: unknown, min: number) => + `${String(value)} failed ${rule}(${min})`, + }, + 'greaterThan', + 1, + 2, + ); + expect(res.pass).toBe(false); + expect(res.message).toBe('1 failed greaterThan(2)'); + }); + + it('handles string message as-is', () => { + const res = transformResult( + { pass: false, message: 'Oops' }, + 'anyRule', + 'value', + ); + expect(res).toEqual({ pass: false, message: 'Oops' }); + }); + }); + + describe('enforceMessage', () => { + it('prefers custom message when provided', () => { + const msg = enforceMessage( + 'rule', + { pass: false } as RuleDetailedResult, + 'value', + 'Custom', + ); + expect(String(msg)).toBe('Custom'); + }); + + it('falls back to rule-provided message', () => { + const msg = enforceMessage( + 'rule', + { pass: false, message: 'FromRule' }, + 'value', + ); + expect(String(msg)).toBe('FromRule'); + }); + + it('falls back to default composed message when none provided', () => { + const msg = enforceMessage('equals', { pass: false }, 'abc'); + expect(String(msg)).toBe('enforce/equals failed with "abc"'); + }); + }); + + describe('validateResult', () => { + it('accepts boolean and object with boolean pass', () => { + expect(() => validateResult(true)).not.toThrow(); + expect(() => validateResult({ pass: false })).not.toThrow(); + }); + + it('throws on invalid result shapes', () => { + expect(() => validateResult(undefined)).toThrow(); + expect(() => validateResult(null)).toThrow(); + expect(() => validateResult({})).toThrow(); + expect(() => validateResult({ pass: 'nope' })).toThrow(); + }); + }); +}); diff --git a/packages/n4s/src/__tests__/schemaExports.types.test.ts b/packages/n4s/src/__tests__/schemaExports.types.test.ts new file mode 100644 index 000000000..977b3c9e3 --- /dev/null +++ b/packages/n4s/src/__tests__/schemaExports.types.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, expectTypeOf } from 'vitest'; + +import { + enforce, + type RuleInstance, + type SchemaInfer, + type ShapeType, + type LooseShapeValue, + type PartialShapeValue, +} from 'n4s'; + +describe('schema type exports', () => { + it('exposes RuleInstance type for schemas', () => { + const rule = enforce.isString(); + + expectTypeOf(rule).toMatchTypeOf>(); + expect(rule.test('value')).toBe(true); + }); + + it('infers data types from schema rule instances', () => { + const schema = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + expectTypeOf(schema.infer).toEqualTypeOf<{ + name: string; + age: number; + }>(); + }); + + it('infers data types from loose and partial schemas', () => { + const looseSchema = enforce.loose({ + title: enforce.isString(), + }); + + const partialSchema = enforce.partial({ + id: enforce.isNumber(), + label: enforce.isString(), + }); + + expectTypeOf(looseSchema.infer).toEqualTypeOf<{ + title: string; + } & Record>(); + + expectTypeOf(partialSchema.infer).toEqualTypeOf<{ + id?: number | undefined; + label?: string | undefined; + }>(); + }); + + it('allows importing schema helper types', () => { + type Schema = { + username: RuleInstance; + score: RuleInstance; + }; + + type InferredViaSchemaInfer = SchemaInfer; + type InferredViaShapeType = ShapeType; + type LooseValue = LooseShapeValue; + type PartialValue = PartialShapeValue; + + expectTypeOf().toEqualTypeOf<{ + username: string; + score: number; + }>(); + + expectTypeOf().toEqualTypeOf<{ + username: string; + score: number; + }>(); + + expectTypeOf().toEqualTypeOf<{ + username: string; + score: number; + } & Record>(); + + expectTypeOf().toEqualTypeOf<{ + username?: string | undefined; + score?: number | undefined; + }>(); + }); +}); diff --git a/packages/n4s/src/__tests__/types.test.ts b/packages/n4s/src/__tests__/types.test.ts new file mode 100644 index 000000000..fd9820a88 --- /dev/null +++ b/packages/n4s/src/__tests__/types.test.ts @@ -0,0 +1,278 @@ +/** + * Comprehensive TypeScript type tests for n4s + * These tests verify type inference, type guards, and compile-time type safety + */ + +/* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars, @typescript-eslint/ban-ts-comment */ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +// Wrap in a function so runtime won't execute; TypeScript still checks it. +function typeChecks() { + // ===== BASIC TYPE INFERENCE ===== + + // Type guards provide proper narrowing + const test1 = enforce(1).isNumber().greaterThan(0); + const test2 = enforce('hello').isString().startsWith('h'); + const test3 = enforce([1, 2, 3]).isArray().includes(1); + const test4 = enforce(true).isBoolean(); + + // These should cause type errors + // Type test: - greaterThan should not be available on boolean + const test5 = enforce(true).greaterThan(5); + + // Type test: - startsWith should not be available on number + const test6 = enforce(123).startsWith('1'); + + // Type test: - array includes() takes single value, not available on string + const test7 = enforce('hello').includes('h'); + + // ===== TYPE GUARDS ===== + + // isString type guard - valid chains + const str1 = enforce('hello').isString().startsWith('h'); + const str2 = enforce('hello').isString().endsWith('o'); + const str3 = enforce('hello').isString().matches(/^h/); + const str4 = enforce('hello').isString().longerThan(3); + const str5 = enforce('hello').isString().minLength(1); + + // isNumber type guard - valid chains + const num1 = enforce(42).isNumber().greaterThan(0); + const num2 = enforce(42).isNumber().lessThan(100); + const num3 = enforce(42).isNumber().isEven(); + const num4 = enforce(42).isNumber().isPositive(); + const num5 = enforce(42).isNumber().isBetween(0, 100); + const num6 = enforce(42).isNumber().isNotNaN(); + + // isBoolean type guard - valid chains + const bool1 = enforce(true).isBoolean().isTrue(); + const bool2 = enforce(false).isBoolean().isFalse(); + const bool3 = enforce(true).isBoolean().isTruthy(); + const bool4 = enforce(false).isBoolean().isFalsy(); + const bool5 = enforce(true).isBoolean().equals(true); + + // isArray type guard - valid chains + const arr1 = enforce([1, 2, 3]).isArray().includes(1); + const arr2 = enforce([1, 2, 3]).isArray().minLength(1); + const arr3 = enforce([1, 2, 3]).isArray().maxLength(10); + const arr4 = enforce([1, 2, 3]).isArray().lengthEquals(3); + const arr5 = enforce([1, 2, 3]).isArray().isEmpty(); + const arr6 = enforce([1, 2, 3]).isArray().longerThan(2); + + // isNumeric type guard - valid chains (works with numeric strings) + const numeric1 = enforce('42').isNumeric().greaterThan(0); + const numeric2 = enforce(42).isNumeric().lessThan(100); + const numeric3 = enforce('42').isNumeric().isBetween(0, 100); + const numeric4 = enforce('42').isNumeric().isPositive(); + const numeric5 = enforce('42').isNumeric().isEven(); + + // isNull type guard + const null1 = enforce(null).isNull(); + + // isUndefined type guard + const undef1 = enforce(undefined).isUndefined(); + + // isNullish type guard + const nullish1 = enforce(null).isNullish(); + const nullish2 = enforce(undefined).isNullish(); + + // Type guards work with unknown + const unknownValue: unknown = 42; + const strUnknown = enforce(unknownValue).isString().startsWith('x'); + const numUnknown = enforce(unknownValue).isNumber().isPositive(); + const boolUnknown = enforce(unknownValue).isBoolean().isTrue(); + const arrUnknown = enforce(unknownValue).isArray().includes(1); + const numericUnknown = enforce(unknownValue).isNumeric().isPositive(); + const nullUnknown = enforce(unknownValue).isNull(); + const undefUnknown = enforce(unknownValue).isUndefined(); + const nullishUnknown = enforce(unknownValue).isNullish(); + + // ===== SCHEMA RULES TYPE INFERENCE ===== + + // shape rule - infers exact type + const shape1 = enforce({ name: 'John', age: 30 }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + // optional rule - infers union with undefined and null + const opt1 = enforce('hello').optional(enforce.isString()); + const opt2 = enforce(undefined).optional(enforce.isString()); + const opt3 = enforce(42).optional(enforce.isNumber().greaterThan(0)); + + // isArrayOf rule - infers array type + const arrOf1 = enforce([1, 2, 3]).isArrayOf(enforce.isNumber()); + const arrOf2 = enforce(['a', 'b']).isArrayOf(enforce.isString()); + const arrOf3 = enforce([true, false]).isArrayOf(enforce.isBoolean()); + + // loose rule - allows extra properties + const loose1 = enforce({ name: 'John', age: 30, extra: 'data' }).loose({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + // partial rule - all properties optional + const partial1 = enforce({ name: 'John' }).partial({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + // ===== COMPOUND RULES TYPE INFERENCE ===== + + // allOf rule - must satisfy all rules + const allOf1 = enforce(42).allOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(0), + enforce.isNumber().lessThan(100), + ); + + // anyOf rule - must satisfy at least one rule (union type) + const anyOf1 = enforce('42').anyOf(enforce.isString(), enforce.isNumber()); + + // noneOf rule - must satisfy none of the rules + const noneOf1 = enforce(42).noneOf(enforce.isString(), enforce.isBoolean()); + + // oneOf rule - must satisfy exactly one rule + const oneOf1 = enforce('hello').oneOf(enforce.isString(), enforce.isNumber()); + + // ===== COMPLEX CHAINING SCENARIOS ===== + + // Multiple type guards in sequence + const complex1 = enforce(42).isNumber().greaterThan(0).isNumber().isEven(); + + // Type guard after schema rule + const complex2 = enforce([1, 2, 3]) + .isArrayOf(enforce.isNumber()) + .minLength(1); + + // Chaining multiple validations on unknown + const complex3 = enforce(unknownValue) + .isNumber() + .greaterThan(0) + .lessThan(100) + .isEven(); + + // ===== TYPE INFERENCE WITH .infer PROPERTY ===== + + // Primitive rules + const stringRule = enforce.isString(); + type StringType = typeof stringRule.infer; // Should be string + + const numberRule = enforce.isNumber(); + type NumberType = typeof numberRule.infer; // Should be number + + const booleanRule = enforce.isBoolean(); + type BooleanType = typeof booleanRule.infer; // Should be boolean + + const arrayRule = enforce.isArray(); + type ArrayType = typeof arrayRule.infer; // Should be unknown[] + + // Schema rules + const userRule = enforce.shape({ + id: enforce.isNumber(), + name: enforce.isString(), + email: enforce.optional(enforce.isString()), + }); + type UserType = typeof userRule.infer; + + // Compound rules + const stringOrNumberRule = enforce.anyOf( + enforce.isString(), + enforce.isNumber(), + ); + type StringOrNumber = typeof stringOrNumberRule.infer; // Should be string | number + + // Array of objects + const usersRule = enforce.isArrayOf(userRule); + type UsersType = typeof usersRule.infer; + + // ===== CUSTOM RULES TYPE SAFETY ===== + + // Custom rules with extend should be type-safe + // (This would require the n4s namespace declaration) + + // Mark all as used to avoid warnings + void [ + test1, + test2, + test3, + test4, + test5, + test6, + test7, + str1, + str2, + str3, + str4, + str5, + num1, + num2, + num3, + num4, + num5, + num6, + bool1, + bool2, + bool3, + bool4, + bool5, + arr1, + arr2, + arr3, + arr4, + arr5, + arr6, + numeric1, + numeric2, + numeric3, + numeric4, + numeric5, + null1, + undef1, + nullish1, + nullish2, + strUnknown, + numUnknown, + boolUnknown, + arrUnknown, + numericUnknown, + nullUnknown, + undefUnknown, + nullishUnknown, + shape1, + opt1, + opt2, + opt3, + arrOf1, + arrOf2, + arrOf3, + loose1, + partial1, + allOf1, + anyOf1, + noneOf1, + oneOf1, + complex1, + complex2, + complex3, + ]; +} + +// Runtime test to verify the file is recognized by vitest +describe('TypeScript Type Tests', () => { + it('compiles without errors', () => { + expect(true).toBe(true); + }); + + it('ensures type guards work at runtime', () => { + // Verify that type guards actually work + expect(enforce.isNumber().test(42)).toBe(true); + expect(enforce.isString().test('hello')).toBe(true); + expect(enforce.isBoolean().test(true)).toBe(true); + expect(enforce.isArray().test([])).toBe(true); + }); +}); + +// Mark unused function as referenced for TS noUnusedLocals +void typeChecks; diff --git a/packages/n4s/src/compose.ts b/packages/n4s/src/compose.ts new file mode 100644 index 000000000..e903ec313 --- /dev/null +++ b/packages/n4s/src/compose.ts @@ -0,0 +1,84 @@ +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; +import { ctx } from 'enforceContext'; +import { StringObject, assign, invariant, mapFirst } from 'vest-utils'; + +type ComposeResult = RuleInstance & { + (value: T): void; +}; + +/** + * Composes multiple validation rules into a single reusable rule. + * The composed rule executes rules in order and fails on the first failing rule. + * Returns a RuleInstance that can be used with both eager and lazy APIs. + * + * @template T - The type of value to validate + * @param composites - Validation rules to compose + * @returns A composed rule that can be run with values or called directly + * + * @example + * ```typescript + * // Create a reusable adult age validation + * const isAdult = compose( + * enforce.isNumber(), + * enforce.greaterThanOrEquals(18), + * enforce.lessThan(150) + * ); + * + * // Use with lazy API + * isAdult.test(25); // true + * isAdult.test(16); // false + * + * // Use with eager API + * enforce(30).run(isAdult); // passes + * + * // Call directly (throws on failure) + * isAdult(25); // ok + * isAdult(16); // throws + * + * // Compose with other rules + * const userSchema = enforce.shape({ + * age: isAdult, + * name: enforce.isString() + * }); + * ``` + */ +export function compose( + ...composites: RuleInstance[] +): ComposeResult { + const composedFn = assign( + (value: T) => { + const res = run(value); + invariant(res.pass, StringObject(res.message)); + }, + { + run, + test: (value: T) => run(value).pass, + infer: {} as T, + }, + ); + + return composedFn as ComposeResult; + + function run(value: T): RuleRunReturn { + return ctx.run({ value }, () => { + let result: RuleRunReturn = RuleRunReturn.Passing(value); + + mapFirst( + composites, + ( + composite: RuleInstance, + breakout: (conditional: boolean, res: RuleRunReturn) => void, + ) => { + const res = composite.run(value); + if (!res.pass) { + result = res; + breakout(true, res); + } + }, + ); + + return result; + }); + } +} diff --git a/packages/n4s/src/eager.ts b/packages/n4s/src/eager.ts new file mode 100644 index 000000000..c5cd9a7c6 --- /dev/null +++ b/packages/n4s/src/eager.ts @@ -0,0 +1,96 @@ +import { allRules, schemaRulesMap } from 'allRules'; +import type { EnforceEagerReturn } from 'eagerTypes'; +import { createRuleCall } from 'ruleCallGenerator'; +import { extendEager, getRule, getSchemaRule } from 'ruleRegistry'; +import type { Maybe } from 'vest-utils'; + +export { extendEager }; +export type { EnforceEagerReturn, TArraySchemaRules } from 'eagerTypes'; + +const MESSAGE_KEY = 'message'; + +type EagerReturn = EnforceEagerReturn< + T, + typeof allRules, + typeof schemaRulesMap +>; + +/** + * Eager (imperative) validation API - validates a value immediately with chainable assertions. + * Each chained rule executes synchronously and the chain breaks on the first failure. + * + * @template T - The type of value being validated + * @param value - The value to validate + * @returns A proxy object with chainable validation methods and a `pass` property + * + * @example + * ```typescript + * // Simple validation + * enforce('hello').isString(); // passes + * + * // Chained validation + * enforce(25) + * .isNumber() + * .greaterThan(18) + * .lessThan(100); + * + * // Custom error messages + * enforce('') + * .message('Field is required') + * .isNotEmpty(); + * + * // Type narrowing + * enforce(value) + * .isString() + * .longerThan(5); + * // value is now known to be a string + * + * // Schema validation + * enforce({ name: 'John', age: 30 }) + * .shape({ + * name: enforce.isString(), + * age: enforce.isNumber() + * }); + * + * // Check pass status without throwing + * const result = enforce(value).isString(); + * if (result.pass) { + * // validation passed + * } + * ``` + */ +export function enforceEager(value: T): EagerReturn { + let customMessage: Maybe = undefined; + + const setMessage = (msg?: string) => { + customMessage = msg; + return proxy; + }; + + const clearMessage = () => setMessage(undefined); + + const proxy: EagerReturn = new Proxy( + {}, + { + get(_target: any, key: string) { + if (key === MESSAGE_KEY) return setMessage; + + const rule = getRule(key) ?? getSchemaRule(key); + if (rule) { + return createRuleCall({ + clearMessage, + customMessage, + rule, + ruleName: key, + target: proxy, + value, + }); + } + + return _target[key]; + }, + }, + ); + + return proxy; +} diff --git a/packages/n4s/src/eager/allRules.ts b/packages/n4s/src/eager/allRules.ts new file mode 100644 index 000000000..200abbf98 --- /dev/null +++ b/packages/n4s/src/eager/allRules.ts @@ -0,0 +1,35 @@ +import * as arrayRules from 'arrayRules'; +import * as booleanRules from 'booleanRules'; +import * as commonComparison from 'commonComparison'; +import * as commonContainer from 'commonContainer'; +import * as commonLength from 'commonLength'; +import * as compoundRules from 'compoundRules'; +import * as generalRules from 'generalRules'; +import { isNumeric } from 'isNumeric'; +import * as nullishRules from 'nullishRules'; +import * as numberRules from 'numberRules'; +import * as numericRules from 'numberRules'; +import * as objectRules from 'objectRules'; +import * as schemaRules from 'schemaRules'; +import * as stringRules from 'stringRules'; + +export const allRules = { + ...arrayRules, + ...booleanRules, + ...commonComparison, + ...commonContainer, + ...commonLength, + ...generalRules, + ...nullishRules, + ...numberRules, + // not ideal but it helps us that all the numeric rules are exported directly from number rules + isNumeric, + ...numericRules, + ...objectRules, + ...stringRules, +} as const; + +export const schemaRulesMap = { + ...compoundRules, + ...schemaRules, +} as const; diff --git a/packages/n4s/src/eager/eagerTypes.ts b/packages/n4s/src/eager/eagerTypes.ts new file mode 100644 index 000000000..e2587daa1 --- /dev/null +++ b/packages/n4s/src/eager/eagerTypes.ts @@ -0,0 +1,53 @@ +import type { RuleInstance } from 'RuleInstance'; +import { TCustomRules } from 'n4sTypes'; +import { MultiTypeInput } from 'schemaRulesTypes'; +import type { + AnyFn, + FirstParam, + TailParams, + InferNextValue, + DropFirstFn, + UnwrapRuleInstance, +} from 'typeUtils'; + +type Msg = { message: (input: string) => EnforceEagerReturn }; + +export type TRules = { + [K in keyof A as A[K] extends (...args: any) => any + ? T extends FirstParam> + ? K + : never + : never]: ( + ...args: TailParams> + ) => EnforceEagerReturn>, A, S>; +}; + +export type TSchemaRules = T extends any[] + ? Record + : T extends Record + ? { + [K in keyof S]: DropFirstFn extends ( + ...args: infer Args + ) => infer R + ? (...args: Args) => EnforceEagerReturn, A, S> + : never; + } + : Record; + +export type TArraySchemaRules = T extends any[] + ? { + isArrayOf: []>( + ...rules: Rules + ) => EnforceEagerReturn[], A, S>; + } + : Record; + +type Base = Msg & + TRules & + TCustomRules & + TSchemaRules & + TArraySchemaRules; + +export type EnforceEagerReturn = Base & { + pass: boolean; +}; diff --git a/packages/n4s/src/eager/ruleCallGenerator.ts b/packages/n4s/src/eager/ruleCallGenerator.ts new file mode 100644 index 000000000..d5bd7e3a4 --- /dev/null +++ b/packages/n4s/src/eager/ruleCallGenerator.ts @@ -0,0 +1,40 @@ +import { ctx } from 'enforceContext'; +import { invariant } from 'vest-utils'; + +import type { UnmodifiedRules, SchemaRules } from 'ruleRegistry'; +import { enforceMessage, transformResult } from 'ruleResult'; + +type RuleCallConfig = { + target: any; + rule: UnmodifiedRules | SchemaRules; + ruleName: string; + value: any; + customMessage: string | undefined; + clearMessage: () => void; +}; + +export function createRuleCall(config: RuleCallConfig) { + const { target, rule, ruleName, value, customMessage, clearMessage } = config; + + return function ruleCall(...args: any[]): any { + const transformedResult = ctx.run({ value }, () => + transformResult( + (rule as (...args: any[]) => any)(value, ...args), + ruleName, + value, + ...args, + ), + ); + + invariant( + transformedResult.pass, + enforceMessage(ruleName, transformedResult, value, customMessage), + ); + + // Clear message after each rule - it only applies to the next rule + clearMessage(); + target.pass = transformedResult.pass; + + return target; + }; +} diff --git a/packages/n4s/src/eager/ruleRegistry.ts b/packages/n4s/src/eager/ruleRegistry.ts new file mode 100644 index 000000000..7b2938d73 --- /dev/null +++ b/packages/n4s/src/eager/ruleRegistry.ts @@ -0,0 +1,25 @@ +import { assign } from 'vest-utils'; + +import { allRules, schemaRulesMap } from 'allRules'; + +const customRules: Record any> = {}; + +export type UnmodifiedRuleKeys = keyof typeof allRules; +export type UnmodifiedRules = (typeof allRules)[UnmodifiedRuleKeys]; +export type SchemaRuleKeys = keyof typeof schemaRulesMap; +export type SchemaRules = (typeof schemaRulesMap)[SchemaRuleKeys]; + +export function extendEager(rules: Record any>) { + assign(customRules, rules); +} + +export function getSchemaRule(ruleName: string): SchemaRules | null { + return schemaRulesMap[ruleName as SchemaRuleKeys] ?? null; +} + +export function getRule(ruleName: string): UnmodifiedRules | null { + return ( + (customRules[ruleName] as UnmodifiedRules | undefined) ?? + allRules[ruleName as UnmodifiedRuleKeys] + ); +} diff --git a/packages/n4s/src/eager/typeUtils.ts b/packages/n4s/src/eager/typeUtils.ts new file mode 100644 index 000000000..38396050b --- /dev/null +++ b/packages/n4s/src/eager/typeUtils.ts @@ -0,0 +1,36 @@ +import type { RuleInstance } from 'RuleInstance'; + +export type AnyFn = (...args: any[]) => any; + +export type FirstParam = F extends ( + arg: infer A, + ...rest: any +) => any + ? A + : never; + +export type TailParams = F extends ( + arg: any, + ...rest: infer R +) => any + ? R + : never; + +export type InferNextValue = F extends ( + arg: any, + ...rest: any +) => arg is infer Narrowed + ? Narrowed + : ReturnType extends RuleInstance + ? Inner + : ReturnType extends boolean | void + ? T + : ReturnType extends T + ? T + : ReturnType; + +export type DropFirstFn = F extends (arg: any, ...rest: infer R) => infer Ret + ? (...args: R) => Ret + : never; + +export type UnwrapRuleInstance = R extends RuleInstance ? V : R; diff --git a/packages/n4s/src/runtime/enforceContext.ts b/packages/n4s/src/enforceContext.ts similarity index 52% rename from packages/n4s/src/runtime/enforceContext.ts rename to packages/n4s/src/enforceContext.ts index a26e217c8..82daeafb9 100644 --- a/packages/n4s/src/runtime/enforceContext.ts +++ b/packages/n4s/src/enforceContext.ts @@ -1,6 +1,32 @@ import { createCascade } from 'context'; import { assign, Nullable } from 'vest-utils'; +/** + * Context API for accessing validation state during rule execution. + * Provides access to the current value being validated, metadata, and parent context. + * Used internally by rules to track nested validation (e.g., in shape, isArrayOf). + * + * @example + * ```typescript + * // Access context in custom rules + * enforce.extend({ + * customRule: (value: any) => { + * const context = enforce.context(); + * console.log('Current value:', context?.value); + * console.log('Metadata:', context?.meta); + * return true; + * } + * }); + * + * // Context is automatically set in nested validations + * enforce({ user: { name: 'John' } }).shape({ + * user: enforce.shape({ + * name: enforce.isString() + * }) + * }); + * // When validating 'name', context.parent() gives access to 'user' object + * ``` + */ export const ctx = createCascade((ctxRef, parentContext): CTXType => { const base = { value: ctxRef.value, diff --git a/packages/n4s/src/exports/__tests__/compose.test.ts b/packages/n4s/src/exports/__tests__/compose.test.ts deleted file mode 100644 index 8d4cc25dd..000000000 --- a/packages/n4s/src/exports/__tests__/compose.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import compose from 'compose'; -import { enforce } from 'n4s'; -import * as ruleReturn from 'ruleReturn'; -import 'schema'; -import 'compounds'; - -describe('compose', () => { - it('Should create "and" relationship between composed rules', () => { - const NumberAboveTen = compose(enforce.isNumber(), enforce.greaterThan(10)); - - expect(() => NumberAboveTen(5)).toThrow(); - expect(() => NumberAboveTen('11')).toThrow(); - expect(() => NumberAboveTen(10)).toThrow(); - NumberAboveTen(11); // does not throw - }); - - it('Should allow lazy evaluation of composed rules', () => { - const NumericStringBetweenThreeAndFive = compose( - enforce.isNumeric(), - enforce.isString(), - enforce.greaterThan(3), - enforce.lessThan(5), - ); - - expect(NumericStringBetweenThreeAndFive.run('4')).toEqual( - ruleReturn.passing(), - ); - expect(NumericStringBetweenThreeAndFive.run('3')).toEqual( - ruleReturn.failing(), - ); - expect(NumericStringBetweenThreeAndFive.run(5)).toEqual( - ruleReturn.failing(), - ); - expect(NumericStringBetweenThreeAndFive.test('4')).toBe(true); - expect(NumericStringBetweenThreeAndFive.test('3')).toBe(false); - expect(NumericStringBetweenThreeAndFive.test(5)).toBe(false); - }); - - it('Should allow running composite as part of a shape', () => { - const Name = compose( - enforce.shape({ - first: enforce.isString().isNotEmpty(), - last: enforce.isString().isNotEmpty(), - middle: enforce.optional(enforce.isString().isNotEmpty()), - }), - ); - - expect( - enforce - .shape({ - name: Name, - }) - .run({ - name: { - first: 'John', - last: 'Doe', - }, - }), - ).toEqual(ruleReturn.passing()); - - expect( - enforce - .shape({ - name: Name, - }) - .run({ - name: { - first: 'John', - last: 'Doe', - middle: '', - }, - }), - ).toEqual(ruleReturn.failing()); - }); - it('Should allow composing compositions', () => { - const Name = compose( - enforce.loose({ - name: enforce.shape({ - first: enforce.isString().isNotEmpty(), - last: enforce.isString().isNotEmpty(), - middle: enforce.optional(enforce.isString().isNotEmpty()), - }), - }), - ); - - const Entity = compose( - enforce.loose({ - id: enforce.isNumeric(), - }), - ); - - const User = compose(Name, Entity); - - expect( - User.run({ - id: '1', - name: { - first: 'John', - middle: 'M', - last: 'Doe', - }, - }), - ).toEqual(ruleReturn.passing()); - User({ - id: '1', - name: { - first: 'John', - middle: 'M', - last: 'Doe', - }, - }); - - // failing - expect( - User.run({ - id: '_', - name: { - first: 'John', - }, - }), - ).toEqual(ruleReturn.failing()); - - expect(() => - User({ - name: { - first: 'John', - }, - id: '__', - }), - ).toThrow(); - }); -}); diff --git a/packages/n4s/src/exports/__tests__/date.test.ts b/packages/n4s/src/exports/__tests__/date.test.ts index fe0f637d3..dfb0fa497 100644 --- a/packages/n4s/src/exports/__tests__/date.test.ts +++ b/packages/n4s/src/exports/__tests__/date.test.ts @@ -4,6 +4,70 @@ import { enforce } from 'n4s'; import 'date'; describe('date', () => { + describe('Type compatibility', () => { + it('Should work in eager mode (value-first)', () => { + // Type test: these should compile without errors + enforce('2002-07-15').isDate(); + enforce('2002-07-15').isDate({ format: 'YYYY-MM-DD' }); + enforce('2100-07-15').isAfter(); + enforce('2002-07-15').isAfter('2002-07-14'); + enforce('1900-07-15').isBefore(); + enforce('2002-07-15').isBefore('2002-07-16'); + enforce('2020-07-10T15:00:00Z').isISO8601(); + enforce('2020-07-10').isISO8601({ strict: true }); + }); + + it('Should work in lazy mode (builder pattern)', () => { + // Type test: these should compile without errors + const dateRule = enforce.isDate(); + expect(dateRule.test('2002-07-15')).toBe(true); + expect(dateRule.run('2002-07-15').pass).toBe(true); + expect(dateRule.test('not-a-date')).toBe(false); + expect(dateRule.run('not-a-date').pass).toBe(false); + + const dateWithOptionsRule = enforce.isDate({ format: 'YYYY-MM-DD' }); + expect(dateWithOptionsRule.test('2002-07-15')).toBe(true); + expect(dateWithOptionsRule.run('2002-07-15').pass).toBe(true); + expect(dateWithOptionsRule.test('07/15/2002')).toBe(false); + expect(dateWithOptionsRule.run('07/15/2002').pass).toBe(false); + + const isAfterRule = enforce.isAfter(); + expect(isAfterRule.test('2100-07-15')).toBe(true); + expect(isAfterRule.run('2100-07-15').pass).toBe(true); + expect(isAfterRule.test('1900-07-15')).toBe(false); + expect(isAfterRule.run('1900-07-15').pass).toBe(false); + + const isAfterWithDateRule = enforce.isAfter('2002-07-14'); + expect(isAfterWithDateRule.test('2002-07-15')).toBe(true); + expect(isAfterWithDateRule.run('2002-07-15').pass).toBe(true); + expect(isAfterWithDateRule.test('2002-07-13')).toBe(false); + expect(isAfterWithDateRule.run('2002-07-13').pass).toBe(false); + + const isBeforeRule = enforce.isBefore(); + expect(isBeforeRule.test('1900-07-15')).toBe(true); + expect(isBeforeRule.run('1900-07-15').pass).toBe(true); + expect(isBeforeRule.test('2100-07-15')).toBe(false); + expect(isBeforeRule.run('2100-07-15').pass).toBe(false); + + const isBeforeWithDateRule = enforce.isBefore('2002-07-16'); + expect(isBeforeWithDateRule.test('2002-07-15')).toBe(true); + expect(isBeforeWithDateRule.run('2002-07-15').pass).toBe(true); + expect(isBeforeWithDateRule.test('2002-07-17')).toBe(false); + expect(isBeforeWithDateRule.run('2002-07-17').pass).toBe(false); + + const isISO8601Rule = enforce.isISO8601(); + expect(isISO8601Rule.test('2020-07-10T15:00:00Z')).toBe(true); + expect(isISO8601Rule.run('2020-07-10T15:00:00Z').pass).toBe(true); + expect(isISO8601Rule.test('not-iso')).toBe(false); + expect(isISO8601Rule.run('not-iso').pass).toBe(false); + + const isISO8601StrictRule = enforce.isISO8601({ strict: true }); + expect(isISO8601StrictRule.test('2020-07-10')).toBe(true); + expect(isISO8601StrictRule.run('2020-07-10').pass).toBe(true); + expect(isISO8601StrictRule.test('invalid')).toBe(false); + expect(isISO8601StrictRule.run('invalid').pass).toBe(false); + }); + }); describe('isDate', () => { /** enforce(value).isDate() * check if the string is a valid date. e.g. [2002-07-15, new Date()]. diff --git a/packages/n4s/src/exports/__tests__/email.test.ts b/packages/n4s/src/exports/__tests__/email.test.ts index 15426381e..99111b921 100644 --- a/packages/n4s/src/exports/__tests__/email.test.ts +++ b/packages/n4s/src/exports/__tests__/email.test.ts @@ -4,6 +4,44 @@ import { enforce } from 'n4s'; import 'email'; describe('isEmail', () => { + describe('Type compatibility', () => { + it('Should work in eager mode (value-first)', () => { + // Type test: these should compile without errors + enforce('abc@xyz.com').isEmail(); + enforce('user.name@mail.example.com').isEmail(); + enforce('Display Name ').isEmail({ + allow_display_name: true, + }); + enforce('user@192.168.0.1').isEmail({ allow_ip_domain: true }); + }); + + it('Should work in lazy mode (builder pattern)', () => { + // Type test: these should compile without errors + const emailRule = enforce.isEmail(); + expect(emailRule.test('abc@xyz.com')).toBe(true); + expect(emailRule.run('abc@xyz.com').pass).toBe(true); + expect(emailRule.test('abc@xyz')).toBe(false); + expect(emailRule.run('abc@xyz').pass).toBe(false); + + const emailWithOptionsRule = enforce.isEmail({ + allow_display_name: true, + }); + expect(emailWithOptionsRule.test('Display Name ')).toBe( + true, + ); + expect( + emailWithOptionsRule.run('Display Name ').pass, + ).toBe(true); + expect(emailWithOptionsRule.test('user@example.com')).toBe(true); + expect(emailWithOptionsRule.run('user@example.com').pass).toBe(true); + + const emailWithIPRule = enforce.isEmail({ allow_ip_domain: true }); + expect(emailWithIPRule.test('user@192.168.0.1')).toBe(true); + expect(emailWithIPRule.run('user@192.168.0.1').pass).toBe(true); + expect(emailWithIPRule.test('user@example.com')).toBe(true); + expect(emailWithIPRule.run('user@example.com').pass).toBe(true); + }); + }); it('Should pass for valid emails', () => { expect(() => enforce('abc@xyz.com').isEmail()).not.toThrow(); expect(() => enforce('user.name@mail.example.com').isEmail()).not.toThrow(); diff --git a/packages/n4s/src/exports/__tests__/isUrl.test.ts b/packages/n4s/src/exports/__tests__/isUrl.test.ts index e48ef328e..9a07e5958 100644 --- a/packages/n4s/src/exports/__tests__/isUrl.test.ts +++ b/packages/n4s/src/exports/__tests__/isUrl.test.ts @@ -4,6 +4,45 @@ import { enforce } from 'n4s'; import 'isURL'; describe('isURL', () => { + describe('Type compatibility', () => { + it('Should work in eager mode (value-first)', () => { + // Type test: these should compile without errors + enforce('http://www.google.com').isURL(); + enforce('https://google.com').isURL(); + enforce('google.com').isURL(); + enforce('myprotocol://customdomain.com').isURL({ + protocols: ['myprotocol'], + }); + enforce('http://localhost:8080').isURL({ require_tld: false }); + }); + + it('Should work in lazy mode (builder pattern)', () => { + // Type test: these should compile without errors + const urlRule = enforce.isURL(); + expect(urlRule.test('http://www.google.com')).toBe(true); + expect(urlRule.run('http://www.google.com').pass).toBe(true); + expect(urlRule.test('google')).toBe(false); + expect(urlRule.run('google').pass).toBe(false); + + const urlWithProtocolRule = enforce.isURL({ + protocols: ['myprotocol'], + }); + expect(urlWithProtocolRule.test('myprotocol://customdomain.com')).toBe( + true, + ); + expect( + urlWithProtocolRule.run('myprotocol://customdomain.com').pass, + ).toBe(true); + expect(urlWithProtocolRule.test('http://www.google.com')).toBe(false); + expect(urlWithProtocolRule.run('http://www.google.com').pass).toBe(false); + + const urlWithoutTLDRule = enforce.isURL({ require_tld: false }); + expect(urlWithoutTLDRule.test('http://localhost:8080')).toBe(true); + expect(urlWithoutTLDRule.run('http://localhost:8080').pass).toBe(true); + expect(urlWithoutTLDRule.test('http://localhost')).toBe(true); + expect(urlWithoutTLDRule.run('http://localhost').pass).toBe(true); + }); + }); it('Should pass for valid URLs', () => { expect(() => enforce('http://www.google.com').isURL()).not.toThrow(); expect(() => enforce('https://google.com').isURL()).not.toThrow(); diff --git a/packages/n4s/src/exports/compose.ts b/packages/n4s/src/exports/compose.ts deleted file mode 100644 index 44762f04a..000000000 --- a/packages/n4s/src/exports/compose.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ctx } from 'n4s'; -import { invariant, StringObject, assign, mapFirst } from 'vest-utils'; - -import type { ComposeResult, LazyRuleRunners } from 'genEnforceLazy'; -import { defaultToPassing, RuleDetailedResult } from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -/* eslint-disable max-lines-per-function */ - -export default function compose( - ...composites: LazyRuleRunners[] -): ComposeResult { - return assign( - (value: any) => { - const res = run(value); - - invariant(res.pass, StringObject(res.message)); - }, - { - run, - test: (value: any) => run(value).pass, - }, - ); - - function run(value: any): RuleDetailedResult { - return ctx.run({ value }, () => { - return defaultToPassing( - mapFirst( - composites, - ( - composite: LazyRuleRunners, - breakout: (conditional: boolean, res: RuleDetailedResult) => void, - ) => { - /* HACK: Just a small white lie. ~~HELP WANTED~~. - The ideal is that instead of `LazyRuleRunners` We would simply use `Lazy` to begin with. - The problem is that lazy rules can't really be passed to this function due to some generic hell - so we're limiting it to a small set of functions. - */ - - const res = runLazyRule(composite, value); - - breakout(!res.pass, res); - }, - ), - ); - }); - } -} diff --git a/packages/n4s/src/exports/compounds.ts b/packages/n4s/src/exports/compounds.ts deleted file mode 100644 index 46e96ea5c..000000000 --- a/packages/n4s/src/exports/compounds.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { enforce } from 'n4s'; - -import { allOf } from 'allOf'; -import { anyOf } from 'anyOf'; -import { EnforceCustomMatcher } from 'enforceUtilityTypes'; -import { Lazy } from 'genEnforceLazy'; -import { noneOf } from 'noneOf'; -import { oneOf } from 'oneOf'; -import { RuleDetailedResult } from 'ruleReturn'; - -enforce.extend({ allOf, anyOf, noneOf, oneOf }); - -type EnforceCompoundRule = ( - value: unknown, - ...rules: Lazy[] -) => RuleDetailedResult; - -/* eslint-disable @typescript-eslint/no-namespace */ -declare global { - namespace n4s { - interface EnforceCustomMatchers { - allOf: EnforceCustomMatcher; - anyOf: EnforceCustomMatcher; - noneOf: EnforceCustomMatcher; - oneOf: EnforceCustomMatcher; - } - } -} diff --git a/packages/n4s/src/exports/date.ts b/packages/n4s/src/exports/date.ts index c2f2216ec..6c644f155 100644 --- a/packages/n4s/src/exports/date.ts +++ b/packages/n4s/src/exports/date.ts @@ -4,18 +4,16 @@ import isBefore from 'validator/es/lib/isBefore'; import isDate from 'validator/es/lib/isDate'; import isISO8601 from 'validator/es/lib/isISO8601'; -import { EnforceCustomMatcher } from 'enforceUtilityTypes'; - enforce.extend({ isAfter, isBefore, isDate, isISO8601 }); /* eslint-disable @typescript-eslint/no-namespace */ declare global { namespace n4s { - interface EnforceCustomMatchers { - isAfter: EnforceCustomMatcher; - isBefore: EnforceCustomMatcher; - isDate: EnforceCustomMatcher; - isISO8601: EnforceCustomMatcher; + interface EnforceMatchers { + isAfter: typeof isAfter; + isBefore: typeof isBefore; + isDate: typeof isDate; + isISO8601: typeof isISO8601; } } } diff --git a/packages/n4s/src/exports/email.ts b/packages/n4s/src/exports/email.ts index 22d71f412..ccd369bcd 100644 --- a/packages/n4s/src/exports/email.ts +++ b/packages/n4s/src/exports/email.ts @@ -1,15 +1,14 @@ -import { enforce } from 'n4s'; import isEmail from 'validator/es/lib/isEmail'; -import { EnforceCustomMatcher } from 'enforceUtilityTypes'; +import { enforce } from 'n4s'; enforce.extend({ isEmail }); /* eslint-disable @typescript-eslint/no-namespace */ declare global { namespace n4s { - interface EnforceCustomMatchers { - isEmail: EnforceCustomMatcher; + interface EnforceMatchers { + isEmail: typeof isEmail; } } } diff --git a/packages/n4s/src/exports/isURL.ts b/packages/n4s/src/exports/isURL.ts index 3d76b4bd7..8afef3338 100644 --- a/packages/n4s/src/exports/isURL.ts +++ b/packages/n4s/src/exports/isURL.ts @@ -1,15 +1,13 @@ import { enforce } from 'n4s'; import isURL from 'validator/es/lib/isURL'; -import { EnforceCustomMatcher } from 'enforceUtilityTypes'; - enforce.extend({ isURL }); /* eslint-disable @typescript-eslint/no-namespace */ declare global { namespace n4s { - interface EnforceCustomMatchers { - isURL: EnforceCustomMatcher; + interface EnforceMatchers { + isURL: typeof isURL; } } } diff --git a/packages/n4s/src/exports/schema.ts b/packages/n4s/src/exports/schema.ts deleted file mode 100644 index 1f6d315c8..000000000 --- a/packages/n4s/src/exports/schema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { enforce } from 'n4s'; - -import { EnforceCustomMatcher } from 'enforceUtilityTypes'; -import { isArrayOf } from 'isArrayOf'; -import { loose } from 'loose'; -import { optional } from 'optional'; -import { shape } from 'shape'; - -export { partial } from 'partial'; - -enforce.extend({ isArrayOf, loose, optional, shape }); - -/* eslint-disable @typescript-eslint/no-namespace */ -declare global { - namespace n4s { - interface EnforceCustomMatchers { - isArrayOf: EnforceCustomMatcher; - loose: EnforceCustomMatcher; - shape: EnforceCustomMatcher; - optional: EnforceCustomMatcher; - } - } -} diff --git a/packages/n4s/src/extendLogic.ts b/packages/n4s/src/extendLogic.ts new file mode 100644 index 000000000..58e3b7024 --- /dev/null +++ b/packages/n4s/src/extendLogic.ts @@ -0,0 +1,65 @@ +import { RuleRunReturn } from 'RuleRunReturn'; +import { extendEager } from 'eager'; +import { ctx } from 'enforceContext'; +import { addToChain, registerLazyRule } from 'genRuleChain'; + +/** + * Extends the enforce API with custom validation rules. + * Custom rules are added to both eager and lazy APIs automatically. + * + * Rules receive the value as the first parameter, followed by any additional arguments. + * They should return a boolean or RuleRunReturn. + * + * @param enforce - The enforce object to extend + * @param rules - Object mapping rule names to validation functions + * + * @example + * ```typescript + * // Add custom rules + * extendEnforce(enforce, { + * isPositive: (value: number) => value > 0, + * isBetween: (value: number, min: number, max: number) => + * value >= min && value <= max, + * isEven: (value: number) => value % 2 === 0 + * }); + * + * // Use in eager API + * enforce(10).isPositive().isEven(); + * enforce(5).isBetween(1, 10); + * + * // Use in lazy API + * const positiveRule = enforce.isPositive(); + * positiveRule.test(5); // true + * positiveRule.test(-3); // false + * + * // Combine with built-in rules + * const schema = enforce.shape({ + * age: enforce.isNumber().isPositive().isBetween(18, 100), + * score: enforce.isNumber().isEven() + * }); + * ``` + */ +export function extendEnforce( + enforce: any, + rules: Record any>, +) { + extendEager(rules); + + Object.keys(rules).forEach(ruleName => { + const rule = rules[ruleName]; + const ruleWrapper = (value: any, ...args: any[]) => { + const res = ctx.run({ value }, () => rule(value, ...args)); + return RuleRunReturn.create(res, value); + }; + + enforce[ruleName] = (...args: any[]) => + addToChain({}, (value: any) => ruleWrapper(value, ...args)); + + registerLazyRule( + ruleName, + (...args: any[]) => + (value: any) => + ruleWrapper(value, ...args), + ); + }); +} diff --git a/packages/n4s/src/lazy.ts b/packages/n4s/src/lazy.ts new file mode 100644 index 000000000..9e2793fac --- /dev/null +++ b/packages/n4s/src/lazy.ts @@ -0,0 +1,101 @@ +import { type RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; +import type { ArrayRuleInstance } from 'arrayRules'; +import * as arrayRules from 'arrayRules'; +import * as compoundRules from 'compoundRules'; +import type { CompoundRuleLazyTypes } from 'compoundRules'; +import { ctx } from 'enforceContext'; +import { addToChain } from 'genRuleChain'; +import { AnyRuleInstance } from 'generalRules'; +import * as generalRules from 'generalRules'; +import type { CustomMatcherArgs } from 'n4sTypes'; +import type { ObjectRulesUnion } from 'objectRules'; +import * as objectRules from 'objectRules'; +import { adaptDynamicRules } from 'ruleAdapter'; +import * as schemaRules from 'schemaRules'; +import type { SchemaRuleLazyTypes } from 'schemaRules'; +import { typeRules } from 'typeRules'; +import { FirstParam } from 'typeUtils'; + +/** + * Type mapping for custom rules in the lazy (builder) API. + * Excludes schema and compound rules as they have special handling. + */ +type TCustomLazyRules = { + [K in keyof n4s.EnforceMatchers as K extends keyof SchemaRuleLazyTypes + ? never + : K extends keyof CompoundRuleLazyTypes + ? never + : K]: ( + ...args: CustomMatcherArgs + ) => RuleInstance< + FirstParam, + [FirstParam] + >; +}; + +// Create schema rules with isArrayOf handled specially +const adaptedSchemaRules = adaptDynamicRules< + RuleInstance, + typeof schemaRules +>(schemaRules); + +// Override isArrayOf to chain array rules +const schemaRulesWithArrayChaining = { + ...adaptedSchemaRules, + isArrayOf: (...rules: any[]): ArrayRuleInstance => + addToChain>(arrayRules as any, (value: any) => { + const result = ctx.run({ value }, () => + schemaRules.isArrayOf(value, ...rules), + ); + return RuleRunReturn.create(result, value); + }), +}; + +const baseEnforceLazy = { + ...(adaptDynamicRules, typeof compoundRules>( + compoundRules, + ) as unknown as CompoundRuleLazyTypes), + ...(schemaRulesWithArrayChaining as unknown as SchemaRuleLazyTypes), + ...adaptDynamicRules(generalRules), + ...adaptDynamicRules(objectRules), + ...typeRules, +}; + +/** + * Lazy (builder) API for creating reusable validation rules. + * Rules are created without a value and can be executed later with `run()` or `test()`. + * + * This is the builder pattern side of the enforce API - rules are chainable and reusable. + * + * @example + * ```typescript + * // Create reusable rules + * const stringRule = enforce.isString(); + * const emailRule = enforce.isString().matches(/@/); + * + * // Test with values + * stringRule.test('hello'); // true + * stringRule.test(123); // false + * + * // Run for detailed results + * const result = emailRule.run('user@example.com'); + * console.log(result.pass); // true + * + * // Chain type-specific rules + * const ageRule = enforce.isNumber() + * .greaterThanOrEquals(18) + * .lessThan(150); + * + * // Schema validation + * const userSchema = enforce.shape({ + * name: enforce.isString(), + * email: enforce.isString().matches(/@/), + * age: ageRule + * }); + * + * userSchema.test({ name: 'John', email: 'john@example.com', age: 25 }); // true + * ``` + */ +export const enforceLazy = baseEnforceLazy as unknown as TCustomLazyRules & + typeof baseEnforceLazy; diff --git a/packages/n4s/src/lazy/ruleAdapter.ts b/packages/n4s/src/lazy/ruleAdapter.ts new file mode 100644 index 000000000..c51725749 --- /dev/null +++ b/packages/n4s/src/lazy/ruleAdapter.ts @@ -0,0 +1,25 @@ +import { ctx } from 'enforceContext'; + +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; +import { addToChain } from 'genRuleChain'; + +export function adaptDynamicRules< + T extends RuleInstance, + O extends Record any>, +>(container: O): Record T> { + return Object.keys(container).reduce( + (acc, key) => { + (acc as any)[key] = (...args: any[]) => + addToChain({}, (value: any) => { + // eslint-disable-next-line max-nested-callbacks + const result = ctx.run({ value }, () => + (container as any)[key](value, ...args), + ); + return RuleRunReturn.create(result, value); + }); + return acc; + }, + {} as Record T>, + ); +} diff --git a/packages/n4s/src/lazy/typeRules.ts b/packages/n4s/src/lazy/typeRules.ts new file mode 100644 index 000000000..eb2ee75a7 --- /dev/null +++ b/packages/n4s/src/lazy/typeRules.ts @@ -0,0 +1,36 @@ +import type { ArrayRuleInstance } from 'arrayRules'; +import * as arrayRules from 'arrayRules'; +import { isBoolean, type BooleanRuleInstance } from 'booleanRules'; +import * as booleanRules from 'booleanRules'; +import { addToChain } from 'genRuleChain'; +import { isArray } from 'isArrayRule'; +import { isNumeric } from 'isNumeric'; +import { + isNull, + isUndefined, + isNullish, + type NullRuleInstance, + type UndefinedRuleInstance, + type NullishRuleInstance, +} from 'nullishRules'; +import { + isNumber, + type NumberRuleInstance, + type NumericRuleInstance, +} from 'numberRules'; +import * as numberRules from 'numberRules'; +import * as numericRules from 'numberRules'; +import { isString, type StringRuleInstance } from 'stringRules'; +import * as stringRules from 'stringRules'; + +export const typeRules = { + isArray: (): ArrayRuleInstance => + addToChain>(arrayRules as any, isArray), + isBoolean: (): BooleanRuleInstance => addToChain(booleanRules, isBoolean), + isNull: (): NullRuleInstance => addToChain({}, isNull), + isNullish: (): NullishRuleInstance => addToChain({}, isNullish), + isNumber: (): NumberRuleInstance => addToChain(numberRules, isNumber), + isNumeric: (): NumericRuleInstance => addToChain(numericRules, isNumeric), + isString: (): StringRuleInstance => addToChain(stringRules, isString), + isUndefined: (): UndefinedRuleInstance => addToChain({}, isUndefined), +}; diff --git a/packages/n4s/src/lib/enforceUtilityTypes.ts b/packages/n4s/src/lib/enforceUtilityTypes.ts deleted file mode 100644 index d87b457fc..000000000 --- a/packages/n4s/src/lib/enforceUtilityTypes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CB, DropFirst } from 'vest-utils'; - -export type EnforceCustomMatcher = ( - ...args: DropFirst> -) => R; diff --git a/packages/n4s/src/lib/ruleReturn.ts b/packages/n4s/src/lib/ruleReturn.ts deleted file mode 100644 index 5b30d61bc..000000000 --- a/packages/n4s/src/lib/ruleReturn.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Stringable } from 'vest-utils'; -import { defaultTo } from 'vest-utils'; - -export default function ruleReturn( - pass: boolean, - message?: string, -): RuleDetailedResult { - const output: RuleDetailedResult = { pass }; - - if (message) { - output.message = message; - } - - return output; -} - -export function failing(): RuleDetailedResult { - return ruleReturn(false); -} - -export function passing(): RuleDetailedResult { - return ruleReturn(true); -} - -export function defaultToFailing( - callback: (...args: any[]) => RuleDetailedResult, -): RuleDetailedResult { - return defaultTo(callback, failing()); -} - -export function defaultToPassing( - callback: (...args: any[]) => RuleDetailedResult, -): RuleDetailedResult { - return defaultTo(callback, passing()); -} - -export type RuleReturn = - | boolean - | { - pass: boolean; - message?: Stringable; - }; - -export type RuleDetailedResult = { pass: boolean; message?: string }; diff --git a/packages/n4s/src/lib/runLazyRule.ts b/packages/n4s/src/lib/runLazyRule.ts deleted file mode 100644 index 2e9e49a7f..000000000 --- a/packages/n4s/src/lib/runLazyRule.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { LazyRuleRunners } from 'genEnforceLazy'; -import type { RuleDetailedResult } from 'ruleReturn'; -import * as ruleReturn from 'ruleReturn'; - -export default function runLazyRule( - lazyRule: LazyRuleRunners, - currentValue: any, -): RuleDetailedResult { - try { - return lazyRule.run(currentValue); - } catch { - return ruleReturn.failing(); - } -} diff --git a/packages/n4s/src/lib/transformResult.ts b/packages/n4s/src/lib/transformResult.ts deleted file mode 100644 index e8542a0a9..000000000 --- a/packages/n4s/src/lib/transformResult.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { invariant, optionalFunctionValue, isBoolean } from 'vest-utils'; - -import ruleReturn, { RuleReturn, RuleDetailedResult } from 'ruleReturn'; -import type { RuleValue, Args } from 'runtimeRules'; - -/** - * Transform the result of a rule into a standard format - */ -export function transformResult( - result: RuleReturn, - ruleName: string, - value: RuleValue, - ...args: Args -): RuleDetailedResult { - validateResult(result); - - // if result is boolean - if (isBoolean(result)) { - return ruleReturn(result); - } - return ruleReturn( - result.pass, - optionalFunctionValue(result.message, ruleName, value, ...args), - ); -} - -function validateResult(result: RuleReturn): void { - // if result is boolean, or if result.pass is boolean - invariant( - isBoolean(result) || (result && isBoolean(result.pass)), - 'Incorrect return value for rule: ' + JSON.stringify(result), - ); -} diff --git a/packages/n4s/src/n4s.ts b/packages/n4s/src/n4s.ts index daed01571..58059d6d9 100644 --- a/packages/n4s/src/n4s.ts +++ b/packages/n4s/src/n4s.ts @@ -1,9 +1,142 @@ -export { enforce } from 'enforce'; +import { enforceEager } from 'eager'; +import { ctx } from 'enforceContext'; +import type { EnforceContext } from 'enforceContext'; +import { extendEnforce } from 'extendLogic'; +import { enforceLazy } from 'lazy'; +import { assign } from 'vest-utils'; + +/** + * Context API for accessing validation context. + * Allows accessing metadata and parent validation context during rule execution. + */ export { ctx } from 'enforceContext'; -/* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-empty-interface, @typescript-eslint/no-unused-vars */ -declare global { - namespace n4s { - interface EnforceCustomMatchers {} - } -} +/** + * Compose multiple validation rules into a single reusable rule. + * Returns a composed rule that can be used in both eager and lazy validation. + * + * @example + * ```typescript + * // Compose separate rules that apply to the same value + * const isValidUsername = compose( + * enforce.isString() + * .longerThan(3) + * .shorterThan(20) + * .matches(/^[a-zA-Z0-9_]+$/) + * ); + * + * isValidUsername.test('john_doe'); // true + * isValidUsername.test('ab'); // false (too short) + * isValidUsername.test('john doe'); // false (contains space) + * + * // Use in schema validation + * enforce({ username: 'john_doe' }).shape({ + * username: isValidUsername + * }); + * ``` + */ +export { compose } from 'compose'; + +export type { RuleInstance } from 'RuleInstance'; + +export type { + ArraySchemaResultMap, + MultiTypeInput, + SchemaInfer, + SchemaResultMap, + ShapeType, +} from 'schemaRulesTypes'; + +export type { ShapeRuleInstance, ShapeValue } from 'shape'; +export type { LooseRuleInstance, LooseShapeValue } from 'loose'; +export type { PartialRuleInstance, PartialShapeValue } from 'partial'; +export type { OptionalRuleInstance } from 'optional'; +export type { IsArrayOfRuleInstance } from 'isArrayOf'; + +type ExtendFn = (rules: Record any>) => void; +type ContextFn = () => EnforceContext; +type Enforce = typeof enforceEager & + typeof enforceLazy & { extend: ExtendFn; context: ContextFn }; + +/** + * Main validation function supporting both eager (imperative) and lazy (builder) APIs. + * + * **Eager API (Imperative):** + * Immediately validates a value with chainable assertions that execute on call. + * + * **Lazy API (Builder Pattern):** + * Builds a reusable validation rule without a value, returns a RuleInstance. + * + * @example + * ```typescript + * // Eager API - validates immediately + * enforce('hello').isString().longerThan(3); + * + * // Lazy API - builds a reusable rule + * const stringRule = enforce.isString(); + * stringRule.test('hello'); // true + * stringRule.run('hello'); // RuleRunReturn { pass: true, type: 'hello' } + * + * // Custom messages + * enforce('').message('Field is required').isNotEmpty(); + * + * // Schema validation + * enforce({ name: 'John', age: 30 }).shape({ + * name: enforce.isString(), + * age: enforce.isNumber() + * }); + * ``` + */ +export const enforce = assign(enforceEager, enforceLazy) as Enforce; + +/** + * Access the current validation context. + * Returns metadata and parent context information during rule execution. + * + * @returns The current EnforceContext or null if not in validation context + * + * @example + * ```typescript + * const context = enforce.context(); + * console.log(context?.value); // Current value being validated + * console.log(context?.meta); // Metadata attached to context + * ``` + */ +enforce.context = function context(): EnforceContext { + return ctx.use(); +}; + +/** + * Extend enforce with custom validation rules. + * Custom rules become available on both eager and lazy APIs. + * + * @param rules - Object mapping rule names to validation functions + * + * @example + * ```typescript + * enforce.extend({ + * isPositive: (value: number) => value > 0, + * isBetween: (value: number, min: number, max: number) => + * value >= min && value <= max + * }); + * + * // Now use your custom rules + * enforce(5).isPositive(); // eager API + * const rule = enforce.isPositive(); // lazy API + * + * // With TypeScript, declare types: + * declare global { + * namespace n4s { + * interface EnforceMatchers { + * isPositive: (value: number) => boolean; + * isBetween: (value: number, min: number, max: number) => boolean; + * } + * } + * } + * ``` + */ +enforce.extend = function extend( + rules: Record any>, +) { + extendEnforce(enforce, rules); +}; diff --git a/packages/n4s/src/n4sTypes.ts b/packages/n4s/src/n4sTypes.ts new file mode 100644 index 000000000..1d1d5e2fe --- /dev/null +++ b/packages/n4s/src/n4sTypes.ts @@ -0,0 +1,60 @@ +import { RuleRunReturn } from 'RuleRunReturn'; +import type { FirstParam } from 'typeUtils'; +import type { CB, DropFirst } from 'vest-utils'; + +/** + * Global namespace for n4s custom rules. + * Users should extend EnforceMatchers with value-first rule signatures. + * These will be used to type both eager (value-first drop) and lazy (builder) APIs. + * + * Each rule is a function whose FIRST parameter is the value being validated. + * The function should return a boolean or a RuleRunReturn. + * + * Example: + * declare global { + * namespace n4s { + * interface EnforceMatchers { + * isPositive: (value: number) => boolean; + * isBetween: (value: number, min: number, max: number) => boolean; + * } + * } + * } + */ +/* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-empty-interface */ +declare global { + namespace n4s { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface EnforceMatchers {} + } +} + +export type EnforceMatchers = n4s.EnforceMatchers; +// Note: We don't augment RuleInstance here with mapped types, because TS disallows +// interfaces extending mapped/conditional types. Instead, eager.ts and lazy.ts +// each map n4s.EnforceMatchers into their respective APIs explicitly. + +/** + * Base type for mapping custom matcher functions. + * Drops the first parameter (value) and maps remaining args. + */ +export type CustomMatcherArgs = DropFirst< + Parameters> +>; + +export type EnforceCustomMatcher = ( + ...args: CustomMatcherArgs +) => boolean | RuleRunReturn; + +/** + * Maps custom rules to eager API signatures (drops the value parameter). + * Only includes rules where T matches the first parameter type. + */ +export type TCustomRules = { + [K in keyof n4s.EnforceMatchers as T extends FirstParam< + n4s.EnforceMatchers[K] + > + ? K + : never]: ( + ...args: CustomMatcherArgs + ) => import('eager').EnforceEagerReturn; +}; diff --git a/packages/n4s/src/plugins/compounds/__tests__/allOf.test.ts b/packages/n4s/src/plugins/compounds/__tests__/allOf.test.ts deleted file mode 100644 index 824135d1c..000000000 --- a/packages/n4s/src/plugins/compounds/__tests__/allOf.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import 'compounds'; - -describe('allOf', () => { - describe('Lazy Assertions', () => { - describe('When all rules are satisfied', () => { - it('Should return a passing result', () => { - expect( - enforce - .allOf(enforce.isArray(), enforce.longerThan(2)) - .run([1, 2, 3]), - ).toEqual(ruleReturn.passing()); - }); - }); - }); -}); diff --git a/packages/n4s/src/plugins/compounds/__tests__/noneOf.test.ts b/packages/n4s/src/plugins/compounds/__tests__/noneOf.test.ts deleted file mode 100644 index cba74ae6f..000000000 --- a/packages/n4s/src/plugins/compounds/__tests__/noneOf.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import 'compounds'; - -describe('noneOf', () => { - describe('Lazy Assertions', () => { - describe('When none of the rules are satisfied', () => { - it('Should return a passing result', () => { - expect( - enforce.noneOf(enforce.isArray(), enforce.longerThan(2)).run('x'), - ).toEqual(ruleReturn.passing()); - }); - }); - - describe('When some of the rules are satisfied', () => { - it('Should return a failing result', () => { - expect( - enforce.noneOf(enforce.isArray(), enforce.longerThan(2)).run([2]), - ).toEqual(ruleReturn.failing()); - }); - }); - - describe('When all of the rules are satisfied', () => { - it('Should return a failing result', () => { - expect( - enforce.noneOf(enforce.isArray(), enforce.longerThan(2)).run([2, 3]), - ).toEqual(ruleReturn.failing()); - }); - }); - }); -}); diff --git a/packages/n4s/src/plugins/compounds/__tests__/oneOf.test.ts b/packages/n4s/src/plugins/compounds/__tests__/oneOf.test.ts deleted file mode 100644 index 47793f978..000000000 --- a/packages/n4s/src/plugins/compounds/__tests__/oneOf.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; - -import 'schema'; -import 'compounds'; - -describe('enforce.oneOf', () => { - it('Should fail when multiple enforcements are met', () => { - expect( - enforce.oneOf(enforce.isNumber(), enforce.greaterThan(2)).run(3), - ).toEqual(ruleReturn.failing()); - }); - - it('Should pass when only one enforcement is met', () => { - expect( - User.run({ - name: { - first: 'John', - last: 'Doe', - }, - }), - ).toEqual(ruleReturn.passing()); - expect(User.run({ id: 11 })).toEqual(ruleReturn.passing()); - }); - - it('Should fail when no enforcement is met', () => { - expect(User.run({})).toEqual(ruleReturn.failing()); - }); -}); - -const Entity = enforce.loose({ - id: enforce.isNumber(), -}); - -const Person = enforce.loose({ - name: enforce.shape({ - first: enforce.isString().longerThan(2), - last: enforce.isString().longerThan(2), - }), -}); -const User = enforce.oneOf(Entity, Person); diff --git a/packages/n4s/src/plugins/compounds/allOf.ts b/packages/n4s/src/plugins/compounds/allOf.ts deleted file mode 100644 index 2d03b49be..000000000 --- a/packages/n4s/src/plugins/compounds/allOf.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { mapFirst } from 'vest-utils'; - -import type { Lazy } from 'genEnforceLazy'; -import * as ruleReturn from 'ruleReturn'; -import type { RuleDetailedResult } from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -export function allOf(value: unknown, ...rules: Lazy[]): RuleDetailedResult { - return ruleReturn.defaultToPassing( - mapFirst(rules, (rule, breakout) => { - const res = runLazyRule(rule, value); - breakout(!res.pass, res); - }), - ); -} diff --git a/packages/n4s/src/plugins/compounds/anyOf.ts b/packages/n4s/src/plugins/compounds/anyOf.ts deleted file mode 100644 index e29e72681..000000000 --- a/packages/n4s/src/plugins/compounds/anyOf.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { mapFirst } from 'vest-utils'; - -import type { Lazy } from 'genEnforceLazy'; -import * as ruleReturn from 'ruleReturn'; -import type { RuleDetailedResult } from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -export function anyOf(value: unknown, ...rules: Lazy[]): RuleDetailedResult { - return ruleReturn.defaultToFailing( - mapFirst(rules, (rule, breakout) => { - const res = runLazyRule(rule, value); - breakout(res.pass, res); - }), - ); -} diff --git a/packages/n4s/src/plugins/compounds/noneOf.ts b/packages/n4s/src/plugins/compounds/noneOf.ts deleted file mode 100644 index 0e59ef617..000000000 --- a/packages/n4s/src/plugins/compounds/noneOf.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { mapFirst } from 'vest-utils'; - -import type { Lazy } from 'genEnforceLazy'; -import type { RuleDetailedResult } from 'ruleReturn'; -import * as ruleReturn from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -export function noneOf(value: unknown, ...rules: Lazy[]): RuleDetailedResult { - return ruleReturn.defaultToPassing( - mapFirst(rules, (rule, breakout) => { - const res = runLazyRule(rule, value); - - breakout(res.pass, ruleReturn.failing()); - }), - ); -} diff --git a/packages/n4s/src/plugins/compounds/oneOf.ts b/packages/n4s/src/plugins/compounds/oneOf.ts deleted file mode 100644 index bc71c7ad7..000000000 --- a/packages/n4s/src/plugins/compounds/oneOf.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { greaterThan } from 'vest-utils'; - -import { equals } from 'equals'; -import type { Lazy } from 'genEnforceLazy'; -import ruleReturn, { RuleDetailedResult } from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -const REQUIRED_COUNT = 1; - -export function oneOf(value: unknown, ...rules: Lazy[]): RuleDetailedResult { - let passingCount = 0; - rules.some(rule => { - const res = runLazyRule(rule, value); - - if (res.pass) { - passingCount++; - } - - if (greaterThan(passingCount, REQUIRED_COUNT)) { - return false; - } - }); - - return ruleReturn(equals(passingCount, REQUIRED_COUNT)); -} diff --git a/packages/n4s/src/plugins/schema/__tests__/isArrayOf.test.ts b/packages/n4s/src/plugins/schema/__tests__/isArrayOf.test.ts deleted file mode 100644 index badeea74e..000000000 --- a/packages/n4s/src/plugins/schema/__tests__/isArrayOf.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import 'schema'; -import 'compounds'; -import * as ruleReturn from 'ruleReturn'; - -describe('enforce.isArrayOf', () => { - describe('lazy interface', () => { - it('Should return a passing return for an empty array', () => { - expect(enforce.isArrayOf(enforce.isString()).run([])).toEqual( - ruleReturn.passing(), - ); - }); - - it('Should return a passing return for valid arrays', () => { - expect( - enforce.isArrayOf(enforce.isString()).run(['a', 'b', 'c']), - ).toEqual(ruleReturn.passing()); - - expect( - enforce - .isArrayOf(enforce.anyOf(enforce.isString(), enforce.isNumber())) - .run([1, 'b', 'c']), - ).toEqual(ruleReturn.passing()); - - expect( - enforce - .isArrayOf( - enforce.shape({ - id: enforce.isNumber(), - username: enforce.isString(), - }), - ) - .run([ - { id: 1, username: 'b' }, - { id: 2, username: 'c' }, - ]), - ).toEqual(ruleReturn.passing()); - }); - - it('Should return a failing return for invalid arrays', () => { - expect(enforce.isArrayOf(enforce.isString()).run([1, 2, 3])).toEqual( - ruleReturn.failing(), - ); - - expect( - enforce - .isArrayOf(enforce.allOf(enforce.isString(), enforce.isNumber())) - .run([1, 2, 3]), - ).toEqual(ruleReturn.failing()); - - expect( - enforce - .isArrayOf( - enforce.shape({ - id: enforce.isNumber(), - username: enforce.isString(), - }), - ) - .run([ - { id: '1', username: 'b' }, - { id: '2', username: 'c' }, - { id: '3', username: 'd' }, - ]), - ).toEqual(ruleReturn.failing()); - }); - }); - - describe('eager interface', () => { - it('Should return silently for an empty array', () => { - enforce([]).isArrayOf(enforce.isString()); - }); - - it('Should return silently for valid arrays', () => { - enforce(['a', 'b', 'c']).isArrayOf(enforce.isString()); - - enforce([1, 'b', 'c']).isArrayOf( - enforce.anyOf(enforce.isString(), enforce.isNumber()), - ); - - enforce([ - { id: 1, username: 'b' }, - { id: 2, username: 'c' }, - ]).isArrayOf( - enforce.shape({ - id: enforce.isNumber(), - username: enforce.isString(), - }), - ); - }); - - it('Should throw for invalid arrays', () => { - expect(() => enforce([1, 2, 3]).isArrayOf(enforce.isString())).toThrow(); - - expect(() => - enforce([1, 2, 3]).isArrayOf( - enforce.allOf(enforce.isString(), enforce.isNumber()), - ), - ).toThrow(); - - expect(() => - enforce([ - { id: '1', username: 'b' }, - { id: '2', username: 'c' }, - { id: '3', username: 'd' }, - ]).isArrayOf( - enforce.shape({ - id: enforce.isNumber(), - username: enforce.isString(), - }), - ), - ).toThrow(); - }); - }); -}); diff --git a/packages/n4s/src/plugins/schema/__tests__/loose.test.ts b/packages/n4s/src/plugins/schema/__tests__/loose.test.ts deleted file mode 100644 index 0eb2085d0..000000000 --- a/packages/n4s/src/plugins/schema/__tests__/loose.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import 'schema'; - -describe('enforce.loose for loose matching', () => { - describe('lazy interface', () => { - it('Should return a passing return when value has non-enforced keys', () => { - expect( - enforce - .loose({ username: enforce.isString(), age: enforce.isNumber() }) - .run({ username: 'ealush', age: 31, foo: 'bar' }), - ).toEqual(ruleReturn.passing()); - }); - }); - describe('eager interface', () => { - it('Should return silently return when value has non-enforced keys', () => { - enforce({ username: 'ealush', age: 31, foo: 'bar' }).loose({ - username: enforce.isString(), - age: enforce.isNumber(), - }); - }); - }); -}); diff --git a/packages/n4s/src/plugins/schema/__tests__/optional.test.ts b/packages/n4s/src/plugins/schema/__tests__/optional.test.ts deleted file mode 100644 index e4cf15430..000000000 --- a/packages/n4s/src/plugins/schema/__tests__/optional.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import 'schema'; -import 'compounds'; - -describe('enforce.optional', () => { - describe('lazy interface', () => { - it('Should return a passing result for nullable values', () => { - expect(enforce.optional(enforce.isNumber()).run(null)).toEqual( - ruleReturn.passing(), - ); - expect(enforce.optional(enforce.isArray()).run(undefined)).toEqual( - ruleReturn.passing(), - ); - - expect( - enforce - .shape({ - firstName: enforce.isString(), - middleName: enforce.optional(enforce.isString()), - lastName: enforce.isString(), - }) - .run({ - firstName: 'John', - lastName: 'Doe', - }), - ).toEqual(ruleReturn.passing()); - }); - - it('Should return passing result for non-nullable values that satisfy the tests', () => { - expect(enforce.optional(enforce.isNumber()).run(2)).toEqual( - ruleReturn.passing(), - ); - expect(enforce.optional(enforce.isArray()).run([1, 2])).toEqual( - ruleReturn.passing(), - ); - expect( - enforce - .shape({ - firstName: enforce.isString(), - middleName: enforce.optional(enforce.isString()), - lastName: enforce.isString(), - }) - .run({ - firstName: 'John', - middleName: 'H.', - lastName: 'Doe', - }), - ).toEqual(ruleReturn.passing()); - }); - - it('Should return a failing result for non-nullable values that do not satisfy the tests', () => { - expect(enforce.optional(enforce.isNumber()).run('2')).toEqual( - ruleReturn.failing(), - ); - expect(enforce.optional(enforce.isArray()).run('2')).toEqual( - ruleReturn.failing(), - ); - expect( - enforce - .shape({ - firstName: enforce.isString(), - middleName: enforce.optional(enforce.isString().longerThan(3)), - lastName: enforce.isString(), - }) - .run({ - firstName: 'John', - middleName: 'H.', - lastName: 'Doe', - }), - ).toEqual(ruleReturn.failing()); - }); - }); -}); diff --git a/packages/n4s/src/plugins/schema/__tests__/partial.test.ts b/packages/n4s/src/plugins/schema/__tests__/partial.test.ts deleted file mode 100644 index 106afcfea..000000000 --- a/packages/n4s/src/plugins/schema/__tests__/partial.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import { partial } from 'schema'; - -import 'compounds'; - -describe('partial', () => { - describe('Lazy Interface', () => { - it('Should pass when wrapped fields are undefined or null', () => { - const rules = enforce.shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - - partial({}); - - expect(rules.run({})).toEqual(ruleReturn.passing()); - expect( - rules.run({ - username: null, - id: null, - }), - ).toEqual(ruleReturn.passing()); - }); - - it('Should pass when wrapped fields are valid', () => { - const rules = enforce.shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - - expect(rules.run({ username: 'foobar', id: 1 })).toEqual( - ruleReturn.passing(), - ); - }); - - it('Should pass when some wrapped fields are missing', () => { - const rules = enforce.shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - - expect(rules.run({ username: 'foobar' })).toEqual(ruleReturn.passing()); - }); - - it('Should fail when wrapped fields are invalid', () => { - const rules = enforce.shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - - expect(rules.run({ username: 'foo', id: '1' })).toEqual( - ruleReturn.failing(), - ); - }); - }); - - describe('Eager interface', () => { - it('Should pass when wrapped fields are undefined or null', () => { - enforce({}).shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - - enforce({ - username: null, - id: null, - }).shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - }); - - it('Should pass when wrapped fields are valid', () => { - enforce({ username: 'foobar', id: 1 }).shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - }); - - it('Should pass when some wrapped fields are missing', () => { - enforce({ username: 'foobar' }).shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ); - }); - - it('Should fail when wrapped fields are invalid', () => { - expect(() => - enforce({ username: 'foo', id: '1' }).shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ), - ).toThrow(); - }); - }); - - it("Should retain rule's original constraints", () => { - expect( - enforce - .shape( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ) - .run({ username: 'foobar', id: '1', foo: 'bar' }), - ).toEqual(ruleReturn.failing()); - - expect( - enforce - .loose( - partial({ - username: enforce.isString().longerThan(3), - id: enforce.isNumeric(), - }), - ) - .run({ username: 'foobar', id: '1', foo: 'bar' }), - ).toEqual(ruleReturn.passing()); - }); -}); diff --git a/packages/n4s/src/plugins/schema/__tests__/shape&loose.test.ts b/packages/n4s/src/plugins/schema/__tests__/shape&loose.test.ts deleted file mode 100644 index eb47d1501..000000000 --- a/packages/n4s/src/plugins/schema/__tests__/shape&loose.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import 'schema'; -import 'compounds'; - -/* eslint-disable sort-keys */ - -// The base behavior of 'loose' and 'shape' is practically the same -// so we cover them using the same tests. -describe.each(['loose', 'shape'])('enforce.%s', (methodName: string) => { - describe('lazy interface', () => { - it('Should return a passing return when tests are valid', () => { - expect( - enforce[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - }).run({ username: 'ealush', age: 31 }), - ).toEqual(ruleReturn.passing()); - }); - - it('Should return a failing return when tests are invalid', () => { - expect( - enforce[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - }).run({ username: null, age: 0 }), - ).toEqual(ruleReturn.failing()); - }); - - describe('nested shapes', () => { - it('Should return a passing return when tests are valid', () => { - expect( - enforce[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - address: enforce.shape({ - street: enforce.isString(), - city: enforce.isString(), - state: enforce.isString(), - zip: enforce.isNumber(), - }), - }).run({ - username: 'ealush', - age: 31, - address: { - street: '123 Main St', - city: 'New York', - state: 'NY', - zip: 12345, - }, - }), - ).toEqual(ruleReturn.passing()); - }); - it('Should return a failing return when tests are invalid', () => { - expect( - enforce[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - address: enforce.shape({ - street: enforce.isString(), - city: enforce.isString(), - state: enforce.isString(), - zip: enforce.isNumber(), - }), - }).run({ - username: 'ealush', - age: 31, - address: { - street: '123 Main St', - city: null, - }, - }), - ).toEqual(ruleReturn.failing()); - }); - }); - }); - - describe('eager interface', () => { - it('Should throw an error fora failing return', () => { - expect(() => { - enforce({ username: null, age: 0 })[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - }); - }).toThrow(); - }); - - it('Should return silently for a passing return', () => { - enforce({ username: 'ealush', age: 31 })[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - }); - }); - - describe('nested shapes', () => { - it('Should return silently when tests are valid', () => { - enforce({ - username: 'ealush', - age: 31, - address: { - street: '123 Main St', - city: 'New York', - state: 'NY', - zip: 12345, - }, - })[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - address: enforce.shape({ - street: enforce.isString(), - city: enforce.isString(), - state: enforce.isString(), - zip: enforce.isNumber(), - }), - }); - }); - it('Should throw when tests are invalid', () => { - expect(() => { - enforce({ - username: 'ealush', - age: 31, - address: { - street: '123 Main St', - city: null, - }, - })[methodName]({ - username: enforce.isString(), - age: enforce.isNumber().gt(18), - address: enforce.shape({ - street: enforce.isString(), - city: enforce.isString(), - state: enforce.isString(), - zip: enforce.isNumber(), - }), - }); - }).toThrow(); - }); - }); - }); -}); - -/* eslint-enable sort-keys */ diff --git a/packages/n4s/src/plugins/schema/__tests__/shape.test.ts b/packages/n4s/src/plugins/schema/__tests__/shape.test.ts deleted file mode 100644 index 3407a6452..000000000 --- a/packages/n4s/src/plugins/schema/__tests__/shape.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import 'schema'; -import 'compounds'; - -describe('enforce.shape exact matching', () => { - describe('lazy interface', () => { - it('Should return a failing return when value has non-enforced keys', () => { - expect( - enforce - .shape({ username: enforce.isString(), age: enforce.isNumber() }) - .run({ username: 'ealush', age: 31, foo: 'bar' }), - ).toEqual(ruleReturn.failing()); - }); - }); - describe('eager interface', () => { - it('Should throw an error when value has non-enforced keys', () => { - expect(() => { - enforce({ username: 'ealush', age: 31, foo: 'bar' }).shape({ - username: enforce.isString(), - age: enforce.isNumber(), - }); - }).toThrow(); - }); - }); -}); diff --git a/packages/n4s/src/plugins/schema/isArrayOf.ts b/packages/n4s/src/plugins/schema/isArrayOf.ts deleted file mode 100644 index 9644d2ef5..000000000 --- a/packages/n4s/src/plugins/schema/isArrayOf.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ctx } from 'n4s'; -import { mapFirst } from 'vest-utils'; - -import type { LazyRuleRunners } from 'genEnforceLazy'; -import type { RuleDetailedResult } from 'ruleReturn'; -import * as ruleReturn from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -export function isArrayOf( - inputArray: any[], - currentRule: LazyRuleRunners, -): RuleDetailedResult { - return ruleReturn.defaultToPassing( - mapFirst(inputArray, (currentValue, breakout, index) => { - const res = ctx.run( - { value: currentValue, set: true, meta: { index } }, - () => runLazyRule(currentRule, currentValue), - ); - - breakout(!res.pass, res); - }), - ); -} diff --git a/packages/n4s/src/plugins/schema/loose.ts b/packages/n4s/src/plugins/schema/loose.ts deleted file mode 100644 index a88ab012b..000000000 --- a/packages/n4s/src/plugins/schema/loose.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ctx } from 'n4s'; - -import type { RuleDetailedResult } from 'ruleReturn'; -import * as ruleReturn from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; -import type { ShapeObject } from 'schemaTypes'; - -export function loose( - inputObject: Record, - shapeObject: ShapeObject, -): RuleDetailedResult { - for (const key in shapeObject) { - const currentValue = inputObject[key]; - const currentRule = shapeObject[key]; - - const res = ctx.run({ value: currentValue, set: true, meta: { key } }, () => - runLazyRule(currentRule, currentValue), - ); - - if (!res.pass) { - return res; - } - } - - return ruleReturn.passing(); -} diff --git a/packages/n4s/src/plugins/schema/optional.ts b/packages/n4s/src/plugins/schema/optional.ts deleted file mode 100644 index 662f4c682..000000000 --- a/packages/n4s/src/plugins/schema/optional.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isNullish } from 'vest-utils'; - -import type { Lazy } from 'genEnforceLazy'; -import type { RuleDetailedResult } from 'ruleReturn'; -import * as ruleReturn from 'ruleReturn'; -import runLazyRule from 'runLazyRule'; - -export function optional(value: any, ruleChain: Lazy): RuleDetailedResult { - if (isNullish(value)) { - return ruleReturn.passing(); - } - return runLazyRule(ruleChain, value); -} diff --git a/packages/n4s/src/plugins/schema/partial.ts b/packages/n4s/src/plugins/schema/partial.ts deleted file mode 100644 index 50c6ceb4e..000000000 --- a/packages/n4s/src/plugins/schema/partial.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { enforce } from 'n4s'; - -// Help needed improving the typings of this file. -// Ideally, we'd be able to extend ShapeObject, but that's not possible. -export function partial>(shapeObject: T): T { - const output = {} as T; - for (const key in shapeObject) { - output[key] = enforce.optional(shapeObject[key]) as T[Extract< - keyof T, - string - >]; - } - return output; -} diff --git a/packages/n4s/src/plugins/schema/schemaTypes.ts b/packages/n4s/src/plugins/schema/schemaTypes.ts deleted file mode 100644 index cb9ac39d7..000000000 --- a/packages/n4s/src/plugins/schema/schemaTypes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LazyRuleRunners } from 'genEnforceLazy'; - -export interface ShapeObject - extends Record, - Record {} diff --git a/packages/n4s/src/plugins/schema/shape.ts b/packages/n4s/src/plugins/schema/shape.ts deleted file mode 100644 index aa2dd4fdb..000000000 --- a/packages/n4s/src/plugins/schema/shape.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { hasOwnProperty } from 'vest-utils'; - -import { loose } from 'loose'; -import type { RuleDetailedResult } from 'ruleReturn'; -import * as ruleReturn from 'ruleReturn'; -import type { ShapeObject } from 'schemaTypes'; - -export function shape( - inputObject: Record, - shapeObject: ShapeObject, -): RuleDetailedResult { - const baseRes = loose(inputObject, shapeObject); - if (!baseRes.pass) { - return baseRes; - } - for (const key in inputObject) { - if (!hasOwnProperty(shapeObject, key)) { - return ruleReturn.failing(); - } - } - - return ruleReturn.passing(); -} diff --git a/packages/n4s/src/ruleResult.ts b/packages/n4s/src/ruleResult.ts new file mode 100644 index 000000000..b641b1dce --- /dev/null +++ b/packages/n4s/src/ruleResult.ts @@ -0,0 +1,47 @@ +import { dynamicValue, invariant, isNullish, StringObject } from 'vest-utils'; +import type { Stringable } from 'vest-utils'; + +import * as booleanRules from 'booleanRules'; + +export type RuleValue = unknown; +export type Args = any[]; +export type RuleDetailedResult = { pass: boolean; message?: Stringable }; + +export function enforceMessage( + ruleName: string, + transformedResult: RuleDetailedResult, + value: RuleValue, + customMessage?: string, +) { + if (!isNullish(customMessage)) return StringObject(customMessage); + if (isNullish(transformedResult.message)) { + return `enforce/${ruleName} failed with ${JSON.stringify(value)}`; + } + return StringObject(transformedResult.message); +} + +export function transformResult( + result: any, + ruleName: string, + value: RuleValue, + ...args: Args +): RuleDetailedResult { + validateResult(result); + + if (booleanRules.isBoolean(result)) { + return { pass: result }; + } + + return { + pass: !!result.pass, + message: dynamicValue(result.message, ruleName, value, ...args), + }; +} + +export function validateResult(result: any): void { + invariant( + booleanRules.isBoolean(result) || + (result && booleanRules.isBoolean(result.pass)), + 'Incorrect return value for rule: ' + JSON.stringify(result), + ); +} diff --git a/packages/n4s/src/rules/RuleInstanceBuilder.ts b/packages/n4s/src/rules/RuleInstanceBuilder.ts new file mode 100644 index 000000000..c69671aa8 --- /dev/null +++ b/packages/n4s/src/rules/RuleInstanceBuilder.ts @@ -0,0 +1,29 @@ +import type { DropFirst } from 'vest-utils'; + +import type { RuleInstance } from 'RuleInstance'; + +/** + * Generic type utility to build RuleInstance interfaces with chaining methods. + * Eliminates repetitive interface definitions across rule type files. + * + * @template TValue - The value type being validated + * @template TArgs - Tuple of arguments for the RuleInstance + * @template TRules - Record of rule functions available for this type + */ +export type BuildRuleInstance< + TValue, + TArgs extends [any, ...any[]], + TRules extends Record any>, +> = RuleInstance & { + [K in keyof TRules]: ( + ...args: DropFirst> + ) => BuildRuleInstance; +}; + +/** + * Helper type to extract rule functions from a module exports object. + * Filters out non-function exports and type-only exports. + */ +export type ExtractRuleFunctions = { + [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K]; +}; diff --git a/packages/n4s/src/rules/__tests__/compoundAndSchemaRuleTypes.test.ts b/packages/n4s/src/rules/__tests__/compoundAndSchemaRuleTypes.test.ts new file mode 100644 index 000000000..c7478e67b --- /dev/null +++ b/packages/n4s/src/rules/__tests__/compoundAndSchemaRuleTypes.test.ts @@ -0,0 +1,194 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars, @typescript-eslint/ban-ts-comment */ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +// This test suite verifies that compound and schema rule types are properly defined +// and can be used for type inference via the .infer property + +describe('Compound and Schema Rule Types', () => { + it('should properly type allOf rules', () => { + const rule = enforce.allOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(0), + ); + type InferredType = typeof rule.infer; + + const value: InferredType = 5; + void value; + + // Type test: string is not assignable to number + const badValue: InferredType = 'test'; + void badValue; + + expect(rule.run(5).pass).toBe(true); + expect(rule.run(-1).pass).toBe(false); + }); + + it('should properly type anyOf rules', () => { + const rule = enforce.anyOf(enforce.isString(), enforce.isNumber()); + type InferredType = typeof rule.infer; + + const str: InferredType = 'test'; + const num: InferredType = 123; + void str; + void num; + + // Type test: boolean is not string | number + const badValue: InferredType = true; + void badValue; + + expect(rule.run('test').pass).toBe(true); + expect(rule.run(123).pass).toBe(true); + expect(rule.run(true).pass).toBe(false); + }); + + it('should properly type noneOf rules', () => { + const rule = enforce.noneOf(enforce.isString()); + type InferredType = typeof rule.infer; + + const value: InferredType = 'test'; + void value; + + expect(rule.run(123).pass).toBe(true); + expect(rule.run('test').pass).toBe(false); + }); + + it('should properly type oneOf rules', () => { + const rule = enforce.oneOf( + enforce.isString(), + enforce.isNumber(), + enforce.isBoolean(), + ); + type InferredType = typeof rule.infer; + + const str: InferredType = 'test'; + const num: InferredType = 123; + const bool: InferredType = true; + void str; + void num; + void bool; + + expect(rule.run('test').pass).toBe(true); + expect(rule.run(123).pass).toBe(true); + }); + + it('should properly type optional rules', () => { + const rule = enforce.optional(enforce.isString()); + type InferredType = typeof rule.infer; + + const str: InferredType = 'test'; + const undef: InferredType = undefined; + const nul: InferredType = null; + void str; + void undef; + void nul; + + // Type test: number is not string | undefined | null + const badValue: InferredType = 123; + void badValue; + + expect(rule.run('test').pass).toBe(true); + expect(rule.run(undefined).pass).toBe(true); + expect(rule.run(null).pass).toBe(true); + expect(rule.run(123).pass).toBe(false); + }); + + it('should properly type isArrayOf rules', () => { + const rule = enforce.isArrayOf(enforce.isString()); + type InferredType = typeof rule.infer; + + const arr: InferredType = ['a', 'b', 'c']; + void arr; + + // Type test: number[] is not string[] + const badArr: InferredType = [1, 2, 3]; + void badArr; + + expect(rule.run(['a', 'b']).pass).toBe(true); + expect(rule.run([1, 2]).pass).toBe(false); + }); + + it('should properly type shape rules', () => { + const rule = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + type InferredType = typeof rule.infer; + + const obj: InferredType = { name: 'John', age: 30 }; + void obj; + + // Type test: age must be number + const badObj: InferredType = { name: 'John', age: 'thirty' }; + void badObj; + + expect(rule.run({ name: 'John', age: 30 }).pass).toBe(true); + expect(rule.run({ name: 'John', age: 'thirty' }).pass).toBe(false); + }); + + it('should properly type loose rules', () => { + const rule = enforce.loose({ + name: enforce.isString(), + }); + type InferredType = typeof rule.infer; + + const obj: InferredType = { name: 'John', extraProp: true }; + void obj; + + expect(rule.run({ name: 'John', extraProp: true }).pass).toBe(true); + }); + + it('should properly type partial rules', () => { + const rule = enforce.partial({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + type InferredType = typeof rule.infer; + + const obj1: InferredType = { name: 'John' }; + const obj2: InferredType = { age: 30 }; + const obj3: InferredType = {}; + void obj1; + void obj2; + void obj3; + + expect(rule.run({ name: 'John' }).pass).toBe(true); + expect(rule.run({ age: 30 }).pass).toBe(true); + expect(rule.run({}).pass).toBe(true); + }); + + it('should work with complex nested compositions', () => { + const userRule = enforce.shape({ + id: enforce.isNumber(), + profile: enforce.shape({ + name: enforce.isString(), + email: enforce.optional(enforce.isString()), + }), + tags: enforce.optional(enforce.isArrayOf(enforce.isString())), + }); + + type User = typeof userRule.infer; + + const validUser: User = { + id: 1, + profile: { + name: 'John', + email: 'john@example.com', + }, + tags: ['developer', 'admin'], + }; + void validUser; + + expect( + userRule.run({ + id: 1, + profile: { + name: 'John', + email: 'john@example.com', + }, + tags: ['developer', 'admin'], + }).pass, + ).toBe(true); + }); +}); diff --git a/packages/n4s/src/rules/__tests__/endsWith.test.ts b/packages/n4s/src/rules/__tests__/endsWith.test.ts deleted file mode 100644 index bc854f861..000000000 --- a/packages/n4s/src/rules/__tests__/endsWith.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { endsWith } from 'endsWith'; - -describe('Tests isArray rule', () => { - const word = 'meow'; - const totallyDifferentWord = 'lorem'; - it('Should return true for the same word', () => { - expect(endsWith(word, word)).toBe(true); - }); - - it('Should return true for a suffix', () => { - expect(endsWith(word, word.substring(word.length / 2, word.length))).toBe( - true, - ); - }); - - it('Should return true for empty suffix', () => { - expect(endsWith(word, '')).toBe(true); - }); - - it('Should return false for a wrong suffix', () => { - expect(endsWith(word, word.substring(0, word.length - 1))).toBe(false); - }); - - it('Should return false for a suffix which is a totally different word', () => { - expect(endsWith(word, totallyDifferentWord)).toBe(false); - }); - - it('Should return false for a suffix longer than the word', () => { - expect(endsWith(word, word.repeat(2))).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/equals.test.ts b/packages/n4s/src/rules/__tests__/equals.test.ts deleted file mode 100644 index fd7d1ef9e..000000000 --- a/packages/n4s/src/rules/__tests__/equals.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { sample } from 'lodash'; -import { describe, it, expect } from 'vitest'; - -import { equals } from 'equals'; - -const VALUES = [ - faker.lorem.word(), - faker.number.int(), - { [faker.lorem.slug()]: faker.lorem.word() }, - [faker.number.int()], - faker.datatype.boolean(), -]; - -const LOOSE_PAIRS = [ - [1, '1'], - [1, true], - [false, 0], - [undefined, null], -]; - -describe('Equals rule', () => { - it('Should return true for same value', () => { - VALUES.forEach(value => expect(equals(value, value)).toBe(true)); - }); - - it('Should return true for same different value', () => { - VALUES.forEach(value => { - let sampled = value; - - // consistently produce a different value - while (sampled === value) { - // @ts-expect-error - testing different types of values - sampled = sample(VALUES); - } - - expect(equals(value, sampled)).toBe(false); - }); - }); - - it('Should treat loose equality as false', () => { - LOOSE_PAIRS.forEach(pair => expect(equals(pair[0], pair[1])).toBe(false)); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/genRuleChain.test.ts b/packages/n4s/src/rules/__tests__/genRuleChain.test.ts new file mode 100644 index 000000000..8f0b93eee --- /dev/null +++ b/packages/n4s/src/rules/__tests__/genRuleChain.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; + +import { addToChain, registerLazyRule } from 'genRuleChain'; + +describe('genRuleChain', () => { + it('builds a chain with a base predicate and runs it', () => { + const chain = addToChain({}, (v: unknown) => typeof v === 'number'); + + expect(chain.run(1)).toMatchObject({ pass: true }); + expect(chain.run('1')).toMatchObject({ pass: false }); + }); + + it('adds rule methods from the provided rules map', () => { + const rules = { + greaterThan: (value: number, min: number) => value > min, + isEven: (value: number) => value % 2 === 0, + } as const; + + const chain: any = addToChain(rules, (v: unknown) => typeof v === 'number'); + + expect(chain.greaterThan).toBeTypeOf('function'); + expect(chain.isEven).toBeTypeOf('function'); + + expect(chain.greaterThan(3).isEven().run(6)).toMatchObject({ pass: true }); + expect(chain.greaterThan(3).isEven().run(5)).toMatchObject({ pass: false }); + expect(chain.greaterThan(10).run(9)).toMatchObject({ pass: false }); + }); + + it('registers a lazy rule and uses it in the chain', () => { + registerLazyRule('custom', (n: number) => (value: unknown) => { + return typeof value === 'number' && (value as number) % n === 0; + }); + + const chain: any = addToChain({}, (v: unknown) => typeof v === 'number'); + + expect(chain.custom).toBeTypeOf('function'); + expect(chain.custom(3).run(9)).toMatchObject({ pass: true }); + expect(chain.custom(3).run(10)).toMatchObject({ pass: false }); + }); +}); diff --git a/packages/n4s/src/rules/__tests__/greaterThanOrEquals.test.ts b/packages/n4s/src/rules/__tests__/greaterThanOrEquals.test.ts deleted file mode 100644 index cfcb7ca47..000000000 --- a/packages/n4s/src/rules/__tests__/greaterThanOrEquals.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { describe, it, expect, beforeEach } from 'vitest'; - -import { greaterThanOrEquals } from 'greaterThanOrEquals'; - -describe('Tests greaterThanOrEquals rule', () => { - describe('Arguments are numbers', () => { - let arg0: number; - beforeEach(() => { - arg0 = faker.number.int(); - }); - - describe('When first argument is larger', () => { - it('Should return true', () => { - expect(greaterThanOrEquals(arg0, arg0 - 1)).toBe(true); - }); - }); - - describe('When first argument is smaller', () => { - it('Should return true', () => { - expect(greaterThanOrEquals(arg0, arg0 + 1)).toBe(false); - }); - }); - - describe('When values are equal', () => { - it('Should return true', () => { - expect(greaterThanOrEquals(arg0, arg0)).toBe(true); - }); - }); - }); - - describe('Arguments are numeric strings', () => { - describe('When first argument is larger', () => { - it('Should return true', () => { - expect(greaterThanOrEquals('10', '9')).toBe(true); - }); - }); - - describe('When first argument is smaller', () => { - it('Should return true', () => { - expect(greaterThanOrEquals('9', '10')).toBe(false); - }); - }); - - describe('When values are equal', () => { - it('Should return true', () => { - expect(greaterThanOrEquals('1000', '1000')).toBe(true); - }); - }); - }); - - describe('Arguments are non numeric', () => { - [faker.lorem.word(), `${faker.number.int()}`.split(''), {}].forEach( - element => { - it('Should return false', () => { - // @ts-expect-error - Testing invalid input - expect(greaterThanOrEquals(element, 0)).toBe(false); - }); - }, - ); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/inside.test.ts b/packages/n4s/src/rules/__tests__/inside.test.ts deleted file mode 100644 index 4ae425092..000000000 --- a/packages/n4s/src/rules/__tests__/inside.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { inside } from 'inside'; - -describe('Inside rule', () => { - it('Should correctly find a string inside an array', () => { - expect(inside("I'm", ["I'm", 'gonna', 'pop', 'some', 'tags'])).toBe(true); - expect(inside('Eric', ['Eric', 'Kenny', 'Kyle', 'Stan'])).toBe(true); - expect(inside('myString', [1, [55], 'myString'])).toBe(true); - }); - - it('Should fail to find a string inside an array in which it does not exist', () => { - expect(inside('going to', ["I'm", 'gonna', 'pop', 'some', 'tags'])).toBe( - false, - ); - }); - - it('Should correctly find a number inside an array', () => { - expect(inside(1, [1, 2, 3])).toBe(true); - expect(inside(42, [43, 44, 45, 46, 42])).toBe(true); - expect(inside(0, [1, [55], 0])).toBe(true); - }); - - it('Should fail to find a number inside an array in which it does not exist', () => { - expect(inside(55, [1, 2, 3])).toBe(false); - }); - - it('Should correctly find a boolean inside an array', () => { - expect(inside(true, [true, false, true, false])).toBe(true); - expect(inside(false, ['true', false])).toBe(true); - }); - - it('Should fail to find a boolean inside an array in which it does not exist', () => { - expect(inside(true, ['true', false])).toBe(false); - expect(inside(false, [true, 'one', 'two'])).toBe(false); - }); - - it('Should fail to find array elements in another array in which they do not exist', () => { - expect(inside(['no', 'treble'], ['all', 'about', 'the', 'bass'])).toBe( - false, - ); - }); - - it('Should fail to find object keys in an array in which they do not exist', () => { - expect(inside(['one', 'two'], ['three', 'four'])).toBe(false); - }); - - it('Should correctly find a string inside another string', () => { - expect(inside('pop', "I'm gonna pop some tags")).toBe(true); - expect(inside('Kenny', 'You Killed Kenny!')).toBe(true); - }); - - it('Should fail to find a string inside another string in which it does not exist', () => { - expect(inside('mugs', "I'm gonna pop some tags")).toBe(false); - }); - - it('Should return false when either values is not an array or string', () => { - // @ts-ignore - expect(inside('pop', 1)).toBe(false); - // @ts-ignore - expect(inside(1, 'pop')).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isBetween.test.ts b/packages/n4s/src/rules/__tests__/isBetween.test.ts deleted file mode 100644 index 4a2cc3598..000000000 --- a/packages/n4s/src/rules/__tests__/isBetween.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isBetween } from 'isBetween'; - -describe('Tests isBetween rule', () => { - it('Should return true for 5 between 0 and 10', () => { - expect(isBetween(5, 0, 10)).toBe(true); - }); - - it('Should return true for 5 between 4 and 6', () => { - expect(isBetween(5, 4, 6)).toBe(true); - }); - - it('Should return true for 5 not between 5 and 6', () => { - expect(isBetween(5, 5, 6)).toBe(true); - }); - - it('Should return true -5 between -5 and -6', () => { - expect(isBetween(-5, -6, -5)).toBe(true); - }); - - it('Should return true for -5 between -1 and -10', () => { - expect(isBetween(-5, -10, -1)).toBe(true); - }); - - it('Should return true for 5 between 5 and 5', () => { - expect(isBetween(5, 5, 5)).toBe(true); - }); - - it('Should return false for bad type for value', () => { - expect(isBetween('string', 5, 10)).toBe(false); - }); - - it('Should return false for bad type for min', () => { - expect(isBetween(5, 'string', 10)).toBe(false); - }); - - it('Should return false for bad type for max', () => { - expect(isBetween(5, 4, 'string')).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isBlank.test.ts b/packages/n4s/src/rules/__tests__/isBlank.test.ts deleted file mode 100644 index 11ea85a18..000000000 --- a/packages/n4s/src/rules/__tests__/isBlank.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isBlank, isNotBlank } from 'isBlank'; - -describe('isBlank', () => { - it('Should return true for a string of white spaces', () => { - expect(isBlank(' ')).toBe(true); - }); - - it('Should return false for a string with at least a non-whitespace', () => { - expect(isBlank('not blank')).toBe(false); - }); - - it('Should return true for undefined', () => { - expect(isBlank(undefined)).toBeTruthy(); - }); - - it('Should return true for null', () => { - expect(isBlank(null)).toBeTruthy(); - }); -}); - -describe('isNotBlank', () => { - it('Should return false for a string of white spaces', () => { - expect(isNotBlank(' ')).toBe(false); - }); - - it('Should return true for a string with at least a non-whitespace', () => { - expect(isNotBlank('not blank')).toBe(true); - }); - - it('Should return false for undefined', () => { - expect(isNotBlank(undefined)).toBeFalsy(); - }); - - it('Should return false for null', () => { - expect(isNotBlank(null)).toBeFalsy(); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isBoolean.test.ts b/packages/n4s/src/rules/__tests__/isBoolean.test.ts deleted file mode 100644 index 8e32b7520..000000000 --- a/packages/n4s/src/rules/__tests__/isBoolean.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; - -describe('isBoolean', () => { - it('Should pass for a boolean value', () => { - enforce(true).isBoolean(); - enforce(false).isBoolean(); - }); - - it('Should fail for a non boolean value', () => { - expect(() => enforce('true').isBoolean()).toThrow(); - }); -}); - -describe('isNotBoolean', () => { - it('Should pass for a non boolean value', () => { - enforce('true').isNotBoolean(); - enforce([false]).isNotBoolean(); - }); - - it('Should fail for a boolean value', () => { - expect(() => enforce(true).isNotBoolean()).toThrow(); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isEven.test.ts b/packages/n4s/src/rules/__tests__/isEven.test.ts deleted file mode 100644 index bc30d0efb..000000000 --- a/packages/n4s/src/rules/__tests__/isEven.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { describe, it, expect, beforeAll } from 'vitest'; - -import { isEven } from 'isEven'; - -describe('Tests isEven rule', () => { - describe('When value is an even number', () => { - const evenNumbers: number[] = []; - - beforeAll(() => { - let counter = 0; - while (evenNumbers.length < 100) { - evenNumbers.push(counter); - counter += 2; - } - }); - - it('Should return true', () => { - evenNumbers.forEach(number => { - expect(isEven(number)).toBe(true); - }); - }); - - describe('When value is a numeric string', () => { - it('Should return true', () => { - evenNumbers.forEach(number => { - expect(isEven(number.toString())).toBe(true); - }); - }); - }); - - describe('When value is negative', () => { - it('Should return true', () => { - evenNumbers.forEach(number => { - expect(isEven(-number)).toBe(true); - }); - }); - }); - }); - - describe('When value is an odd number', () => { - const oddNumbers: number[] = []; - - beforeAll(() => { - let counter = 1; - while (oddNumbers.length < 100) { - oddNumbers.push(counter); - counter += 2; - } - }); - - it('Should return false', () => { - oddNumbers.forEach((number: number) => { - expect(isEven(number)).toBe(false); - expect(isEven(-number)).toBe(false); - expect(isEven(number.toString())).toBe(false); - }); - }); - }); - - describe('When value is non numeric', () => { - it('Should return false', () => { - [ - faker.lorem.word(), - [], - new Function(), - new Object(), - 'withNumber2', - '2hasNumber', - ].forEach(value => { - expect(isEven(value)).toBe(false); - }); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isKeyOf.test.ts b/packages/n4s/src/rules/__tests__/isKeyOf.test.ts deleted file mode 100644 index febf89bd9..000000000 --- a/packages/n4s/src/rules/__tests__/isKeyOf.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import { isKeyOf, isNotKeyOf } from 'isKeyOf'; - -const FRUITES = { - apples: 5, - bananas: 2, - cantelopes: 0, -}; - -const TOP_GROSSING_MOVIES = { - 1976: 'Rocky', - 1988: 'Rain Man', - 2008: 'The Dark Knight', -}; - -const DUMMY_KEY = 'key'; - -describe('Tests isKeyOf rule', () => { - describe('When the key exists in the object', () => { - it('Should return true', () => { - expect(isKeyOf('bananas', FRUITES)).toBe(true); - expect(isKeyOf(1976, TOP_GROSSING_MOVIES)).toBe(true); - }); - - it('Should return true using enforce', () => { - enforce('bananas').isKeyOf(FRUITES); - enforce(1976).isKeyOf(TOP_GROSSING_MOVIES); - }); - }); - - describe('When the key does not exists in the object', () => { - it('Should return false', () => { - expect(isKeyOf('avocado', FRUITES)).toBe(false); - expect(isKeyOf(1999, TOP_GROSSING_MOVIES)).toBe(false); - }); - - it.each([undefined, null, false, true, Object, [], '', Function.prototype])( - 'Should throw when %s is an object', - v => { - expect(() => enforce(DUMMY_KEY).isKeyOf({ v })).toThrow(); - }, - ); - }); -}); - -describe('Tests isNotKeyOf rule', () => { - describe('When the key does not exists in the object', () => { - it('Should return true', () => { - expect(isNotKeyOf('avocado', FRUITES)).toBe(true); - }); - - it('Should return true using enforce', () => { - enforce(1999).isNotKeyOf(TOP_GROSSING_MOVIES); - }); - }); - - describe('When the key exists in the object', () => { - it('Should return false', () => { - expect(isNotKeyOf(1976, TOP_GROSSING_MOVIES)).toBe(false); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isNaN.test.ts b/packages/n4s/src/rules/__tests__/isNaN.test.ts deleted file mode 100644 index 248fa85fd..000000000 --- a/packages/n4s/src/rules/__tests__/isNaN.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import * as NaNRule from 'isNaN'; - -describe('Tests isNaN rule', () => { - it('Should return true for `NaN` value', () => { - expect(NaNRule.isNaN(NaN)).toBe(true); - }); - - it.each([ - undefined, - null, - false, - true, - Object, - Array(0), - '', - ' ', - 0, - 1, - '0', - '1', - ])('Should return false for %s value', v => { - expect(NaNRule.isNaN(v)).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isNegative.test.ts b/packages/n4s/src/rules/__tests__/isNegative.test.ts deleted file mode 100644 index b8502b634..000000000 --- a/packages/n4s/src/rules/__tests__/isNegative.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isNegative } from 'isNegative'; - -describe('Tests isNegative rule', () => { - it('Should return false for zero', () => { - expect(isNegative(0)).toBe(false); - }); - describe('When argument is a negative number', () => { - it('Should return true for negative number', () => { - expect(isNegative(-1)).toBe(true); - }); - it('should return true for negative desimal number', () => { - expect(isNegative(-1.1)).toBe(true); - }); - it('should return true for negative string number', () => { - expect(isNegative('-1')).toBe(true); - }); - it('should return true for negative decimal string number', () => { - expect(isNegative('-1.10')).toBe(true); - }); - }); - describe('When argument is a positive number', () => { - it('should return false for positive number', () => { - expect(isNegative(10)).toBe(false); - }); - it('should return false for positive desimal number', () => { - expect(isNegative(10.1)).toBe(false); - }); - it('should return false for positive string number', () => { - expect(isNegative('10')).toBe(false); - }); - }); - - describe('When argument is undefined or null or string', () => { - it('should return false for undefined value', () => { - // @ts-expect-error - testing bad usage - expect(isNegative()).toBe(false); - }); - it('should return false for null value', () => { - // @ts-expect-error - testing bad usage - expect(isNegative(null)).toBe(false); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isNullish.test.ts b/packages/n4s/src/rules/__tests__/isNullish.test.ts deleted file mode 100644 index 3d0117cc4..000000000 --- a/packages/n4s/src/rules/__tests__/isNullish.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'n4s'; - -describe('enforce.isNullish', () => { - it('Should return true for `null` value', () => { - expect(enforce.isNullish().test(null)).toBe(true); - }); - - it('Should return true for `undefined` value', () => { - expect(enforce.isNullish().test(undefined)).toBe(true); - }); - - it.each([ - NaN, - false, - true, - Object, - Array(0), - '', - ' ', - 0, - 1, - '0', - '1', - Function.prototype, - ])('Should return false for %s value', v => { - expect(enforce.isNullish().test(v)).toBe(false); - }); -}); - -describe('enforce.isNotNullish', () => { - it('Should return false for `null` value', () => { - expect(enforce.isNotNullish().test(null)).toBe(false); - }); - - it('Should return false for `undefined` value', () => { - expect(enforce.isNotNullish().test(undefined)).toBe(false); - }); - - it.each([ - NaN, - false, - true, - Object, - Array(0), - '', - ' ', - 0, - 1, - '0', - '1', - Function.prototype, - ])('Should return true for %s value', v => { - expect(enforce.isNotNullish().test(v)).toBe(true); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isNumber.test.ts b/packages/n4s/src/rules/__tests__/isNumber.test.ts deleted file mode 100644 index da3d626e0..000000000 --- a/packages/n4s/src/rules/__tests__/isNumber.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isNumber } from 'isNumber'; - -describe('Tests isNumber rule', () => { - it('Should return true for a number', () => { - expect(isNumber(42)).toBe(true); - }); - - it('Should return true for a NaN', () => { - expect(isNumber(NaN)).toBe(true); - }); - - it('Should return false a string', () => { - expect(isNumber('1')).toBe(false); - }); - - it('Should return false an array', () => { - expect(isNumber([1, 2, 3])).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isOdd.test.ts b/packages/n4s/src/rules/__tests__/isOdd.test.ts deleted file mode 100644 index a80a57a8c..000000000 --- a/packages/n4s/src/rules/__tests__/isOdd.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { describe, it, expect } from 'vitest'; - -import { isOdd } from 'isOdd'; - -describe('Tests isOdd rule', () => { - describe('When value is an odd number', () => { - const oddNumbers: number[] = []; - - beforeAll(() => { - let counter = 1; - while (oddNumbers.length < 100) { - oddNumbers.push(counter); - counter += 2; - } - }); - - it('Should return true', () => { - oddNumbers.forEach(number => { - expect(isOdd(number)).toBe(true); - }); - }); - - describe('When value is a numeric string', () => { - it('Should return true', () => { - oddNumbers.forEach(number => { - expect(isOdd(number.toString())).toBe(true); - }); - }); - }); - - describe('When value is negative', () => { - it('Should return true', () => { - oddNumbers.forEach(number => { - expect(isOdd(-number)).toBe(true); - }); - }); - }); - }); - - describe('When value is an even number', () => { - const evenNumbers: number[] = []; - - beforeAll(() => { - let counter = 0; - while (evenNumbers.length < 100) { - evenNumbers.push(counter); - counter += 2; - } - }); - - it('Should return false', () => { - evenNumbers.forEach((number: number) => { - expect(isOdd(number)).toBe(false); - expect(isOdd(-number)).toBe(false); - expect(isOdd(number.toString())).toBe(false); - }); - }); - }); - - describe('When value is non numeric', () => { - it('Should return false', () => { - [ - faker.lorem.word(), - [], - new Function(), - new Object(), - 'withNumber1', - '1hasNumber', - ].forEach(value => { - expect(isOdd(value)).toBe(false); - }); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isPositive.test.ts b/packages/n4s/src/rules/__tests__/isPositive.test.ts deleted file mode 100644 index f04fbc0ee..000000000 --- a/packages/n4s/src/rules/__tests__/isPositive.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { enforce } from 'n4s'; - -describe('Test isPositive rule', () => { - it('Shiuld fail for non-numeric values', () => { - expect(() => enforce(false).isPositive()).toThrow(); - expect(() => enforce([]).isPositive()).toThrow(); - expect(() => enforce({}).isPositive()).toThrow(); - }); - - it('Should fail for negative values', () => { - expect(() => enforce(-1).isPositive()).toThrow(); - expect(() => enforce(-1.1).isPositive()).toThrow(); - expect(() => enforce('-1').isPositive()).toThrow(); - expect(() => enforce('-1.10').isPositive()).toThrow(); - }); - - it('Should pass for positive values', () => { - enforce(10).isPositive(); - enforce(10.1).isPositive(); - enforce('10').isPositive(); - enforce('10.10').isPositive(); - }); - - it('Should fail for zero', () => { - expect(() => enforce(0).isPositive()).toThrow(); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isString.test.ts b/packages/n4s/src/rules/__tests__/isString.test.ts deleted file mode 100644 index b6767fb9a..000000000 --- a/packages/n4s/src/rules/__tests__/isString.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { enforce } from 'n4s'; - -describe('Tests isString rule', () => { - it('Should fail for non-string values', () => { - expect(() => enforce(42).isString()).toThrow(); - expect(() => enforce([]).isString()).toThrow(); - }); - - it('Should pass for string values', () => { - enforce('I love you').isString(); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/isTruthy.test.ts b/packages/n4s/src/rules/__tests__/isTruthy.test.ts deleted file mode 100644 index 91ebdc5f1..000000000 --- a/packages/n4s/src/rules/__tests__/isTruthy.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isTruthy } from 'isTruthy'; - -describe('Tests isTruthy rule', () => { - const values = [ - [0, false, 0], - [null, false, 'null'], - [undefined, false, 'undefined'], - [false, false, 'false'], - [{}, true, '{}'], - [[], true, '[]'], - ['', false, '""'], - [1, true, 1], - ['hi', true, 'hi'], - [new Date(), true, 'new Date()'], - [() => true, true, '() => true'], - [[1], true, '[1]'], - ]; - - for (const set of values) { - const value = set[0], - expected = set[1], - name = set[2]; - - it(`The value ${name} with type ${typeof value} Should return ${expected}`, () => { - expect(isTruthy(value)).toBe(expected); - }); - } -}); diff --git a/packages/n4s/src/rules/__tests__/isValueOf.test.ts b/packages/n4s/src/rules/__tests__/isValueOf.test.ts deleted file mode 100644 index c9d71b9bb..000000000 --- a/packages/n4s/src/rules/__tests__/isValueOf.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { enforce } from 'enforce'; -import { isValueOf, isNotValueOf } from 'isValueOf'; - -const testObject = { - a: 'Bravo', - b: false, - c: 42, -}; - -const testObject2 = { - d: { - greet: 'hello', - }, - e: null, - f: undefined, -}; - -describe('isValueOf tests', () => { - describe('When the value exists in the object', () => { - it('Should return true using enforce', () => { - enforce('Bravo').isValueOf(testObject); - enforce(42).isValueOf(testObject); - enforce(false).isValueOf(testObject); - enforce(null).isValueOf(testObject2); - enforce(undefined).isValueOf(testObject2); - }); - - it('Should return true', () => { - expect(isValueOf('Bravo', testObject)).toBe(true); - expect(isValueOf(42, testObject)).toBe(true); - expect(isValueOf(false, testObject)).toBe(true); - expect(isValueOf(null, testObject2)).toBe(true); - expect(isValueOf(undefined, testObject2)).toBe(true); - }); - }); - describe('When the value does not exist in the object', () => { - it('Should return false', () => { - expect(isValueOf('Alpha', testObject)).toBe(false); - expect(isValueOf(1, testObject)).toBe(false); - expect(isValueOf(true, testObject)).toBe(false); - expect(isValueOf(null, testObject)).toBe(false); - expect(isValueOf({ greet: 'hello' }, testObject2)).toBe(false); - }); - - it('Should throw using enforce', () => { - expect(() => enforce('Alpha').isValueOf(testObject)).toThrow(); - expect(() => enforce(null).isValueOf(testObject)).toThrow(); - }); - }); - - describe('When object to check is nullish', () => { - it('Should return false', () => { - expect(isValueOf('Bravo', null)).toBe(false); - expect(isValueOf('Bravo', undefined)).toBe(false); - }); - }); -}); - -describe('isNotValueOf tests', () => { - describe('When the value does not exist in the object', () => { - it('Should return true using enforce', () => { - enforce('Delta').isNotValueOf(testObject); - }); - it('Should return true', () => { - expect(isNotValueOf('Alpha', testObject)).toBe(true); - expect(isNotValueOf(1, testObject)).toBe(true); - expect(isNotValueOf(true, testObject)).toBe(true); - expect(isNotValueOf(null, testObject)).toBe(true); - }); - }); - describe('When the value exists in the object', () => { - it('Should return false', () => { - expect(isNotValueOf('Bravo', testObject)).toBe(false); - }); - it('Should throw using enforce', () => { - expect(() => enforce(42).isNotValueOf(testObject)).toThrow(); - }); - }); - - describe('When object to check is nullish', () => { - it('Should return true', () => { - expect(isNotValueOf('Bravo', null)).toBe(true); - expect(isNotValueOf('Bravo', undefined)).toBe(true); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/lessThan.test.ts b/packages/n4s/src/rules/__tests__/lessThan.test.ts deleted file mode 100644 index eb7a4e099..000000000 --- a/packages/n4s/src/rules/__tests__/lessThan.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { describe, it, expect, beforeEach } from 'vitest'; - -import { lessThan } from 'lessThan'; - -describe('Tests lessThan rule', () => { - describe('Arguments are numbers', () => { - let arg0: number = 0; - beforeEach(() => { - arg0 = faker.number.int(); - }); - - describe('When first argument is larger', () => { - it('Should return true', () => { - expect(lessThan(arg0, arg0 - 1)).toBe(false); - }); - }); - - describe('When first argument is smaller', () => { - it('Should return true', () => { - expect(lessThan(arg0, arg0 + 1)).toBe(true); - }); - }); - - describe('When values are equal', () => { - it('Should return false', () => { - expect(lessThan(arg0, arg0)).toBe(false); - }); - }); - }); - - describe('Arguments are numeric strings', () => { - let arg0: string = '0'; - beforeEach(() => { - arg0 = faker.number.int().toString(); - }); - describe('When first argument is larger', () => { - it('Should return true', () => { - expect(lessThan('10', '9')).toBe(false); - }); - }); - - describe('When first argument is smaller', () => { - it('Should return true', () => { - expect(lessThan('9', '10')).toBe(true); - }); - }); - - describe('When values are equal', () => { - it('Should return false', () => { - expect(lessThan(arg0, arg0)).toBe(false); - }); - }); - }); - - describe('Arguments are non numeric', () => { - [faker.lorem.word(), `${faker.number.int()}`.split(''), {}].forEach( - element => { - it('Should return false', () => { - // @ts-expect-error - Testing invalid input - expect(lessThan(element, 0)).toBe(false); - }); - }, - ); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/lessThanOrEquals.test.ts b/packages/n4s/src/rules/__tests__/lessThanOrEquals.test.ts deleted file mode 100644 index ec01f8c6f..000000000 --- a/packages/n4s/src/rules/__tests__/lessThanOrEquals.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { describe, it, expect, beforeEach } from 'vitest'; - -import { lessThanOrEquals } from 'lessThanOrEquals'; - -describe('Tests lessThanOrEquals rule', () => { - describe('Arguments are numbers', () => { - let arg0: number; - beforeEach(() => { - arg0 = faker.number.int(); - }); - - describe('When first argument is larger', () => { - it('Should return true', () => { - expect(lessThanOrEquals(arg0, arg0 - 1)).toBe(false); - }); - }); - - describe('When first argument is smaller', () => { - it('Should return true', () => { - expect(lessThanOrEquals(arg0, arg0 + 1)).toBe(true); - }); - }); - - describe('When values are equal', () => { - it('Should return true', () => { - expect(lessThanOrEquals(arg0, arg0)).toBe(true); - }); - }); - }); - - describe('Arguments are numeric strings', () => { - let arg0: string; - beforeEach(() => { - arg0 = faker.number.int().toString(); - }); - describe('When first argument is larger', () => { - it('Should return true', () => { - expect(lessThanOrEquals('10', '9')).toBe(false); - }); - }); - - describe('When first argument is smaller', () => { - it('Should return true', () => { - expect(lessThanOrEquals('9', '10')).toBe(true); - }); - }); - - describe('When values are equal', () => { - it('Should return true', () => { - expect(lessThanOrEquals(arg0, arg0)).toBe(true); - }); - }); - }); - - describe('Arguments are non numeric', () => { - [faker.lorem.word(), `${faker.number.int()}`.split(''), {}].forEach( - element => { - it('Should return false', () => { - // @ts-expect-error - Testing invalid input - expect(lessThanOrEquals(element, 0)).toBe(false); - }); - }, - ); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/longerThanOrEquals.test.ts b/packages/n4s/src/rules/__tests__/longerThanOrEquals.test.ts deleted file mode 100644 index d8d9699e2..000000000 --- a/packages/n4s/src/rules/__tests__/longerThanOrEquals.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { describe, it, expect } from 'vitest'; - -import { longerThanOrEquals } from 'longerThanOrEquals'; - -describe('Tests longerThanOrEquals rule', () => { - const length = 10; - const word = faker.lorem.word(); - const boolean = faker.datatype.boolean(); - - describe('First argument is array or string', () => { - describe('When first argument is longer', () => { - it('Should return true for an array longer than length', () => { - expect(longerThanOrEquals(new Array(length), length - 1)).toBe(true); - }); - - it('Should return true for a string longer than word length', () => { - expect(longerThanOrEquals(word, word.length - 1)).toBe(true); - }); - }); - - describe('When first argument is equal to a given value', () => { - it('Should return true for an array equal to length', () => { - expect(longerThanOrEquals(new Array(length), length)).toBe(true); - }); - - it('Should return true for a string equal to word length', () => { - expect(longerThanOrEquals(word, word.length)).toBe(true); - }); - }); - - describe('When first argument is shorter', () => { - it('Should return false for an array shorter than length', () => { - expect(longerThanOrEquals(new Array(length), length + 1)).toBe(false); - }); - - it('Should return false for a string shorter than word length', () => { - expect(longerThanOrEquals(word, word.length + 1)).toBe(false); - }); - }); - }); - - describe("First argument isn't array or string", () => { - it('Should throw error', () => { - // @ts-expect-error - testing invalid input - expect(() => longerThanOrEquals(undefined, 0)).toThrow(TypeError); - }); - - it('Should return false for number argument', () => { - // @ts-expect-error - testing invalid input - expect(longerThanOrEquals(length, 0)).toBe(false); - }); - - it('Should return false for boolean argument', () => { - // @ts-expect-error - testing invalid input - expect(longerThanOrEquals(boolean, 0)).toBe(false); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/matches.test.ts b/packages/n4s/src/rules/__tests__/matches.test.ts deleted file mode 100644 index 48a495c69..000000000 --- a/packages/n4s/src/rules/__tests__/matches.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { matches } from 'matches'; - -const URL = - /(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.(?=.*[a-z]){1,24}\b([-a-zA-Z0-9@:%_+.~#?&//=()]*)/, - LENGTH = /^[a-zA-Z]{3,7}$/, - NUMBERS = '[0-9]'; - -describe('Tests matches rule', () => { - it('Should return true for a matching regex', () => { - expect(matches('https://google.com', URL)).toBe(true); - expect(matches('github.com', URL)).toBe(true); - expect(matches('ealush', LENGTH)).toBe(true); - }); - - it('Should return false for a non matching regex', () => { - expect(matches('google', URL)).toBe(false); - expect(matches('Minimum1', LENGTH)).toBe(false); - }); - - it('Should convert string to regex and return true', () => { - expect(matches('9675309', NUMBERS)).toBe(true); - expect(matches('Minimum1', NUMBERS)).toBe(true); - }); - - it('Should convert string to regex and return false', () => { - expect(matches('no-match', NUMBERS)).toBe(false); - expect(matches('Minimum', NUMBERS)).toBe(false); - }); - - it('Should return false if a valid RegExp nor a string were given', () => { - // @ts-expect-error - testing bad usage - expect(matches('no-match', {})).toBe(false); - // @ts-expect-error - testing bad usage - expect(matches('no-match')).toBe(false); - // @ts-expect-error - testing bad usage - expect(matches('no-match', null)).toBe(false); - // @ts-expect-error - testing bad usage - expect(matches('no-match', 11)).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/ruleCondition.test.ts b/packages/n4s/src/rules/__tests__/ruleCondition.test.ts deleted file mode 100644 index 3e880e5ea..000000000 --- a/packages/n4s/src/rules/__tests__/ruleCondition.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { enforce } from 'n4s'; -import ruleReturn, { failing, passing } from 'ruleReturn'; - -describe('enforce.condition', () => { - it('Should pass down enforced value to condition as the first argument', () => { - const condition = vi.fn(() => true); - - enforce(1).condition(condition); - expect(condition).toHaveBeenCalledWith(1); - - enforce.condition(condition).run(2); - expect(condition).toHaveBeenCalledWith(2); - }); - - describe('Lazy interface', () => { - it('Should return a failing result if condition is failing', () => { - expect(enforce.condition(() => false).run(1)).toEqual(failing()); - expect(enforce.condition(() => failing()).run(1)).toEqual(failing()); - expect( - enforce.condition(() => ruleReturn(false, 'failure message')).run(1), - ).toEqual(ruleReturn(false, 'failure message')); - }); - - it('Should return a passing result if condition is passing', () => { - expect(enforce.condition(() => true).run(1)).toEqual(passing()); - expect(enforce.condition(() => passing()).run(1)).toEqual(passing()); - expect( - enforce.condition(() => ruleReturn(true, 'success message')).run(1), - ).toEqual(passing()); - }); - }); - - describe('Eager interface', () => { - it('Should throw an error if condition is failing', () => { - expect(() => enforce(1).condition(() => false)).toThrow(); - - expect(() => enforce(1).condition(() => failing())).toThrow(); - - expect(() => - enforce(1).condition(() => ruleReturn(false, 'failure message')), - ).toThrow(); - }); - - it('Should return silently if condition is passing', () => { - expect(() => enforce(1).condition(() => true)).not.toThrow(); - - expect(() => enforce(1).condition(() => passing())).not.toThrow(); - - expect(() => - enforce(1).condition(() => ruleReturn(true, 'success message')), - ).not.toThrow(); - }); - }); - - describe('Error handling', () => { - it('Should fail if not a function', () => { - // @ts-expect-error - testing bad usage - expect(() => enforce().condition('not a function')).toThrow(); - expect(enforce.condition('not a function').run(1)).toEqual(failing()); - }); - - it('Should throw an error if condition returns a non-boolean or a non-ruleReturn', () => { - expect(() => enforce(1).condition(() => 1)).toThrow(); - expect(() => enforce(1).condition(() => undefined)).toThrow(); - expect(() => enforce(1).condition(() => 'not a boolean')).toThrow(); - - // @ts-expect-error - testing bad usage - expect(() => enforce(1).condition(() => ruleReturn())).toThrow(); - expect(() => enforce.condition(() => 1).run(1)).toThrow(); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/rules.test.ts b/packages/n4s/src/rules/__tests__/rules.test.ts deleted file mode 100644 index 58c8bc46d..000000000 --- a/packages/n4s/src/rules/__tests__/rules.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import rules from 'rules'; -import { describe, it, expect } from 'vitest'; - -describe('Tests enforce rules API', () => { - it('Should expose all enforce rules', () => { - Object.keys(rules()).forEach(rule => { - // @ts-ignore - dynamically checking all built-in rules - expect(rules()[rule]).toBeInstanceOf(Function); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/shorterThan.test.ts b/packages/n4s/src/rules/__tests__/shorterThan.test.ts deleted file mode 100644 index d4a76e934..000000000 --- a/packages/n4s/src/rules/__tests__/shorterThan.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { shorterThan } from 'shorterThan'; -import { describe, it, expect } from 'vitest'; - -describe('Tests shorterThan rule', () => { - const length = 10; - const word = faker.lorem.word(); - const boolean = faker.datatype.boolean(); - - describe('First argument is array or string', () => { - describe('When first argument is shorter', () => { - it('Should return true for an array shorter than length', () => { - expect(shorterThan(new Array(length), length + 1)).toBe(true); - }); - - it('Should return true for a string shorter than word length', () => { - expect(shorterThan(word, word.length + 1)).toBe(true); - }); - }); - - describe('When first argument is longer', () => { - it('Should return false for an array longer than length', () => { - expect(shorterThan(new Array(length), length - 1)).toBe(false); - }); - - it('Should return false for a string longer than word length', () => { - expect(shorterThan(word, word.length - 1)).toBe(false); - }); - }); - - describe('When first argument is equal to a given value', () => { - it('Should return false for an array equal to length', () => { - expect(shorterThan(new Array(length), length)).toBe(false); - }); - - it('Should return false for a string equal to word length', () => { - expect(shorterThan(word, word.length)).toBe(false); - }); - }); - }); - - describe("First argument isn't array or string", () => { - it('Should throw error', () => { - // @ts-expect-error - testing wrong input - expect(() => shorterThan(undefined, 0)).toThrow(TypeError); - }); - - it('Should return false for number argument', () => { - // @ts-expect-error - testing wrong input - expect(shorterThan(length, 0)).toBe(false); - }); - - it('Should return false for boolean argument', () => { - // @ts-expect-error - testing wrong input - expect(shorterThan(boolean, 0)).toBe(false); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/shorterThanOrEquals.test.ts b/packages/n4s/src/rules/__tests__/shorterThanOrEquals.test.ts deleted file mode 100644 index bfc48df18..000000000 --- a/packages/n4s/src/rules/__tests__/shorterThanOrEquals.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { shorterThanOrEquals } from 'shorterThanOrEquals'; -import { describe, it, expect } from 'vitest'; - -describe('Tests shorterThanOrEquals rule', () => { - const length = 10; - const word = faker.lorem.word(); - const boolean = faker.datatype.boolean(); - - describe('First argument is array or string', () => { - describe('When first argument is shorter', () => { - it('Should return true for an array shorter than length', () => { - expect(shorterThanOrEquals(new Array(length), length + 1)).toBe(true); - }); - - it('Should return true for a string shorter than word length', () => { - expect(shorterThanOrEquals(word, word.length + 1)).toBe(true); - }); - }); - - describe('When first argument is equal to a given value', () => { - it('Should return true for an array equal to length', () => { - expect(shorterThanOrEquals(new Array(length), length)).toBe(true); - }); - - it('Should return true for a string equal to word length', () => { - expect(shorterThanOrEquals(word, word.length)).toBe(true); - }); - }); - - describe('When first argument is longer', () => { - it('Should return false for an array longer than length', () => { - expect(shorterThanOrEquals(new Array(length), length - 1)).toBe(false); - }); - - it('Should return false for a string longer than length', () => { - expect(shorterThanOrEquals(word, word.length - 1)).toBe(false); - }); - }); - }); - - describe("First argument isn't array or string", () => { - it('Should throw error', () => { - // @ts-expect-error - testing wrong input - expect(() => shorterThanOrEquals(undefined, 0)).toThrow(TypeError); - }); - - it('Should return false for number argument', () => { - // @ts-expect-error - testing wrong input - expect(shorterThanOrEquals(length, 0)).toBe(false); - }); - - it('Should return false for boolean argument', () => { - // @ts-expect-error - testing wrong input - expect(shorterThanOrEquals(boolean, 0)).toBe(false); - }); - }); -}); diff --git a/packages/n4s/src/rules/__tests__/startsWith.test.ts b/packages/n4s/src/rules/__tests__/startsWith.test.ts deleted file mode 100644 index ab964bc94..000000000 --- a/packages/n4s/src/rules/__tests__/startsWith.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { startsWith } from 'startsWith'; -import { describe, it, expect } from 'vitest'; - -describe('Tests isArray rule', () => { - const word = 'meow'; - const totallyDifferentWord = 'lorem'; - it('Should return true for the same word', () => { - expect(startsWith(word, word)).toBe(true); - }); - - it('Should return true for a prefix', () => { - expect(startsWith(word, word.substring(0, word.length / 2))).toBe(true); - }); - - it('Should return true for empty prefix', () => { - expect(startsWith(word, '')).toBe(true); - }); - - it('Should return false for a wrong prefix', () => { - expect(startsWith(word, word.substring(1, word.length - 1))).toBe(false); - }); - - it('Should return false for a prefix which is a totally different word', () => { - expect(startsWith(word, totallyDifferentWord)).toBe(false); - }); - - it('Should return false for a prefix longer than the word', () => { - expect(startsWith(word, word.repeat(2))).toBe(false); - }); -}); diff --git a/packages/n4s/src/rules/array/__tests__/arrayRules.test.ts b/packages/n4s/src/rules/array/__tests__/arrayRules.test.ts new file mode 100644 index 000000000..4305bfcad --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/arrayRules.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('arrayRules', () => { + it('length predicates', () => { + expect(enforce.isArray().minLength(2).run(['a', 'b']).pass).toBe( + true, + ); + expect(enforce.isArray().maxLength(2).run([1, 2]).pass).toBe(true); + expect(enforce.isArray().lengthEquals(3).run([1, 2, 3]).pass).toBe( + true, + ); + expect( + enforce.isArray().lengthNotEquals(2).run([1, 2, 3]).pass, + ).toBe(true); + expect(enforce.isArray().longerThan(2).run([1, 2, 3]).pass).toBe( + true, + ); + expect( + enforce.isArray().longerThanOrEquals(3).run([1, 2, 3]).pass, + ).toBe(true); + expect(enforce.isArray().shorterThan(4).run([1, 2, 3]).pass).toBe( + true, + ); + expect( + enforce.isArray().shorterThanOrEquals(3).run([1, 2, 3]).pass, + ).toBe(true); + }); + + it('includes predicate', () => { + expect(enforce.isArray().includes('x').run(['x', 'y']).pass).toBe( + true, + ); + expect(enforce.isArray().includes('z').run(['x', 'y']).pass).toBe( + false, + ); + }); + + it('fails when not an array', () => { + expect(enforce.isArray().run('not array').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/includes.test.ts b/packages/n4s/src/rules/array/__tests__/includes.test.ts new file mode 100644 index 000000000..6643aa25c --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/includes.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('includes', () => { + it('pass when item is in array', () => { + expect(enforce.isArray().includes(2).run([1, 2]).pass).toBe(true); + expect(enforce.isArray().includes('a').run(['a', 'b']).pass).toBe( + true, + ); + expect(enforce.isArray().includes(1).run([1]).pass).toBe(true); + }); + + it('fails when item is not in array', () => { + expect(enforce.isArray().includes(3).run([1, 2]).pass).toBe(false); + expect(enforce.isArray().includes('c').run(['a', 'b']).pass).toBe( + false, + ); + expect(enforce.isArray().includes(1).run([]).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/isArray.test.ts b/packages/n4s/src/rules/array/__tests__/isArray.test.ts new file mode 100644 index 000000000..ec10830f5 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/isArray.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isArray', () => { + it('pass for empty arrays', () => { + expect(enforce.isArray().run([]).pass).toBe(true); + }); + + it('pass for arrays with elements', () => { + expect(enforce.isArray().run([1, 2]).pass).toBe(true); + expect(enforce.isArray().run(['a', 'b']).pass).toBe(true); + expect(enforce.isArray().run([null, undefined]).pass).toBe(true); + expect(enforce.isArray().run([{}, []]).pass).toBe(true); + }); + + it('fails for array-like objects', () => { + const arrayLike: any = { 0: 'a', 1: 'b', length: 2 }; + expect(enforce.isArray().run(arrayLike).pass).toBe(false); + }); + + it('fails for non-arrays', () => { + const values: any[] = [{}, null, undefined, 0, '', true, 'text', () => {}]; + + values.forEach(value => { + expect(enforce.isArray().run(value).pass).toBe(false); + }); + }); + + describe('chain: minLength', () => { + it('pass when length meets minimum', () => { + expect(enforce.isArray().minLength(1).run([1]).pass).toBe(true); + expect(enforce.isArray().minLength(1).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().minLength(0).run([]).pass).toBe(true); + }); + + it('fails when length is below minimum', () => { + expect(enforce.isArray().minLength(1).run([]).pass).toBe(false); + expect(enforce.isArray().minLength(3).run([1, 2]).pass).toBe( + false, + ); + }); + }); + + describe('chain: maxLength', () => { + it('pass when length is within maximum', () => { + expect(enforce.isArray().maxLength(2).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().maxLength(2).run([1]).pass).toBe(true); + expect(enforce.isArray().maxLength(0).run([]).pass).toBe(true); + }); + + it('fails when length exceeds maximum', () => { + expect(enforce.isArray().maxLength(1).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().maxLength(0).run([1]).pass).toBe(false); + }); + }); + + describe('chain: lengthEquals', () => { + it('pass when length matches exactly', () => { + expect(enforce.isArray().lengthEquals(2).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().lengthEquals(0).run([]).pass).toBe(true); + }); + + it('fails when length differs', () => { + expect(enforce.isArray().lengthEquals(1).run([]).pass).toBe( + false, + ); + expect(enforce.isArray().lengthEquals(1).run([1, 2]).pass).toBe( + false, + ); + }); + }); + + describe('chain: lengthNotEquals', () => { + it('pass when length differs', () => { + expect(enforce.isArray().lengthNotEquals(0).run([1]).pass).toBe( + true, + ); + expect(enforce.isArray().lengthNotEquals(1).run([]).pass).toBe( + true, + ); + }); + + it('fails when length matches', () => { + expect(enforce.isArray().lengthNotEquals(0).run([]).pass).toBe( + false, + ); + expect( + enforce.isArray().lengthNotEquals(2).run([1, 2]).pass, + ).toBe(false); + }); + }); + + describe('chain: includes', () => { + it('pass when item is in array', () => { + expect(enforce.isArray().includes(2).run([1, 2]).pass).toBe(true); + expect(enforce.isArray().includes('a').run(['a', 'b']).pass).toBe( + true, + ); + }); + + it('fails when item is not in array', () => { + expect(enforce.isArray().includes(3).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().includes('c').run(['a', 'b']).pass).toBe( + false, + ); + }); + }); + + describe('chain: inside', () => { + it('pass when all items are in container', () => { + expect(enforce.isArray().inside([1, 2, 3]).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().inside([5]).run([]).pass).toBe(true); + }); + + it('fails when some items are not in container', () => { + expect(enforce.isArray().inside([1, 2]).run([1, 2, 3]).pass).toBe( + false, + ); + expect(enforce.isArray().inside([1]).run([2]).pass).toBe(false); + }); + }); + + describe('chain: notInside', () => { + it('pass when some items are not in container', () => { + expect(enforce.isArray().notInside([3, 4]).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().notInside([1]).run([2, 3]).pass).toBe( + true, + ); + }); + + it('fails when all items are in container', () => { + expect( + enforce.isArray().notInside([1, 2, 3]).run([1, 2]).pass, + ).toBe(false); + }); + }); + + describe('chain: isEmpty', () => { + it('pass for empty arrays', () => { + expect(enforce.isArray().isEmpty().run([]).pass).toBe(true); + }); + + it('fails for non-empty arrays', () => { + expect(enforce.isArray().isEmpty().run([1]).pass).toBe(false); + }); + }); + + describe('chain: isNotEmpty', () => { + it('pass for non-empty arrays', () => { + expect(enforce.isArray().isNotEmpty().run([1]).pass).toBe(true); + }); + + it('fails for empty arrays', () => { + expect(enforce.isArray().isNotEmpty().run([]).pass).toBe(false); + }); + }); + + describe('chain: equals', () => { + it('pass when arrays are the same reference', () => { + const arr = [1, 2, 3]; + expect(enforce.isArray().equals(arr).run(arr).pass).toBe(true); + }); + + it('fails when arrays have same content but different references', () => { + expect(enforce.isArray().equals([1, 2]).run([1, 2]).pass).toBe( + false, + ); + }); + }); + + describe('chain: longerThan', () => { + it('pass when array length is greater', () => { + expect(enforce.isArray().longerThan(1).run([1, 2]).pass).toBe( + true, + ); + }); + + it('fails when array length is equal or less', () => { + expect(enforce.isArray().longerThan(2).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().longerThan(3).run([1, 2]).pass).toBe( + false, + ); + }); + }); + + describe('chain: shorterThan', () => { + it('pass when array length is less', () => { + expect(enforce.isArray().shorterThan(3).run([1, 2]).pass).toBe( + true, + ); + }); + + it('fails when array length is equal or greater', () => { + expect(enforce.isArray().shorterThan(2).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().shorterThan(1).run([1, 2]).pass).toBe( + false, + ); + }); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/isEmpty.test.ts b/packages/n4s/src/rules/array/__tests__/isEmpty.test.ts new file mode 100644 index 000000000..50e2c1ce8 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/isEmpty.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isEmpty', () => { + it('pass for empty arrays', () => { + expect(enforce.isArray().isEmpty().run([]).pass).toBe(true); + expect(enforce.isArray().isEmpty().run([]).pass).toBe(true); + expect(enforce.isArray().isEmpty().run([]).pass).toBe(true); + }); + + it('fails for non-empty arrays', () => { + expect(enforce.isArray().isEmpty().run([1]).pass).toBe(false); + expect(enforce.isArray().isEmpty().run(['a']).pass).toBe(false); + expect(enforce.isArray().isEmpty().run([null]).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/isNotArray.test.ts b/packages/n4s/src/rules/array/__tests__/isNotArray.test.ts new file mode 100644 index 000000000..1e8e9f4a8 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/isNotArray.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotArray', () => { + it('fails for arrays', () => { + expect(enforce.isNotArray().run([]).pass).toBe(false); + expect(enforce.isNotArray().run([1, 2, 3]).pass).toBe(false); + expect(enforce.isNotArray().run([null, undefined]).pass).toBe(false); + }); + + describe('pass for non-array types', () => { + it('pass for objects', () => { + expect(enforce.isNotArray().run({}).pass).toBe(true); + expect(enforce.isNotArray().run({ a: 1 }).pass).toBe(true); + expect(enforce.isNotArray().run({ length: 3 }).pass).toBe(true); + }); + + it('pass for numbers', () => { + expect(enforce.isNotArray().run(0).pass).toBe(true); + expect(enforce.isNotArray().run(42).pass).toBe(true); + expect(enforce.isNotArray().run(-1).pass).toBe(true); + expect(enforce.isNotArray().run(NaN).pass).toBe(true); + }); + + it('pass for strings', () => { + const str: any = 'text'; + const empty: any = ''; + expect(enforce.isNotArray().run(str).pass).toBe(true); + expect(enforce.isNotArray().run(empty).pass).toBe(true); + }); + + it('pass for booleans', () => { + expect(enforce.isNotArray().run(true).pass).toBe(true); + expect(enforce.isNotArray().run(false).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotArray().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotArray().run(value).pass).toBe(true); + }); + + it('pass for functions', () => { + const fn: any = () => {}; + expect(enforce.isNotArray().run(fn).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/isNotEmpty.test.ts b/packages/n4s/src/rules/array/__tests__/isNotEmpty.test.ts new file mode 100644 index 000000000..071ed2564 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/isNotEmpty.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotEmpty', () => { + it('pass for non-empty arrays', () => { + expect(enforce.isArray().isNotEmpty().run([1]).pass).toBe(true); + expect(enforce.isArray().isNotEmpty().run(['a', 'b']).pass).toBe( + true, + ); + expect(enforce.isArray().isNotEmpty().run([null]).pass).toBe(true); + }); + + it('fails for empty arrays', () => { + expect(enforce.isArray().isNotEmpty().run([]).pass).toBe(false); + expect(enforce.isArray().isNotEmpty().run([]).pass).toBe(false); + expect(enforce.isArray().isNotEmpty().run([]).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/lengthEquals.test.ts b/packages/n4s/src/rules/array/__tests__/lengthEquals.test.ts new file mode 100644 index 000000000..b4aa10810 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/lengthEquals.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lengthEquals', () => { + it('pass when length matches exactly', () => { + expect(enforce.isArray().lengthEquals(2).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().lengthEquals(0).run([]).pass).toBe(true); + expect( + enforce.isArray().lengthEquals(3).run(['a', 'b', 'c']).pass, + ).toBe(true); + }); + + it('fails when length differs', () => { + expect(enforce.isArray().lengthEquals(1).run([]).pass).toBe(false); + expect(enforce.isArray().lengthEquals(1).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().lengthEquals(5).run(['a', 'b']).pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/lengthNotEquals.test.ts b/packages/n4s/src/rules/array/__tests__/lengthNotEquals.test.ts new file mode 100644 index 000000000..43f319ef3 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/lengthNotEquals.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lengthNotEquals', () => { + it('pass when length differs', () => { + expect(enforce.isArray().lengthNotEquals(0).run([1]).pass).toBe( + true, + ); + expect(enforce.isArray().lengthNotEquals(1).run([]).pass).toBe( + true, + ); + expect( + enforce.isArray().lengthNotEquals(5).run(['a', 'b']).pass, + ).toBe(true); + }); + + it('fails when length matches', () => { + expect(enforce.isArray().lengthNotEquals(0).run([]).pass).toBe( + false, + ); + expect(enforce.isArray().lengthNotEquals(2).run([1, 2]).pass).toBe( + false, + ); + expect( + enforce.isArray().lengthNotEquals(3).run(['a', 'b', 'c']).pass, + ).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/longerThan.test.ts b/packages/n4s/src/rules/array/__tests__/longerThan.test.ts new file mode 100644 index 000000000..022d674fa --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/longerThan.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('longerThan', () => { + it('pass when array length is greater', () => { + expect(enforce.isArray().longerThan(1).run([1, 2]).pass).toBe(true); + expect(enforce.isArray().longerThan(0).run([1]).pass).toBe(true); + expect( + enforce.isArray().longerThan(2).run(['a', 'b', 'c']).pass, + ).toBe(true); + }); + + it('fails when array length is equal or less', () => { + expect(enforce.isArray().longerThan(2).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().longerThan(3).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().longerThan(5).run(['a', 'b']).pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/longerThanOrEquals.test.ts b/packages/n4s/src/rules/array/__tests__/longerThanOrEquals.test.ts new file mode 100644 index 000000000..7e723d9fd --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/longerThanOrEquals.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('longerThanOrEquals', () => { + it('pass when array length is greater than or equal', () => { + expect( + enforce.isArray().longerThanOrEquals(2).run([1, 2]).pass, + ).toBe(true); + expect( + enforce.isArray().longerThanOrEquals(1).run([1, 2]).pass, + ).toBe(true); + expect(enforce.isArray().longerThanOrEquals(0).run([]).pass).toBe( + true, + ); + }); + + it('fails when array length is less', () => { + expect( + enforce.isArray().longerThanOrEquals(3).run([1, 2]).pass, + ).toBe(false); + expect(enforce.isArray().longerThanOrEquals(1).run([]).pass).toBe( + false, + ); + expect( + enforce.isArray().longerThanOrEquals(5).run(['a', 'b']).pass, + ).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/maxLength.test.ts b/packages/n4s/src/rules/array/__tests__/maxLength.test.ts new file mode 100644 index 000000000..28a10d339 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/maxLength.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('maxLength', () => { + it('pass when length is within maximum', () => { + expect(enforce.isArray().maxLength(2).run([1, 2]).pass).toBe(true); + expect(enforce.isArray().maxLength(2).run([1]).pass).toBe(true); + expect(enforce.isArray().maxLength(0).run([]).pass).toBe(true); + }); + + it('fails when length exceeds maximum', () => { + expect(enforce.isArray().maxLength(1).run([1, 2]).pass).toBe(false); + expect(enforce.isArray().maxLength(0).run([1]).pass).toBe(false); + expect( + enforce.isArray().maxLength(2).run(['a', 'b', 'c']).pass, + ).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/minLength.test.ts b/packages/n4s/src/rules/array/__tests__/minLength.test.ts new file mode 100644 index 000000000..c4e4a07a5 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/minLength.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('minLength', () => { + it('pass when length meets minimum', () => { + expect(enforce.isArray().minLength(1).run([1]).pass).toBe(true); + expect(enforce.isArray().minLength(1).run([1, 2]).pass).toBe(true); + expect(enforce.isArray().minLength(0).run([]).pass).toBe(true); + }); + + it('fails when length is below minimum', () => { + expect(enforce.isArray().minLength(1).run([]).pass).toBe(false); + expect(enforce.isArray().minLength(3).run([1, 2]).pass).toBe(false); + expect(enforce.isArray().minLength(5).run(['a', 'b']).pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/shorterThan.test.ts b/packages/n4s/src/rules/array/__tests__/shorterThan.test.ts new file mode 100644 index 000000000..66b835d64 --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/shorterThan.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('shorterThan', () => { + it('pass when array length is less', () => { + expect(enforce.isArray().shorterThan(3).run([1, 2]).pass).toBe( + true, + ); + expect(enforce.isArray().shorterThan(1).run([]).pass).toBe(true); + expect(enforce.isArray().shorterThan(5).run(['a', 'b']).pass).toBe( + true, + ); + }); + + it('fails when array length is equal or greater', () => { + expect(enforce.isArray().shorterThan(2).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().shorterThan(1).run([1, 2]).pass).toBe( + false, + ); + expect(enforce.isArray().shorterThan(0).run([]).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/__tests__/shorterThanOrEquals.test.ts b/packages/n4s/src/rules/array/__tests__/shorterThanOrEquals.test.ts new file mode 100644 index 000000000..76e8e756d --- /dev/null +++ b/packages/n4s/src/rules/array/__tests__/shorterThanOrEquals.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('shorterThanOrEquals', () => { + it('pass when array length is less than or equal', () => { + expect( + enforce.isArray().shorterThanOrEquals(2).run([1, 2]).pass, + ).toBe(true); + expect( + enforce.isArray().shorterThanOrEquals(3).run([1, 2]).pass, + ).toBe(true); + expect(enforce.isArray().shorterThanOrEquals(0).run([]).pass).toBe( + true, + ); + }); + + it('fails when array length is greater', () => { + expect( + enforce.isArray().shorterThanOrEquals(1).run([1, 2]).pass, + ).toBe(false); + expect( + enforce.isArray().shorterThanOrEquals(1).run(['a', 'b', 'c']) + .pass, + ).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/array/includes.ts b/packages/n4s/src/rules/array/includes.ts new file mode 100644 index 000000000..90bc771d3 --- /dev/null +++ b/packages/n4s/src/rules/array/includes.ts @@ -0,0 +1,4 @@ +// Checks if array contains the given item +export function includes(arr: T[], item: T): boolean { + return Array.isArray(arr) && arr.includes(item as any); +} diff --git a/packages/n4s/src/rules/array/isArrayRule.ts b/packages/n4s/src/rules/array/isArrayRule.ts new file mode 100644 index 000000000..87a342d39 --- /dev/null +++ b/packages/n4s/src/rules/array/isArrayRule.ts @@ -0,0 +1,22 @@ +/** + * Validates that a value is an array. + * Type guard that narrows the type to any[]. + * + * @param value - Value to validate + * @returns True if value is an array + * + * @example + * ```typescript + * // Eager API + * enforce([1, 2, 3]).isArray(); // passes + * enforce('hello').isArray(); // fails + * + * // Lazy API + * const arrayRule = enforce.isArray(); + * arrayRule.test([1, 2]); // true + * arrayRule.test({}); // false + * ``` + */ +export function isArray(value: any): value is any[] { + return Array.isArray(value); +} diff --git a/packages/n4s/src/rules/arrayRules.ts b/packages/n4s/src/rules/arrayRules.ts new file mode 100644 index 000000000..02cc83ce1 --- /dev/null +++ b/packages/n4s/src/rules/arrayRules.ts @@ -0,0 +1,62 @@ +import { BuildRuleInstance, ExtractRuleFunctions } from 'RuleInstanceBuilder'; +import { isEmpty, isNotEmpty } from 'vest-utils'; + +import { equals, notEquals } from 'commonComparison'; +import { inside, notInside } from 'commonContainer'; +import { + lengthEquals, + lengthNotEquals, + longerThan, + longerThanOrEquals, + maxLength, + minLength, + shorterThan, + shorterThanOrEquals, +} from 'commonLength'; +import { includes } from 'includes'; +import { isArray } from 'isArrayRule'; +import { isNotArray } from 'isNotArray'; + +export { + equals, + includes, + inside, + isArray, + isEmpty, + isNotArray, + isNotEmpty, + lengthEquals, + lengthNotEquals, + longerThan, + longerThanOrEquals, + maxLength, + minLength, + notEquals, + notInside, + shorterThan, + shorterThanOrEquals, +}; + +const arrayRules = { + equals, + includes, + inside, + isEmpty, + isNotEmpty, + lengthEquals, + lengthNotEquals, + longerThan, + longerThanOrEquals, + maxLength, + minLength, + notEquals, + notInside, + shorterThan, + shorterThanOrEquals, +} as const; + +export type ArrayRuleInstance = BuildRuleInstance< + T[], + [T[]], + ExtractRuleFunctions +>; diff --git a/packages/n4s/src/rules/boolean/__tests__/booleanRules.test.ts b/packages/n4s/src/rules/boolean/__tests__/booleanRules.test.ts new file mode 100644 index 000000000..cdc2e9180 --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/booleanRules.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('booleanRules', () => { + it('supports isTrue and isFalse', () => { + expect(enforce.isBoolean().isTrue().run(true).pass).toBe(true); + expect(enforce.isBoolean().isFalse().run(false).pass).toBe(true); + }); + + it('fails when predicates do not match', () => { + expect(enforce.isBoolean().isTrue().run(false).pass).toBe(false); + expect(enforce.isBoolean().isFalse().run(true).pass).toBe(false); + }); + + it('equals works', () => { + expect(enforce.isBoolean().equals(true).run(true).pass).toBe(true); + expect(enforce.isBoolean().equals(false).run(true).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/boolean/__tests__/equals.test.ts b/packages/n4s/src/rules/boolean/__tests__/equals.test.ts new file mode 100644 index 000000000..39c3c7fb5 --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/equals.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('equals', () => { + it('pass when values match', () => { + expect(enforce.isBoolean().equals(true).run(true).pass).toBe(true); + expect(enforce.isBoolean().equals(false).run(false).pass).toBe(true); + }); + + it('fails when values differ', () => { + expect(enforce.isBoolean().equals(true).run(false).pass).toBe(false); + expect(enforce.isBoolean().equals(false).run(true).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/boolean/__tests__/isBoolean.test.ts b/packages/n4s/src/rules/boolean/__tests__/isBoolean.test.ts new file mode 100644 index 000000000..da28f8d8c --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/isBoolean.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isBoolean', () => { + it('pass for true', () => { + expect(enforce.isBoolean().run(true).pass).toBe(true); + }); + + it('pass for false', () => { + expect(enforce.isBoolean().run(false).pass).toBe(true); + }); + + it('fails for truthy non-boolean values', () => { + const values: any[] = [1, 'true', 'yes', {}, [], () => {}]; + + values.forEach(value => { + expect(enforce.isBoolean().run(value).pass).toBe(false); + }); + }); + + it('fails for falsy non-boolean values', () => { + const values: any[] = [0, '', null, undefined, NaN]; + + values.forEach(value => { + expect(enforce.isBoolean().run(value).pass).toBe(false); + }); + }); + + describe('chain: isTrue', () => { + it('pass only for true', () => { + expect(enforce.isBoolean().isTrue().run(true).pass).toBe(true); + }); + + it('fails for false', () => { + expect(enforce.isBoolean().isTrue().run(false).pass).toBe(false); + }); + }); + + describe('chain: isFalse', () => { + it('pass only for false', () => { + expect(enforce.isBoolean().isFalse().run(false).pass).toBe(true); + }); + + it('fails for true', () => { + expect(enforce.isBoolean().isFalse().run(true).pass).toBe(false); + }); + }); + + describe('chain: equals', () => { + it('pass when values match', () => { + expect(enforce.isBoolean().equals(true).run(true).pass).toBe(true); + expect(enforce.isBoolean().equals(false).run(false).pass).toBe(true); + }); + + it('fails when values differ', () => { + expect(enforce.isBoolean().equals(true).run(false).pass).toBe(false); + expect(enforce.isBoolean().equals(false).run(true).pass).toBe(false); + }); + }); + + describe('chain: isTruthy', () => { + it('pass for true', () => { + expect(enforce.isBoolean().isTruthy().run(true).pass).toBe(true); + }); + + it('fails for false', () => { + expect(enforce.isBoolean().isTruthy().run(false).pass).toBe(false); + }); + }); + + describe('chain: isFalsy', () => { + it('pass for false', () => { + expect(enforce.isBoolean().isFalsy().run(false).pass).toBe(true); + }); + + it('fails for true', () => { + expect(enforce.isBoolean().isFalsy().run(true).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/boolean/__tests__/isFalse.test.ts b/packages/n4s/src/rules/boolean/__tests__/isFalse.test.ts new file mode 100644 index 000000000..4b3da6f35 --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/isFalse.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isFalse', () => { + it('pass only for false', () => { + expect(enforce.isBoolean().isFalse().run(false).pass).toBe(true); + }); + + it('fails for true', () => { + expect(enforce.isBoolean().isFalse().run(true).pass).toBe(false); + }); + + it('fails for falsy non-boolean values', () => { + const values: any[] = [0, '', null, undefined, NaN]; + + values.forEach(value => { + expect(enforce.isBoolean().isFalse().run(value).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/boolean/__tests__/isFalsy.test.ts b/packages/n4s/src/rules/boolean/__tests__/isFalsy.test.ts new file mode 100644 index 000000000..5bbf30091 --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/isFalsy.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isFalsy', () => { + it('pass for false', () => { + expect(enforce.isBoolean().isFalsy().run(false).pass).toBe(true); + }); + + it('fails for true', () => { + expect(enforce.isBoolean().isFalsy().run(true).pass).toBe(false); + }); + + it('fails for falsy non-boolean values', () => { + const values: any[] = [0, '', null, undefined, NaN]; + + values.forEach(value => { + expect(enforce.isBoolean().isFalsy().run(value).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/boolean/__tests__/isTrue.test.ts b/packages/n4s/src/rules/boolean/__tests__/isTrue.test.ts new file mode 100644 index 000000000..5602fbcf4 --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/isTrue.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isTrue', () => { + it('pass only for true', () => { + expect(enforce.isBoolean().isTrue().run(true).pass).toBe(true); + }); + + it('fails for false', () => { + expect(enforce.isBoolean().isTrue().run(false).pass).toBe(false); + }); + + it('fails for truthy non-boolean values', () => { + const values: any[] = [1, 'true', 'yes', {}, [], () => {}]; + + values.forEach(value => { + expect(enforce.isBoolean().isTrue().run(value).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/boolean/__tests__/isTruthy.test.ts b/packages/n4s/src/rules/boolean/__tests__/isTruthy.test.ts new file mode 100644 index 000000000..652b6694d --- /dev/null +++ b/packages/n4s/src/rules/boolean/__tests__/isTruthy.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isTruthy', () => { + it('pass for true', () => { + expect(enforce.isBoolean().isTruthy().run(true).pass).toBe(true); + }); + + it('fails for false', () => { + expect(enforce.isBoolean().isTruthy().run(false).pass).toBe(false); + }); + + it('fails for truthy non-boolean values', () => { + const values: any[] = [1, 'yes', {}, []]; + + values.forEach(value => { + expect(enforce.isBoolean().isTruthy().run(value).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/boolean/isBoolean.ts b/packages/n4s/src/rules/boolean/isBoolean.ts new file mode 100644 index 000000000..0359cdd9a --- /dev/null +++ b/packages/n4s/src/rules/boolean/isBoolean.ts @@ -0,0 +1,29 @@ +import { isBoolean as isBooleanValue } from 'vest-utils'; + +/** + * Validates that a value is a boolean. + * Type guard that narrows the type to boolean. + * + * @param value - Value to validate + * @returns True if value is a boolean + * + * @example + * ```typescript + * // Eager API + * enforce(true).isBoolean(); // passes + * enforce(false).isBoolean(); // passes + * enforce(1).isBoolean(); // fails + * enforce('true').isBoolean(); // fails + * + * // Lazy API + * const boolRule = enforce.isBoolean(); + * boolRule.test(true); // true + * boolRule.test(0); // false + * + * // Chains with boolean-specific rules + * enforce(true).isBoolean().isTrue(); + * ``` + */ +export function isBoolean(value: any): value is boolean { + return isBooleanValue(value); +} diff --git a/packages/n4s/src/rules/boolean/isFalse.ts b/packages/n4s/src/rules/boolean/isFalse.ts new file mode 100644 index 000000000..f7f740f03 --- /dev/null +++ b/packages/n4s/src/rules/boolean/isFalse.ts @@ -0,0 +1,4 @@ +// Checks if value is strictly equal to false +export function isFalse(value: boolean): boolean { + return value === false; +} diff --git a/packages/n4s/src/rules/boolean/isTrue.ts b/packages/n4s/src/rules/boolean/isTrue.ts new file mode 100644 index 000000000..663d04c7b --- /dev/null +++ b/packages/n4s/src/rules/boolean/isTrue.ts @@ -0,0 +1,4 @@ +// Checks if value is strictly equal to true +export function isTrue(value: boolean): boolean { + return value === true; +} diff --git a/packages/n4s/src/rules/booleanRules.ts b/packages/n4s/src/rules/booleanRules.ts new file mode 100644 index 000000000..cc8a548a0 --- /dev/null +++ b/packages/n4s/src/rules/booleanRules.ts @@ -0,0 +1,25 @@ +import { equals } from 'equals'; +import { isBoolean } from 'isBoolean'; +import { isTruthy } from 'isTruthy'; +import { type DropFirst } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { isFalse } from 'isFalse'; +import { isFalsy } from 'isFalsy'; +import { isTrue } from 'isTrue'; + + +export { equals, isFalsy, isFalse, isTrue, isTruthy, isBoolean }; + +export interface BooleanRuleInstance extends RuleInstance { + isTrue(...args: DropFirst>): BooleanRuleInstance; + isFalse(...args: DropFirst>): BooleanRuleInstance; + isTruthy( + ...args: DropFirst> + ): BooleanRuleInstance; + isFalsy(...args: DropFirst>): BooleanRuleInstance; + equals(...args: DropFirst>): BooleanRuleInstance; + isBoolean( + ...args: DropFirst> + ): BooleanRuleInstance; +} diff --git a/packages/n4s/src/rules/chainBuilder/chainBuilder.ts b/packages/n4s/src/rules/chainBuilder/chainBuilder.ts new file mode 100644 index 000000000..cdd3f592d --- /dev/null +++ b/packages/n4s/src/rules/chainBuilder/chainBuilder.ts @@ -0,0 +1,66 @@ +import { + dynamicValue, + type DynamicValue, + type Maybe, + type Stringable, +} from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { executeChain, type Predicate } from 'chainExecutor'; +import { createChainProxyHandlers } from 'proxyHandlers'; + +export type RuleFunctions> = Record< + keyof Omit, + (...args: any[]) => boolean +>; + +type LazyMessage = DynamicValue< + string, + [value: unknown, originalMessage?: Stringable] +>; + +/** + * Creates a chain builder for rule validation. + * Provides methods to add predicates, run validation, and apply custom messages. + */ +export function createChainBuilder>( + rules: RuleFunctions, +) { + const chain: Predicate[] = []; + const target: Partial = {}; + let lazyMessage: Maybe = undefined; + + const add = (p: Predicate): T => { + chain.push(p); + return proxy; + }; + + const run: T['run'] = ((...args: any[]) => { + const result = executeChain(chain, args[0]); + if (!result.pass && lazyMessage) { + return { + ...result, + message: + dynamicValue(lazyMessage, args[0], result.message) ?? result.message, + }; + } + return result; + }) as T['run']; + + const test: T['test'] = ((...args: any[]) => + executeChain(chain, args[0]).pass) as T['test']; + + const message = (msg: Stringable): T => { + if (msg) { + lazyMessage = msg; + } + return proxy; + }; + + const proxy: T = new Proxy( + target as T, + createChainProxyHandlers(rules, add, run, test, message), + ); + + return { add, proxy } as const; +} diff --git a/packages/n4s/src/rules/chainBuilder/chainExecutor.ts b/packages/n4s/src/rules/chainBuilder/chainExecutor.ts new file mode 100644 index 000000000..fdaec56cf --- /dev/null +++ b/packages/n4s/src/rules/chainBuilder/chainExecutor.ts @@ -0,0 +1,24 @@ +import { RuleRunReturn } from 'RuleRunReturn'; + +export type Predicate = (value: any) => boolean | RuleRunReturn; + +function isRuleRunReturn(result: any): result is RuleRunReturn { + return typeof result === 'object' && result !== null && 'pass' in result; +} + +export function executeChain( + chain: Predicate[], + value: any, +): RuleRunReturn { + for (const predicate of chain) { + const result = predicate(value); + + if (isRuleRunReturn(result)) { + if (!result.pass) return result as RuleRunReturn; + } else if (!result) { + return RuleRunReturn.Failing(value); + } + } + + return RuleRunReturn.Passing(value); +} diff --git a/packages/n4s/src/rules/chainBuilder/lazyRegistry.ts b/packages/n4s/src/rules/chainBuilder/lazyRegistry.ts new file mode 100644 index 000000000..23218c030 --- /dev/null +++ b/packages/n4s/src/rules/chainBuilder/lazyRegistry.ts @@ -0,0 +1,16 @@ +import type { Predicate } from 'chainExecutor'; + +const lazyRegistry: Record Predicate> = {}; + +export function registerLazyRule( + name: string, + builder: (...args: any[]) => Predicate, +) { + lazyRegistry[name] = builder; +} + +export function getLazyRule( + name: string, +): ((...args: any[]) => Predicate) | undefined { + return lazyRegistry[name]; +} diff --git a/packages/n4s/src/rules/chainBuilder/proxyHandlers.ts b/packages/n4s/src/rules/chainBuilder/proxyHandlers.ts new file mode 100644 index 000000000..0c9b66ecb --- /dev/null +++ b/packages/n4s/src/rules/chainBuilder/proxyHandlers.ts @@ -0,0 +1,48 @@ +import { hasOwnProperty } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import type { Predicate } from 'chainExecutor'; +import { getLazyRule } from 'lazyRegistry'; + +export function createChainProxyHandlers>( + rules: Record< + keyof Omit, + (...args: any[]) => boolean + >, + add: (p: Predicate) => T, + run: T['run'], + test: T['test'], + message: (msg: any) => T, +) { + return { + get(_target: T, prop: string | symbol, receiver: any) { + if (prop === 'run') return run; + if (prop === 'test') return test; + if (prop === 'message') return message; + + if (hasOwnProperty(rules, prop as any)) { + return (...args: any[]) => + add((value: any) => (rules as any)[prop](value, ...args)); + } + + const lazyRule = getLazyRule(prop as string); + if (lazyRule) { + return (...args: any[]) => add(lazyRule(...args)); + } + + return Reflect.get(_target as object, prop, receiver); + }, + has(_target: T, prop: string | symbol) { + if ( + prop === 'run' || + prop === 'infer' || + prop === 'test' || + prop === 'message' + ) + return true; + if (hasOwnProperty(rules, prop as any)) return true; + if (getLazyRule(prop as string)) return true; + return Reflect.has(_target as object, prop); + }, + }; +} diff --git a/packages/n4s/src/rules/commonComparison.ts b/packages/n4s/src/rules/commonComparison.ts new file mode 100644 index 000000000..5ffc2f87e --- /dev/null +++ b/packages/n4s/src/rules/commonComparison.ts @@ -0,0 +1,33 @@ +/** + * Common comparison predicates that work across multiple types + */ + +export function equals(a: T, b: T): boolean { + return a === b; +} + +export function notEquals(a: T, b: T): boolean { + return a !== b; +} + +export function greaterThan(a: T, b: T): boolean { + return a > b; +} + +export function greaterThanOrEquals( + a: T, + b: T, +): boolean { + return a >= b; +} + +export function lessThan(a: T, b: T): boolean { + return a < b; +} + +export function lessThanOrEquals( + a: T, + b: T, +): boolean { + return a <= b; +} diff --git a/packages/n4s/src/rules/commonContainer.ts b/packages/n4s/src/rules/commonContainer.ts new file mode 100644 index 000000000..aa311595a --- /dev/null +++ b/packages/n4s/src/rules/commonContainer.ts @@ -0,0 +1,51 @@ +/** + * Common container predicates for arrays and strings + */ + +function isStringContainment(value: unknown, container: unknown): boolean { + return typeof container === 'string' && typeof value === 'string'; +} + +function checkAllItemsInSet(items: T[], set: Set): boolean { + for (const item of items) { + if (!set.has(item)) return false; + } + return true; +} + +function checkAnyItemNotInSet(items: T[], set: Set): boolean { + for (const item of items) { + if (!set.has(item)) return true; + } + return false; +} + +export function inside(value: T, container: T[] | string): boolean { + if (isStringContainment(value, container)) { + return (container as string).includes(value as string); + } + + if (Array.isArray(container)) { + const set = new Set(container as T[]); + return Array.isArray(value) + ? checkAllItemsInSet(value, set) + : set.has(value as T); + } + + return false; +} + +export function notInside(value: T, container: any): boolean { + if (isStringContainment(value, container)) { + return !(container as string).includes(value as string); + } + + if (Array.isArray(container)) { + const set = new Set(container as T[]); + return Array.isArray(value) + ? checkAnyItemNotInSet(value, set) + : !set.has(value as T); + } + + return true; +} diff --git a/packages/n4s/src/rules/commonLength.ts b/packages/n4s/src/rules/commonLength.ts new file mode 100644 index 000000000..ff00e1d3c --- /dev/null +++ b/packages/n4s/src/rules/commonLength.ts @@ -0,0 +1,39 @@ +// Shared length-based predicates for strings and arrays. +// Works on any value with a .length property. + +type Lengthable = { length: number }; + +export function minLength(value: Lengthable, n: number): boolean { + return value.length >= n; +} + +export function maxLength(value: Lengthable, n: number): boolean { + return value.length <= n; +} + +export const min = minLength; +export const max = maxLength; + +export function lengthEquals(value: Lengthable, n: number): boolean { + return value.length === n; +} + +export function lengthNotEquals(value: Lengthable, n: number): boolean { + return value.length !== n; +} + +export function longerThan(value: Lengthable, n: number): boolean { + return value.length > n; +} + +export function longerThanOrEquals(value: Lengthable, n: number): boolean { + return value.length >= n; +} + +export function shorterThan(value: Lengthable, n: number): boolean { + return value.length < n; +} + +export function shorterThanOrEquals(value: Lengthable, n: number): boolean { + return value.length <= n; +} diff --git a/packages/n4s/src/rules/compoundRules/__tests__/allOf.test.ts b/packages/n4s/src/rules/compoundRules/__tests__/allOf.test.ts new file mode 100644 index 000000000..af1f85147 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/__tests__/allOf.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('allOf', () => { + it('should return a rule instance', () => { + const rule = enforce.allOf(enforce.isNumber()); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass if all rules pass', () => { + const rule = enforce.allOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(5), + ); + const result = rule.run(10); + expect(result.pass).toBe(true); + }); + + it('should fail if one rule fails', () => { + const rule = enforce.allOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + const result = rule.run(5); + expect(result.pass).toBe(false); + }); + + it('should fail if value is of wrong type', () => { + const rule = enforce.allOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(5), + ); + const result = rule.run('10'); + expect(result.pass).toBe(false); + }); + + it('should pass with no rules', () => { + const rule = enforce.allOf(); + const result = rule.run('any value'); + expect(result.pass).toBe(true); + }); + + describe('When all rules are satisfied', () => { + it('Should return a passing result', () => { + const rule = enforce.allOf(enforce.isArray()); + const result = rule.run([1, 2, 3]); + expect(result).toEqual({ pass: true, type: [1, 2, 3] }); + }); + }); +}); + +describe('allOf - eager API', () => { + it('should pass if all rules pass (eager)', () => { + expect(() => { + enforce(10).allOf(enforce.isNumber(), enforce.isNumber().greaterThan(5)); + }).not.toThrow(); + }); + + it('should fail if one rule fails (eager)', () => { + expect(() => { + enforce(5).allOf(enforce.isNumber(), enforce.isNumber().greaterThan(10)); + }).toThrow(); + }); + + it('should fail if value is of wrong type (eager)', () => { + expect(() => { + enforce('10').allOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(5), + ); + }).toThrow(); + }); + + it('should pass with no rules (eager)', () => { + expect(() => { + enforce('any value').allOf(); + }).not.toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/compoundRules/__tests__/anyOf.test.ts b/packages/n4s/src/rules/compoundRules/__tests__/anyOf.test.ts new file mode 100644 index 000000000..cd7c10f62 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/__tests__/anyOf.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, expectTypeOf } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('anyOf', () => { + it('should return a rule instance', () => { + const rule = enforce.anyOf(enforce.isNumber()); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass if at least one rule pass', () => { + const rule = enforce.anyOf( + enforce.isString(), + enforce.isNumber().greaterThan(10), + ); + expect(rule.run(5).pass).toBe(false); + expect(rule.run(15).pass).toBe(true); + expect(rule.run('hello').pass).toBe(true); + }); + + it('should infer a union of rule input types', () => { + const rule = enforce.anyOf( + enforce.isString(), + enforce.isNumber().greaterThan(10), + ); + + expectTypeOf(rule.infer).toEqualTypeOf(); + expectTypeOf(rule.run).parameter(0).toEqualTypeOf(); + expect(rule).toBeDefined(); + }); + + it('should fail if all rules fail', () => { + const rule = enforce.anyOf( + enforce.isString(), + enforce.isNumber().greaterThan(10), + ); + const result = rule.run(5); + expect(result.pass).toBe(false); + }); + + it('should fail with no rules', () => { + const rule = enforce.anyOf(); + const result = rule.run('any value'); + expect(result.pass).toBe(false); + }); +}); + +describe('anyOf - eager API', () => { + it('should pass if at least one rule passes (eager)', () => { + expect(() => { + enforce(15).anyOf(enforce.isString(), enforce.isNumber().greaterThan(10)); + }).not.toThrow(); + + expect(() => { + enforce('hello').anyOf( + enforce.isString(), + enforce.isNumber().greaterThan(10), + ); + }).not.toThrow(); + }); + + it('should fail if all rules fail (eager)', () => { + expect(() => { + enforce(5).anyOf(enforce.isString(), enforce.isNumber().greaterThan(10)); + }).toThrow(); + }); + + it('should fail with no rules (eager)', () => { + expect(() => { + enforce('any value').anyOf(); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/compoundRules/__tests__/noneOf.test.ts b/packages/n4s/src/rules/compoundRules/__tests__/noneOf.test.ts new file mode 100644 index 000000000..09e8bf5f8 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/__tests__/noneOf.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('noneOf', () => { + it('should return a rule instance', () => { + const rule = enforce.noneOf(enforce.isNumber()); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass if no rules pass', () => { + const rule = enforce.noneOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + const result = rule.run('a string'); + expect(result.pass).toBe(true); + }); + + it('should fail if any rule pass', () => { + const rule = enforce.noneOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + expect(rule.run(5).pass).toBe(false); // isNumber pass + expect(rule.run(12).pass).toBe(false); // isNumber and isGreaterThan(10) pass + expect(rule.run('a string').pass).toBe(true); + }); + + it('should pass with no rules', () => { + const rule = enforce.noneOf(); + const result = rule.run('any value'); + expect(result.pass).toBe(true); + }); +}); + +describe('noneOf - eager API', () => { + it('should pass if no rules pass (eager)', () => { + expect(() => { + enforce('a string').noneOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + }).not.toThrow(); + }); + + it('should fail if any rule passes (eager)', () => { + expect(() => { + enforce(5).noneOf(enforce.isNumber(), enforce.isNumber().greaterThan(10)); + }).toThrow(); + + expect(() => { + enforce(12).noneOf( + enforce.isNumber(), + enforce.isNumber().greaterThan(10), + ); + }).toThrow(); + }); + + it('should pass with no rules (eager)', () => { + expect(() => { + enforce('any value').noneOf(); + }).not.toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/compoundRules/__tests__/oneOf.test.ts b/packages/n4s/src/rules/compoundRules/__tests__/oneOf.test.ts new file mode 100644 index 000000000..1d328d44c --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/__tests__/oneOf.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('oneOf', () => { + it('should return a rule instance', () => { + const rule = enforce.oneOf(enforce.isNumber()); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass if exactly one rule pass', () => { + const rule = enforce.oneOf( + enforce.isNumber().greaterThan(10), + enforce.isNumber().lessThan(5), + ); + expect(rule.run(12).pass).toBe(true); + expect(rule.run(3).pass).toBe(true); + }); + + it('should fail if more than one rule pass', () => { + const rule = enforce.oneOf( + enforce.isNumber().greaterThan(5), + enforce.isNumber().greaterThan(10), + ); + const result = rule.run(12); + expect(result.pass).toBe(false); + }); + + it('should fail if no rules pass', () => { + const rule = enforce.oneOf( + enforce.isNumber().greaterThan(10), + enforce.isNumber().lessThan(5), + ); + const result = rule.run(7); + expect(result.pass).toBe(false); + }); + + it('should fail with no rules', () => { + const rule = enforce.oneOf(); + const result = rule.run('any value'); + expect(result.pass).toBe(false); + }); +}); + +describe('oneOf - eager API', () => { + it('should pass if exactly one rule passes (eager)', () => { + expect(() => { + enforce(12).oneOf( + enforce.isNumber().greaterThan(10), + enforce.isNumber().lessThan(5), + ); + }).not.toThrow(); + + expect(() => { + enforce(3).oneOf( + enforce.isNumber().greaterThan(10), + enforce.isNumber().lessThan(5), + ); + }).not.toThrow(); + }); + + it('should fail if more than one rule passes (eager)', () => { + expect(() => { + enforce(12).oneOf( + enforce.isNumber().greaterThan(5), + enforce.isNumber().greaterThan(10), + ); + }).toThrow(); + }); + + it('should fail if no rules pass (eager)', () => { + expect(() => { + enforce(7).oneOf( + enforce.isNumber().greaterThan(10), + enforce.isNumber().lessThan(5), + ); + }).toThrow(); + }); + + it('should fail with no rules (eager)', () => { + expect(() => { + enforce('any value').oneOf(); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/compoundRules/allOf.ts b/packages/n4s/src/rules/compoundRules/allOf.ts new file mode 100644 index 000000000..2d945ee09 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/allOf.ts @@ -0,0 +1,44 @@ +import { mapFirst } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Validates that a value passes all of the provided rules. + * All rules must pass for the validation to succeed. + * Evaluation stops at the first failing rule. + * + * @template T - The value type to validate + * @param value - The value to validate + * @param rules - One or more RuleInstances that must all pass + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce(25) + * .allOf( + * enforce.isNumber().greaterThan(18).lessThan(100) + * ); // passes (all rules pass) + * + * // Lazy API + * const adultAgeRule = enforce.allOf( + * enforce.isNumber().greaterThanOrEquals(18).lessThan(150) + * ); + * + * adultAgeRule.test(25); // true + * adultAgeRule.test(16); // false + * adultAgeRule.test('25'); // false (not a number) + * ``` + */ +export function allOf(value: T, ...rules: any[]): RuleRunReturn { + return ( + mapFirst(rules, (rule, breakout) => { + const res = rule.run(value); + breakout(!res.pass, res); + }) || RuleRunReturn.Passing(value) + ); +} + +// Type for allOf rule instance +export type AllOfRuleInstance = RuleInstance; diff --git a/packages/n4s/src/rules/compoundRules/anyOf.ts b/packages/n4s/src/rules/compoundRules/anyOf.ts new file mode 100644 index 000000000..8caf769e6 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/anyOf.ts @@ -0,0 +1,46 @@ +import { mapFirst } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Validates that a value passes at least one of the provided rules. + * At least one rule must pass for the validation to succeed. + * Evaluation stops at the first passing rule. + * + * @template T - The value type to validate + * @param value - The value to validate + * @param rules - One or more RuleInstances where at least one must pass + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce('hello') + * .anyOf( + * enforce.isNumber(), + * enforce.isString() + * ); // passes (string matches) + * + * // Lazy API - accept either format + * const phoneOrEmailRule = enforce.anyOf( + * enforce.isString().matches(/^\d{10}$/), // phone + * enforce.isString().matches(/@/) // email + * ); + * + * phoneOrEmailRule.test('1234567890'); // true + * phoneOrEmailRule.test('user@example.com'); // true + * phoneOrEmailRule.test('invalid'); // false + * ``` + */ +export function anyOf(value: T, ...rules: any[]): RuleRunReturn { + return ( + mapFirst(rules, (rule, breakout) => { + const res = rule.run(value); + breakout(res.pass, res); + }) || RuleRunReturn.Failing(value) + ); +} + +// Type for anyOf rule instance +export type AnyOfRuleInstance = RuleInstance; diff --git a/packages/n4s/src/rules/compoundRules/compoundRules.ts b/packages/n4s/src/rules/compoundRules/compoundRules.ts new file mode 100644 index 000000000..422073358 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/compoundRules.ts @@ -0,0 +1,7 @@ +import 'compoundRulesTypes'; + +export { allOf, type AllOfRuleInstance } from 'allOf'; +export { anyOf, type AnyOfRuleInstance } from 'anyOf'; +export { noneOf, type NoneOfRuleInstance } from 'noneOf'; +export { oneOf, type OneOfRuleInstance } from 'oneOf'; +export type { CompoundRuleLazyTypes } from 'compoundRulesTypes'; diff --git a/packages/n4s/src/rules/compoundRules/compoundRulesTypes.ts b/packages/n4s/src/rules/compoundRules/compoundRulesTypes.ts new file mode 100644 index 000000000..1528733ac --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/compoundRulesTypes.ts @@ -0,0 +1,24 @@ +/** + * Compound rules type declarations. + */ +import 'allOf'; +import 'anyOf'; +import 'noneOf'; +import 'oneOf'; + +import type { + AllOfRuleInstance, + AnyOfRuleInstance, + NoneOfRuleInstance, + OneOfRuleInstance, +} from 'compoundRules'; + +/** + * Type mappings for compound rule lazy API return types + */ +export type CompoundRuleLazyTypes = { + allOf: (...rules: any[]) => AllOfRuleInstance; + anyOf: (...rules: any[]) => AnyOfRuleInstance; + noneOf: (...rules: any[]) => NoneOfRuleInstance; + oneOf: (...rules: any[]) => OneOfRuleInstance; +}; diff --git a/packages/n4s/src/rules/compoundRules/noneOf.ts b/packages/n4s/src/rules/compoundRules/noneOf.ts new file mode 100644 index 000000000..e4126f315 --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/noneOf.ts @@ -0,0 +1,47 @@ +import { mapFirst } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Validates that a value passes none of the provided rules. + * All rules must fail for the validation to succeed. + * Evaluation stops at the first passing rule. + * + * @template T - The value type to validate + * @param value - The value to validate + * @param rules - One or more RuleInstances that must all fail + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce(0) + * .noneOf( + * enforce.greaterThan(0), + * enforce.lessThan(0) + * ); // passes (neither rule passes) + * + * // Lazy API - exclude reserved usernames + * const notReservedRule = enforce.noneOf( + * enforce.equals('admin'), + * enforce.equals('root'), + * enforce.equals('system') + * ); + * + * notReservedRule.test('john'); // true + * notReservedRule.test('admin'); // false + * notReservedRule.test('root'); // false + * ``` + */ +export function noneOf(value: T, ...rules: any[]): RuleRunReturn { + return ( + mapFirst(rules, (rule, breakout) => { + const res = rule.run(value); + breakout(res.pass, RuleRunReturn.Failing(value)); + }) || RuleRunReturn.Passing(value) + ); +} + +// Type for noneOf rule instance +export type NoneOfRuleInstance = RuleInstance; diff --git a/packages/n4s/src/rules/compoundRules/oneOf.ts b/packages/n4s/src/rules/compoundRules/oneOf.ts new file mode 100644 index 000000000..daa545e7d --- /dev/null +++ b/packages/n4s/src/rules/compoundRules/oneOf.ts @@ -0,0 +1,66 @@ +import { greaterThan } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +const REQUIRED_COUNT = 1; + +/** + * Validates that a value passes exactly one of the provided rules. + * Exactly one rule must pass - not zero, not two or more. + * All rules are evaluated to count passing rules. + * + * @template T - The value type to validate + * @param value - The value to validate + * @param rules - One or more RuleInstances where exactly one must pass + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce(5) + * .oneOf( + * enforce.lessThan(10), + * enforce.greaterThan(100) + * ); // passes (only first rule passes) + * + * // Lazy API - accept either type but not both + * const stringOrNumberRule = enforce.oneOf( + * enforce.isString(), + * enforce.isNumber() + * ); + * + * stringOrNumberRule.test('hello'); // true + * stringOrNumberRule.test(42); // true + * stringOrNumberRule.test(true); // false (no rules pass) + * + * // More complex example - exclusive validation + * const exclusiveRule = enforce.oneOf( + * enforce.equals(null), + * enforce.isString().longerThan(0) + * ); + * + * exclusiveRule.test(null); // true (exactly one passes) + * exclusiveRule.test('hello'); // true (exactly one passes) + * exclusiveRule.test(''); // false (neither passes) + * ``` + */ +export function oneOf(value: T, ...rules: any[]): RuleRunReturn { + let passingCount = 0; + rules.some(rule => { + const res = rule.run(value); + + if (res.pass) { + passingCount++; + } + + if (greaterThan(passingCount, REQUIRED_COUNT)) { + return RuleRunReturn.Failing(value); + } + }); + + return RuleRunReturn.create(passingCount === REQUIRED_COUNT, value); +} + +// Type for oneOf rule instance +export type OneOfRuleInstance = RuleInstance; diff --git a/packages/n4s/src/rules/endsWith.ts b/packages/n4s/src/rules/endsWith.ts deleted file mode 100644 index 77dc216f9..000000000 --- a/packages/n4s/src/rules/endsWith.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isStringValue as isString, bindNot } from 'vest-utils'; - -export function endsWith(value: string, arg1: string): boolean { - return isString(value) && isString(arg1) && value.endsWith(arg1); -} - -export const doesNotEndWith = bindNot(endsWith); diff --git a/packages/n4s/src/rules/equals.ts b/packages/n4s/src/rules/equals.ts deleted file mode 100644 index a48d057af..000000000 --- a/packages/n4s/src/rules/equals.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { bindNot } from 'vest-utils'; - -export function equals(value: unknown, arg1: unknown): boolean { - return value === arg1; -} - -export const notEquals = bindNot(equals); diff --git a/packages/n4s/src/rules/genRuleChain.ts b/packages/n4s/src/rules/genRuleChain.ts new file mode 100644 index 000000000..afaaa2ff1 --- /dev/null +++ b/packages/n4s/src/rules/genRuleChain.ts @@ -0,0 +1,30 @@ +import { createChainBuilder, type RuleFunctions } from 'chainBuilder'; + +import { RuleInstance } from 'RuleInstance'; +import { type Predicate } from 'chainExecutor'; +import { registerLazyRule } from 'lazyRegistry'; + +export { registerLazyRule }; + +/** + * Adds a predicate to a new rule chain and returns the chained rule instance. + */ +export function addToChain>( + rules: RuleFunctions, + predicate: Predicate, +): T { + const { add, proxy } = createChainBuilder(rules); + add(predicate); + return proxy as T; +} + +/** + * Generates a rule chain factory function. + * Returns a function that accepts a predicate and returns a chained rule instance. + */ +export function genRuleChain>( + rules: RuleFunctions, +): (p: Predicate) => T { + const { add } = createChainBuilder(rules); + return add; +} diff --git a/packages/n4s/src/rules/general/__tests__/condition.test.ts b/packages/n4s/src/rules/general/__tests__/condition.test.ts new file mode 100644 index 000000000..ba6cb7c15 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/condition.test.ts @@ -0,0 +1,68 @@ +import { enforce } from 'n4s'; +import { describe, it, expect, vi } from 'vitest'; + +describe('enforce.condition', () => { + it('Should pass down enforced value to condition as the first argument', () => { + const condition = vi.fn(() => true); + + enforce(1).condition(condition); + expect(condition).toHaveBeenCalledWith(1); + + enforce.condition(condition).run(2); + expect(condition).toHaveBeenCalledWith(2); + + expect(enforce.condition((v: boolean) => v).run(true)).toEqual({ + pass: true, + type: true, + }); + expect(enforce.condition((v: boolean) => v).run(false)).toEqual({ + pass: false, + type: false, + }); + }); + + describe('Lazy interface', () => { + it('Should return a failing result if condition is failing', () => { + expect(enforce.condition(() => false).run(1)).toEqual({ + pass: false, + type: 1, + }); + }); + + it('Should return a passing result if condition is passing', () => { + expect(enforce.condition(() => true).run(1)).toEqual({ + pass: true, + type: 1, + }); + }); + }); + + describe('Eager interface', () => { + it('Should throw an error if condition is failing', () => { + expect(() => enforce(1).condition(() => false)).toThrow(); + + expect(() => enforce(1).condition(() => false)).toThrow(); + + expect(() => enforce(1).condition(() => false)).toThrow(); + }); + + it('Should return silently if condition is passing', () => { + expect(() => enforce(1).condition(() => true)).not.toThrow(); + + expect(() => enforce(1).condition(() => true)).not.toThrow(); + + expect(() => enforce(1).condition(() => true)).not.toThrow(); + }); + }); + + describe('Error handling', () => { + it('Should fail if not a function', () => { + // Type test: - testing bad usage + expect(() => enforce().condition('not a function')).toThrow(); + expect(enforce.condition('not a function').run(1)).toEqual({ + pass: false, + type: 1, + }); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/generalRules.test.ts b/packages/n4s/src/rules/general/__tests__/generalRules.test.ts new file mode 100644 index 000000000..65498441e --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/generalRules.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('generalRules', () => { + it('truthy/falsy checks', () => { + expect(enforce.isTruthy().run(1).pass).toBe(true); + expect(enforce.isFalsy().run(0).pass).toBe(true); + }); + + it('empty/notEmpty checks', () => { + expect(enforce.isEmpty().run('').pass).toBe(true); + expect(enforce.isEmpty().run([]).pass).toBe(true); + expect(enforce.isEmpty().run({}).pass).toBe(true); + expect(enforce.isNotEmpty().run([1]).pass).toBe(true); + }); + + // isBlank / isNotBlank were moved to string rules + + it('NaN checks', () => { + expect(enforce.isNotNaN().run(1).pass).toBe(true); + }); + + it('condition check', () => { + expect(enforce.condition(() => true).run('anything').pass).toBe(true); + expect(enforce.condition(() => false).run('anything').pass).toBe(false); + }); + + it('negative type checks', () => { + expect(enforce.isNotArray().run('str').pass).toBe(true); + expect(enforce.isNotArray().run([]).pass).toBe(false); + + expect(enforce.isNotBoolean().run(0).pass).toBe(true); + expect(enforce.isNotBoolean().run(true).pass).toBe(false); + + // NaN should be considered not a number for this rule + expect(enforce.isNotNumber().run(NaN).pass).toBe(true); + expect(enforce.isNotNumber().run(1).pass).toBe(false); + + expect(enforce.isNotString().run(1).pass).toBe(true); + expect(enforce.isNotString().run('x').pass).toBe(false); + + expect(enforce.isNotNumeric().run('abc').pass).toBe(true); + expect(enforce.isNotNumeric().run('123').pass).toBe(false); + }); +}); + +describe('generalRules - extended', () => { + it('isTruthy / isFalsy', () => { + expect(enforce.isTruthy().run(1).pass).toBe(true); + expect(enforce.isTruthy().run('hello').pass).toBe(true); + expect(enforce.isTruthy().run(true).pass).toBe(true); + expect(enforce.isTruthy().run(0).pass).toBe(false); + + expect(enforce.isFalsy().run(0).pass).toBe(true); + expect(enforce.isFalsy().run('').pass).toBe(true); + expect(enforce.isFalsy().run(false).pass).toBe(true); + expect(enforce.isFalsy().run('nope').pass).toBe(false); + }); + + it('isEmpty / isNotEmpty', () => { + expect(enforce.isEmpty().run([]).pass).toBe(true); + expect(enforce.isEmpty().run('').pass).toBe(true); + expect(enforce.isEmpty().run({}).pass).toBe(true); + expect(enforce.isEmpty().run(0).pass).toBe(true); + expect(enforce.isEmpty().run(NaN).pass).toBe(true); + expect(enforce.isEmpty().run(undefined).pass).toBe(true); + expect(enforce.isEmpty().run(null).pass).toBe(true); + expect(enforce.isEmpty().run(false).pass).toBe(true); + + expect(enforce.isNotEmpty().run([1]).pass).toBe(true); + expect(enforce.isNotEmpty().run('a').pass).toBe(true); + expect(enforce.isNotEmpty().run({ a: 1 }).pass).toBe(true); + expect(enforce.isNotEmpty().run(1).pass).toBe(true); + }); + + // isBlank / isNotBlank moved to stringRules and now apply only to strings + + it('isNaN / isNotNaN', () => { + expect(enforce.isNotNaN().run(123).pass).toBe(true); + expect(enforce.isNotNaN().run(NaN).pass).toBe(false); + }); + + it('condition', () => { + expect(enforce.condition(() => true).run('anything').pass).toBe(true); + expect(enforce.condition(() => false).run('anything').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isBlank.test.ts b/packages/n4s/src/rules/general/__tests__/isBlank.test.ts new file mode 100644 index 000000000..006cc6c28 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isBlank.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isBlank', () => { + describe('strings', () => { + it('pass for empty strings', () => { + expect(enforce.isBlank().run('').pass).toBe(true); + }); + + it('pass for whitespace-only strings', () => { + expect(enforce.isBlank().run(' ').pass).toBe(true); + expect(enforce.isBlank().run(' ').pass).toBe(true); + expect(enforce.isBlank().run('\t').pass).toBe(true); + expect(enforce.isBlank().run('\n').pass).toBe(true); + expect(enforce.isBlank().run('\r\n').pass).toBe(true); + expect(enforce.isBlank().run(' \t \n ').pass).toBe(true); + }); + + it('fails for strings with content', () => { + expect(enforce.isBlank().run('x').pass).toBe(false); + expect(enforce.isBlank().run(' x ').pass).toBe(false); + expect(enforce.isBlank().run('hello').pass).toBe(false); + expect(enforce.isBlank().run(' text ').pass).toBe(false); + }); + }); + + describe('nullish values', () => { + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isBlank().run(value).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isBlank().run(value).pass).toBe(true); + }); + }); + + describe('non-blank values', () => { + it('fails for numbers', () => { + expect(enforce.isBlank().run(0).pass).toBe(false); + expect(enforce.isBlank().run(123).pass).toBe(false); + expect(enforce.isBlank().run(-1).pass).toBe(false); + }); + + it('fails for booleans', () => { + expect(enforce.isBlank().run(true).pass).toBe(false); + expect(enforce.isBlank().run(false).pass).toBe(false); + }); + + it('fails for objects', () => { + expect(enforce.isBlank().run({}).pass).toBe(false); + expect(enforce.isBlank().run({ a: 1 }).pass).toBe(false); + }); + + it('fails for arrays', () => { + expect(enforce.isBlank().run([]).pass).toBe(false); + expect(enforce.isBlank().run([1, 2, 3]).pass).toBe(false); + }); + }); +}); + +describe('isNotBlank', () => { + describe('strings', () => { + it('fails for empty strings', () => { + expect(enforce.isNotBlank().run('').pass).toBe(false); + }); + + it('fails for whitespace-only strings', () => { + expect(enforce.isNotBlank().run(' ').pass).toBe(false); + expect(enforce.isNotBlank().run(' ').pass).toBe(false); + expect(enforce.isNotBlank().run('\t').pass).toBe(false); + expect(enforce.isNotBlank().run('\n').pass).toBe(false); + expect(enforce.isNotBlank().run('\r\n').pass).toBe(false); + expect(enforce.isNotBlank().run(' \t \n ').pass).toBe(false); + }); + + it('pass for strings with content', () => { + expect(enforce.isNotBlank().run('x').pass).toBe(true); + expect(enforce.isNotBlank().run(' x ').pass).toBe(true); + expect(enforce.isNotBlank().run('hello').pass).toBe(true); + expect(enforce.isNotBlank().run(' text ').pass).toBe(true); + }); + }); + + describe('nullish values', () => { + it('fails for undefined', () => { + const value: any = undefined; + expect(enforce.isNotBlank().run(value).pass).toBe(false); + }); + + it('fails for null', () => { + const value: any = null; + expect(enforce.isNotBlank().run(value).pass).toBe(false); + }); + }); + + describe('non-blank values', () => { + it('pass for numbers', () => { + expect(enforce.isNotBlank().run(0).pass).toBe(true); + expect(enforce.isNotBlank().run(123).pass).toBe(true); + expect(enforce.isNotBlank().run(-1).pass).toBe(true); + }); + + it('pass for booleans', () => { + expect(enforce.isNotBlank().run(true).pass).toBe(true); + expect(enforce.isNotBlank().run(false).pass).toBe(true); + }); + + it('pass for objects', () => { + expect(enforce.isNotBlank().run({}).pass).toBe(true); + expect(enforce.isNotBlank().run({ a: 1 }).pass).toBe(true); + }); + + it('pass for arrays', () => { + expect(enforce.isNotBlank().run([]).pass).toBe(true); + expect(enforce.isNotBlank().run([1, 2, 3]).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isEmpty.test.ts b/packages/n4s/src/rules/general/__tests__/isEmpty.test.ts new file mode 100644 index 000000000..5c8827202 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isEmpty.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isEmpty', () => { + describe('arrays', () => { + it('pass for empty arrays', () => { + expect(enforce.isEmpty().run([]).pass).toBe(true); + }); + + it('fails for non-empty arrays', () => { + expect(enforce.isEmpty().run([1]).pass).toBe(false); + expect(enforce.isEmpty().run([null]).pass).toBe(false); + }); + }); + + describe('strings', () => { + it('pass for empty strings', () => { + expect(enforce.isEmpty().run('').pass).toBe(true); + }); + + it('fails for non-empty strings', () => { + expect(enforce.isEmpty().run('a').pass).toBe(false); + expect(enforce.isEmpty().run(' ').pass).toBe(false); + }); + }); + + describe('objects', () => { + it('pass for empty objects', () => { + expect(enforce.isEmpty().run({}).pass).toBe(true); + }); + + it('fails for objects with keys', () => { + expect(enforce.isEmpty().run({ a: 1 }).pass).toBe(false); + expect(enforce.isEmpty().run({ a: undefined }).pass).toBe(false); + }); + }); + + describe('numbers', () => { + it('pass for zero', () => { + expect(enforce.isEmpty().run(0).pass).toBe(true); + }); + + it('pass for NaN', () => { + expect(enforce.isEmpty().run(NaN).pass).toBe(true); + }); + + it('fails for non-zero numbers', () => { + expect(enforce.isEmpty().run(2).pass).toBe(false); + expect(enforce.isEmpty().run(-1).pass).toBe(false); + expect(enforce.isEmpty().run(Infinity).pass).toBe(false); + }); + }); + + describe('booleans', () => { + it('pass for false', () => { + expect(enforce.isEmpty().run(false).pass).toBe(true); + }); + + it('fails for true', () => { + expect(enforce.isEmpty().run(true).pass).toBe(false); + }); + }); + + describe('nullish values', () => { + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isEmpty().run(value).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isEmpty().run(value).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isFalsy.test.ts b/packages/n4s/src/rules/general/__tests__/isFalsy.test.ts new file mode 100644 index 000000000..19d1332dc --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isFalsy.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isFalsy', () => { + it('pass for zero', () => { + expect(enforce.isFalsy().run(0).pass).toBe(true); + expect(enforce.isFalsy().run(-0).pass).toBe(true); + }); + + it('pass for empty string', () => { + expect(enforce.isFalsy().run('').pass).toBe(true); + }); + + it('pass for false', () => { + expect(enforce.isFalsy().run(false).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isFalsy().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isFalsy().run(value).pass).toBe(true); + }); + + it('pass for NaN', () => { + expect(enforce.isFalsy().run(NaN).pass).toBe(true); + }); + + it('fails for truthy numbers', () => { + expect(enforce.isFalsy().run(1).pass).toBe(false); + expect(enforce.isFalsy().run(-1).pass).toBe(false); + }); + + it('fails for truthy strings', () => { + expect(enforce.isFalsy().run('a').pass).toBe(false); + expect(enforce.isFalsy().run('0').pass).toBe(false); + }); + + it('fails for objects and arrays', () => { + expect(enforce.isFalsy().run({}).pass).toBe(false); + expect(enforce.isFalsy().run([]).pass).toBe(false); + }); + + it('fails for true', () => { + expect(enforce.isFalsy().run(true).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isNotBoolean.test.ts b/packages/n4s/src/rules/general/__tests__/isNotBoolean.test.ts new file mode 100644 index 000000000..c709acfa4 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isNotBoolean.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotBoolean', () => { + it('fails for booleans', () => { + expect(enforce.isNotBoolean().run(true).pass).toBe(false); + expect(enforce.isNotBoolean().run(false).pass).toBe(false); + }); + + describe('pass for non-boolean types', () => { + it('pass for numbers', () => { + expect(enforce.isNotBoolean().run(0).pass).toBe(true); + expect(enforce.isNotBoolean().run(1).pass).toBe(true); + expect(enforce.isNotBoolean().run(42).pass).toBe(true); + expect(enforce.isNotBoolean().run(NaN).pass).toBe(true); + }); + + it('pass for strings', () => { + const text: any = 'a'; + const empty: any = ''; + const truthy: any = 'true'; + const falsy: any = 'false'; + expect(enforce.isNotBoolean().run(text).pass).toBe(true); + expect(enforce.isNotBoolean().run(empty).pass).toBe(true); + expect(enforce.isNotBoolean().run(truthy).pass).toBe(true); + expect(enforce.isNotBoolean().run(falsy).pass).toBe(true); + }); + + it('pass for objects', () => { + expect(enforce.isNotBoolean().run({}).pass).toBe(true); + expect(enforce.isNotBoolean().run({ a: 1 }).pass).toBe(true); + }); + + it('pass for arrays', () => { + expect(enforce.isNotBoolean().run([]).pass).toBe(true); + expect(enforce.isNotBoolean().run([true, false]).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotBoolean().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotBoolean().run(value).pass).toBe(true); + }); + + it('pass for functions', () => { + const fn: any = () => true; + expect(enforce.isNotBoolean().run(fn).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isNotEmpty.test.ts b/packages/n4s/src/rules/general/__tests__/isNotEmpty.test.ts new file mode 100644 index 000000000..12f23bf71 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isNotEmpty.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotEmpty', () => { + describe('arrays', () => { + it('fails for empty arrays', () => { + expect(enforce.isNotEmpty().run([]).pass).toBe(false); + }); + + it('pass for non-empty arrays', () => { + expect(enforce.isNotEmpty().run([1]).pass).toBe(true); + expect(enforce.isNotEmpty().run([null]).pass).toBe(true); + expect(enforce.isNotEmpty().run(['a']).pass).toBe(true); + }); + }); + + describe('strings', () => { + it('fails for empty strings', () => { + expect(enforce.isNotEmpty().run('').pass).toBe(false); + }); + + it('pass for non-empty strings', () => { + expect(enforce.isNotEmpty().run('a').pass).toBe(true); + expect(enforce.isNotEmpty().run(' ').pass).toBe(true); + expect(enforce.isNotEmpty().run('hello').pass).toBe(true); + }); + }); + + describe('objects', () => { + it('fails for empty objects', () => { + expect(enforce.isNotEmpty().run({}).pass).toBe(false); + }); + + it('pass for objects with keys', () => { + expect(enforce.isNotEmpty().run({ a: 1 }).pass).toBe(true); + expect(enforce.isNotEmpty().run({ a: undefined }).pass).toBe(true); + expect(enforce.isNotEmpty().run({ a: null }).pass).toBe(true); + }); + }); + + describe('numbers', () => { + it('fails for zero', () => { + expect(enforce.isNotEmpty().run(0).pass).toBe(false); + }); + + it('fails for NaN', () => { + expect(enforce.isNotEmpty().run(NaN).pass).toBe(false); + }); + + it('pass for non-zero numbers', () => { + expect(enforce.isNotEmpty().run(2).pass).toBe(true); + expect(enforce.isNotEmpty().run(-1).pass).toBe(true); + expect(enforce.isNotEmpty().run(Infinity).pass).toBe(true); + expect(enforce.isNotEmpty().run(42).pass).toBe(true); + }); + }); + + describe('booleans', () => { + it('fails for false', () => { + expect(enforce.isNotEmpty().run(false).pass).toBe(false); + }); + + it('pass for true', () => { + expect(enforce.isNotEmpty().run(true).pass).toBe(true); + }); + }); + + describe('nullish values', () => { + it('fails for undefined', () => { + const value: any = undefined; + expect(enforce.isNotEmpty().run(value).pass).toBe(false); + }); + + it('fails for null', () => { + const value: any = null; + expect(enforce.isNotEmpty().run(value).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isNotNaN.test.ts b/packages/n4s/src/rules/general/__tests__/isNotNaN.test.ts new file mode 100644 index 000000000..f2356ddbc --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isNotNaN.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotNaN', () => { + it('fails for NaN', () => { + expect(enforce.isNotNaN().run(NaN).pass).toBe(false); + }); + + describe('pass for numbers', () => { + it('pass for zero', () => { + expect(enforce.isNotNaN().run(0).pass).toBe(true); + }); + + it('pass for positive numbers', () => { + expect(enforce.isNotNaN().run(1).pass).toBe(true); + expect(enforce.isNotNaN().run(42).pass).toBe(true); + expect(enforce.isNotNaN().run(3.14).pass).toBe(true); + }); + + it('pass for negative numbers', () => { + expect(enforce.isNotNaN().run(-1).pass).toBe(true); + expect(enforce.isNotNaN().run(-42).pass).toBe(true); + }); + + it('pass for Infinity', () => { + expect(enforce.isNotNaN().run(Infinity).pass).toBe(true); + expect(enforce.isNotNaN().run(-Infinity).pass).toBe(true); + }); + }); + + describe('pass for non-number types', () => { + it('pass for strings', () => { + const text: any = 'text'; + const numStr: any = '123'; + expect(enforce.isNotNaN().run(text).pass).toBe(true); + expect(enforce.isNotNaN().run(numStr).pass).toBe(true); + }); + + it('pass for booleans', () => { + expect(enforce.isNotNaN().run(true).pass).toBe(true); + expect(enforce.isNotNaN().run(false).pass).toBe(true); + }); + + it('pass for objects', () => { + expect(enforce.isNotNaN().run({}).pass).toBe(true); + expect(enforce.isNotNaN().run({ a: 1 }).pass).toBe(true); + }); + + it('pass for arrays', () => { + expect(enforce.isNotNaN().run([]).pass).toBe(true); + expect(enforce.isNotNaN().run([1, 2]).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotNaN().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotNaN().run(value).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isNotNumber.test.ts b/packages/n4s/src/rules/general/__tests__/isNotNumber.test.ts new file mode 100644 index 000000000..b54278c28 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isNotNumber.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotNumber', () => { + describe('fails for numbers', () => { + it('fails for zero', () => { + expect(enforce.isNotNumber().run(0).pass).toBe(false); + }); + + it('fails for positive numbers', () => { + expect(enforce.isNotNumber().run(1).pass).toBe(false); + expect(enforce.isNotNumber().run(42).pass).toBe(false); + expect(enforce.isNotNumber().run(3.14).pass).toBe(false); + }); + + it('fails for negative numbers', () => { + expect(enforce.isNotNumber().run(-1).pass).toBe(false); + expect(enforce.isNotNumber().run(-42).pass).toBe(false); + }); + + it('fails for Infinity', () => { + expect(enforce.isNotNumber().run(Infinity).pass).toBe(false); + expect(enforce.isNotNumber().run(-Infinity).pass).toBe(false); + }); + }); + + describe('pass for non-number types', () => { + it('pass for NaN', () => { + expect(enforce.isNotNumber().run(NaN).pass).toBe(true); + }); + + it('pass for numeric strings', () => { + const str: any = '123'; + const float: any = '3.14'; + expect(enforce.isNotNumber().run(str).pass).toBe(true); + expect(enforce.isNotNumber().run(float).pass).toBe(true); + }); + + it('pass for non-numeric strings', () => { + const text: any = 'a'; + const empty: any = ''; + expect(enforce.isNotNumber().run(text).pass).toBe(true); + expect(enforce.isNotNumber().run(empty).pass).toBe(true); + }); + + it('pass for booleans', () => { + expect(enforce.isNotNumber().run(true).pass).toBe(true); + expect(enforce.isNotNumber().run(false).pass).toBe(true); + }); + + it('pass for objects', () => { + expect(enforce.isNotNumber().run({}).pass).toBe(true); + expect(enforce.isNotNumber().run({ a: 1 }).pass).toBe(true); + }); + + it('pass for arrays', () => { + expect(enforce.isNotNumber().run([]).pass).toBe(true); + expect(enforce.isNotNumber().run([1, 2]).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotNumber().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotNumber().run(value).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isNotNumeric.test.ts b/packages/n4s/src/rules/general/__tests__/isNotNumeric.test.ts new file mode 100644 index 000000000..dfce0de24 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isNotNumeric.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotNumeric', () => { + describe('fails for numeric values', () => { + it('fails for numbers', () => { + expect(enforce.isNotNumeric().run(0).pass).toBe(false); + expect(enforce.isNotNumeric().run(42).pass).toBe(false); + expect(enforce.isNotNumeric().run(-1).pass).toBe(false); + expect(enforce.isNotNumeric().run(3.14).pass).toBe(false); + }); + + it('fails for numeric strings', () => { + expect(enforce.isNotNumeric().run('0').pass).toBe(false); + expect(enforce.isNotNumeric().run('42').pass).toBe(false); + expect(enforce.isNotNumeric().run('-1').pass).toBe(false); + expect(enforce.isNotNumeric().run('3.14').pass).toBe(false); + }); + + it('fails for Infinity', () => { + expect(enforce.isNotNumeric().run(Infinity).pass).toBe(false); + expect(enforce.isNotNumeric().run(-Infinity).pass).toBe(false); + }); + }); + + describe('pass for non-numeric values', () => { + it('pass for NaN', () => { + const nan: any = NaN; + expect(enforce.isNotNumeric().run(nan).pass).toBe(true); + }); + + it('pass for non-numeric strings', () => { + expect(enforce.isNotNumeric().run('a').pass).toBe(true); + expect(enforce.isNotNumeric().run('hello').pass).toBe(true); + expect(enforce.isNotNumeric().run('').pass).toBe(true); + expect(enforce.isNotNumeric().run('NaN').pass).toBe(true); + }); + + it('pass for booleans', () => { + expect(enforce.isNotNumeric().run(true).pass).toBe(true); + expect(enforce.isNotNumeric().run(false).pass).toBe(true); + }); + + it('pass for objects', () => { + expect(enforce.isNotNumeric().run({}).pass).toBe(true); + expect(enforce.isNotNumeric().run({ a: 1 }).pass).toBe(true); + }); + + it('pass for arrays', () => { + expect(enforce.isNotNumeric().run([]).pass).toBe(true); + expect(enforce.isNotNumeric().run([1, 2]).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotNumeric().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotNumeric().run(value).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isNotString.test.ts b/packages/n4s/src/rules/general/__tests__/isNotString.test.ts new file mode 100644 index 000000000..369462684 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isNotString.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotString', () => { + it('fails for strings', () => { + expect(enforce.isNotString().run('a').pass).toBe(false); + expect(enforce.isNotString().run('').pass).toBe(false); + expect(enforce.isNotString().run('hello').pass).toBe(false); + expect(enforce.isNotString().run('123').pass).toBe(false); + }); + + describe('pass for non-string types', () => { + it('pass for numbers', () => { + const num: any = 1; + const zero: any = 0; + const nan: any = NaN; + expect(enforce.isNotString().run(num).pass).toBe(true); + expect(enforce.isNotString().run(zero).pass).toBe(true); + expect(enforce.isNotString().run(nan).pass).toBe(true); + }); + + it('pass for booleans', () => { + const t: any = true; + const f: any = false; + expect(enforce.isNotString().run(t).pass).toBe(true); + expect(enforce.isNotString().run(f).pass).toBe(true); + }); + + it('pass for objects', () => { + const obj: any = {}; + const filled: any = { a: 1 }; + expect(enforce.isNotString().run(obj).pass).toBe(true); + expect(enforce.isNotString().run(filled).pass).toBe(true); + }); + + it('pass for arrays', () => { + const arr: any = []; + const filled: any = [1, 2]; + expect(enforce.isNotString().run(arr).pass).toBe(true); + expect(enforce.isNotString().run(filled).pass).toBe(true); + }); + + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotString().run(value).pass).toBe(true); + }); + + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotString().run(value).pass).toBe(true); + }); + + it('pass for functions', () => { + const fn: any = () => 'string'; + expect(enforce.isNotString().run(fn).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/general/__tests__/isTruthy.test.ts b/packages/n4s/src/rules/general/__tests__/isTruthy.test.ts new file mode 100644 index 000000000..8493afc30 --- /dev/null +++ b/packages/n4s/src/rules/general/__tests__/isTruthy.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isTruthy', () => { + it('pass for truthy numbers', () => { + expect(enforce.isTruthy().run(1).pass).toBe(true); + expect(enforce.isTruthy().run(-1).pass).toBe(true); + expect(enforce.isTruthy().run(Infinity).pass).toBe(true); + }); + + it('pass for truthy strings', () => { + expect(enforce.isTruthy().run('a').pass).toBe(true); + expect(enforce.isTruthy().run('0').pass).toBe(true); + expect(enforce.isTruthy().run('false').pass).toBe(true); + }); + + it('pass for objects and arrays', () => { + expect(enforce.isTruthy().run({}).pass).toBe(true); + expect(enforce.isTruthy().run([]).pass).toBe(true); + expect(enforce.isTruthy().run(() => {}).pass).toBe(true); + }); + + it('pass for true', () => { + expect(enforce.isTruthy().run(true).pass).toBe(true); + }); + + it('fails for zero', () => { + expect(enforce.isTruthy().run(0).pass).toBe(false); + }); + + it('fails for empty string', () => { + expect(enforce.isTruthy().run('').pass).toBe(false); + }); + + it('fails for false', () => { + expect(enforce.isTruthy().run(false).pass).toBe(false); + }); + + it('fails for null', () => { + const value: any = null; + expect(enforce.isTruthy().run(value).pass).toBe(false); + }); + + it('fails for undefined', () => { + const value: any = undefined; + expect(enforce.isTruthy().run(value).pass).toBe(false); + }); + + it('fails for NaN', () => { + expect(enforce.isTruthy().run(NaN).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/general/condition.ts b/packages/n4s/src/rules/general/condition.ts new file mode 100644 index 000000000..9bf50cce6 --- /dev/null +++ b/packages/n4s/src/rules/general/condition.ts @@ -0,0 +1,11 @@ +// Runs custom validation function, returns false if callback throws +export function condition( + value: any, + callback: (value: any) => boolean, +): boolean { + try { + return callback(value); + } catch { + return false; + } +} diff --git a/packages/n4s/src/rules/general/equals.ts b/packages/n4s/src/rules/general/equals.ts new file mode 100644 index 000000000..5657288a3 --- /dev/null +++ b/packages/n4s/src/rules/general/equals.ts @@ -0,0 +1,4 @@ +// Validates that two values are strictly equal (===) +export function equals(value: T, v: T): boolean { + return value === v; +} diff --git a/packages/n4s/src/rules/general/isBlank.ts b/packages/n4s/src/rules/general/isBlank.ts new file mode 100644 index 000000000..b8cd1da98 --- /dev/null +++ b/packages/n4s/src/rules/general/isBlank.ts @@ -0,0 +1,9 @@ +import { BlankValue, isNullish, isStringValue } from 'vest-utils'; + +export function isBlank(value: unknown): value is BlankValue { + return isNullish(value) || (isStringValue(value) && !value.trim()); +} + +export function isNotBlank(value: unknown): boolean { + return !isBlank(value); +} diff --git a/packages/n4s/src/rules/general/isEmpty.ts b/packages/n4s/src/rules/general/isEmpty.ts new file mode 100644 index 000000000..3e8bcfdbe --- /dev/null +++ b/packages/n4s/src/rules/general/isEmpty.ts @@ -0,0 +1,6 @@ +import { isEmpty as isEmptyValue } from 'vest-utils'; + +// Validates that a value is empty (empty string, array, object, null, or undefined) +export function isEmpty(value: any): boolean { + return isEmptyValue(value); +} diff --git a/packages/n4s/src/rules/general/isFalsy.ts b/packages/n4s/src/rules/general/isFalsy.ts new file mode 100644 index 000000000..b72a61b72 --- /dev/null +++ b/packages/n4s/src/rules/general/isFalsy.ts @@ -0,0 +1,4 @@ +// Validates that a value is falsy (false, 0, '', null, undefined, or NaN) +export function isFalsy(value: any): boolean { + return !value; +} diff --git a/packages/n4s/src/rules/general/isNaN.ts b/packages/n4s/src/rules/general/isNaN.ts new file mode 100644 index 000000000..41edf5d96 --- /dev/null +++ b/packages/n4s/src/rules/general/isNaN.ts @@ -0,0 +1,9 @@ +import { RuleInstance } from 'RuleInstance'; +import { toNumber } from 'toNumber'; + +export interface NaNRuleInstance extends RuleInstance {} + +// Validates that a value is NaN (Not a Number) +export function isNaN(value: number | string): boolean { + return Number.isNaN(toNumber(value)); +} diff --git a/packages/n4s/src/rules/general/isNotArray.ts b/packages/n4s/src/rules/general/isNotArray.ts new file mode 100644 index 000000000..77e584deb --- /dev/null +++ b/packages/n4s/src/rules/general/isNotArray.ts @@ -0,0 +1,17 @@ +/** + * Validates that a value is not an array. + * Inverse of isArray. + * + * @param value - Value to validate + * @returns True if value is not an array + * + * @example + * ```typescript + * enforce({}).isNotArray(); // passes + * enforce('hello').isNotArray(); // passes + * enforce([1, 2, 3]).isNotArray(); // fails + * ``` + */ +export function isNotArray(value: any): boolean { + return !Array.isArray(value); +} diff --git a/packages/n4s/src/rules/general/isNotBoolean.ts b/packages/n4s/src/rules/general/isNotBoolean.ts new file mode 100644 index 000000000..f7dd5ad91 --- /dev/null +++ b/packages/n4s/src/rules/general/isNotBoolean.ts @@ -0,0 +1,18 @@ +/** + * Validates that a value is not a boolean. + * Inverse of isBoolean. + * + * @param value - Value to validate + * @returns True if value is not a boolean + * + * @example + * ```typescript + * enforce(1).isNotBoolean(); // passes + * enforce('true').isNotBoolean(); // passes + * enforce(true).isNotBoolean(); // fails + * enforce(false).isNotBoolean(); // fails + * ``` + */ +export function isNotBoolean(value: any): boolean { + return typeof value !== 'boolean'; +} diff --git a/packages/n4s/src/rules/general/isNotEmpty.ts b/packages/n4s/src/rules/general/isNotEmpty.ts new file mode 100644 index 000000000..9821bdb22 --- /dev/null +++ b/packages/n4s/src/rules/general/isNotEmpty.ts @@ -0,0 +1,6 @@ +import { isNotEmpty as isNotEmptyValue } from 'vest-utils'; + +// Checks if value is not empty (not null, undefined, empty string, empty array, or empty object) +export function isNotEmpty(value: any): boolean { + return isNotEmptyValue(value); +} diff --git a/packages/n4s/src/rules/general/isNotNaN.ts b/packages/n4s/src/rules/general/isNotNaN.ts new file mode 100644 index 000000000..1a9ffbd92 --- /dev/null +++ b/packages/n4s/src/rules/general/isNotNaN.ts @@ -0,0 +1,6 @@ +import { toNumber } from 'toNumber'; + +// Validates that a value is not NaN +export function isNotNaN(value: any): boolean { + return !Number.isNaN(toNumber(value)); +} diff --git a/packages/n4s/src/rules/general/isNotNull.ts b/packages/n4s/src/rules/general/isNotNull.ts new file mode 100644 index 000000000..2a956021d --- /dev/null +++ b/packages/n4s/src/rules/general/isNotNull.ts @@ -0,0 +1,20 @@ +import { isNotNull as isNotNullValue } from 'vest-utils'; + +/** + * Validates that a value is not null. + * Inverse of isNull. Note: undefined passes this check. + * + * @param value - Value to validate + * @returns True if value is not null + * + * @example + * ```typescript + * enforce(undefined).isNotNull(); // passes + * enforce(0).isNotNull(); // passes + * enforce('').isNotNull(); // passes + * enforce(null).isNotNull(); // fails + * ``` + */ +export function isNotNull(value: any): boolean { + return isNotNullValue(value); +} diff --git a/packages/n4s/src/rules/general/isNotNullish.ts b/packages/n4s/src/rules/general/isNotNullish.ts new file mode 100644 index 000000000..bbad63242 --- /dev/null +++ b/packages/n4s/src/rules/general/isNotNullish.ts @@ -0,0 +1,21 @@ +import { isNotNullish as isNotNullishValue } from 'vest-utils'; + +/** + * Validates that a value is not nullish (not null and not undefined). + * Inverse of isNullish. + * + * @param value - Value to validate + * @returns True if value is neither null nor undefined + * + * @example + * ```typescript + * enforce(0).isNotNullish(); // passes + * enforce('').isNotNullish(); // passes + * enforce(false).isNotNullish(); // passes + * enforce(null).isNotNullish(); // fails + * enforce(undefined).isNotNullish(); // fails + * ``` + */ +export function isNotNullish(value: any): boolean { + return isNotNullishValue(value); +} diff --git a/packages/n4s/src/rules/general/isNotNumber.ts b/packages/n4s/src/rules/general/isNotNumber.ts new file mode 100644 index 000000000..e224bdf15 --- /dev/null +++ b/packages/n4s/src/rules/general/isNotNumber.ts @@ -0,0 +1,18 @@ +/** + * Validates that a value is not a number (or is NaN). + * Inverse of isNumber. Considers NaN as not a number. + * + * @param value - Value to validate + * @returns True if value is not a number or is NaN + * + * @example + * ```typescript + * enforce('123').isNotNumber(); // passes + * enforce(NaN).isNotNumber(); // passes + * enforce(true).isNotNumber(); // passes + * enforce(42).isNotNumber(); // fails + * ``` + */ +export function isNotNumber(value: any): boolean { + return typeof value !== 'number' || Number.isNaN(value); +} diff --git a/packages/n4s/src/rules/general/isNotNumeric.ts b/packages/n4s/src/rules/general/isNotNumeric.ts new file mode 100644 index 000000000..0d389219a --- /dev/null +++ b/packages/n4s/src/rules/general/isNotNumeric.ts @@ -0,0 +1,27 @@ +import { isNumeric as isNumericValue } from 'vest-utils'; + +/** + * Validates that a value is not numeric (not a number or numeric string). + * Inverse of isNumeric. + * + * @param value - Value to validate + * @returns True if value is not numeric + * + * @example + * ```typescript + * enforce('hello').isNotNumeric(); // passes + * enforce(true).isNotNumeric(); // passes + * enforce(NaN).isNotNumeric(); // passes + * enforce(42).isNotNumeric(); // fails + * enforce('42').isNotNumeric(); // fails + * ``` + */ +export function isNotNumeric(value: any): boolean { + // Accept numbers (including Infinity) and numeric strings as numeric + if (typeof value === 'number') { + // Only NaN is not numeric among numbers + return Number.isNaN(value); + } + // For strings, use the vest-utils isNumeric which excludes Infinity strings + return !isNumericValue(value); +} diff --git a/packages/n4s/src/rules/general/isNotString.ts b/packages/n4s/src/rules/general/isNotString.ts new file mode 100644 index 000000000..6a802a17b --- /dev/null +++ b/packages/n4s/src/rules/general/isNotString.ts @@ -0,0 +1,17 @@ +/** + * Validates that a value is not a string. + * Inverse of isString. + * + * @param value - Value to validate + * @returns True if value is not a string + * + * @example + * ```typescript + * enforce(123).isNotString(); // passes + * enforce([]).isNotString(); // passes + * enforce('hello').isNotString(); // fails + * ``` + */ +export function isNotString(value: any): boolean { + return typeof value !== 'string'; +} diff --git a/packages/n4s/src/rules/general/isNotUndefined.ts b/packages/n4s/src/rules/general/isNotUndefined.ts new file mode 100644 index 000000000..bc91af1cb --- /dev/null +++ b/packages/n4s/src/rules/general/isNotUndefined.ts @@ -0,0 +1,20 @@ +import { isNotUndefined as isNotUndefinedValue } from 'vest-utils'; + +/** + * Validates that a value is not undefined. + * Inverse of isUndefined. Note: null passes this check. + * + * @param value - Value to validate + * @returns True if value is not undefined + * + * @example + * ```typescript + * enforce(null).isNotUndefined(); // passes + * enforce(0).isNotUndefined(); // passes + * enforce('').isNotUndefined(); // passes + * enforce(undefined).isNotUndefined(); // fails + * ``` + */ +export function isNotUndefined(value: any): boolean { + return isNotUndefinedValue(value); +} diff --git a/packages/n4s/src/rules/general/isTruthy.ts b/packages/n4s/src/rules/general/isTruthy.ts new file mode 100644 index 000000000..21098ee37 --- /dev/null +++ b/packages/n4s/src/rules/general/isTruthy.ts @@ -0,0 +1,4 @@ +// Validates that a value is truthy (not false, 0, '', null, undefined, or NaN) +export function isTruthy(value: any): boolean { + return !!value; +} diff --git a/packages/n4s/src/rules/general/notEquals.ts b/packages/n4s/src/rules/general/notEquals.ts new file mode 100644 index 000000000..03cf1a950 --- /dev/null +++ b/packages/n4s/src/rules/general/notEquals.ts @@ -0,0 +1,4 @@ +// Validates that two values are not strictly equal (!==) +export function notEquals(value: T, v: T): boolean { + return value !== v; +} diff --git a/packages/n4s/src/rules/generalRules.ts b/packages/n4s/src/rules/generalRules.ts new file mode 100644 index 000000000..07c68b6e0 --- /dev/null +++ b/packages/n4s/src/rules/generalRules.ts @@ -0,0 +1,25 @@ +import { RuleInstance } from 'RuleInstance'; + +// Common type for rules that accept any value +export interface AnyRuleInstance extends RuleInstance {} + +export { condition } from 'condition'; +export { equals } from 'equals'; +export { notEquals } from 'notEquals'; +export { isEmpty } from 'isEmpty'; +export { isFalsy } from 'isFalsy'; +export { isNaN } from 'isNaN'; +export type { NaNRuleInstance } from 'isNaN'; +export { isNotArray } from 'isNotArray'; +export { isNotBoolean } from 'isNotBoolean'; +export { isNotEmpty } from 'isNotEmpty'; +export { isNotNaN } from 'isNotNaN'; +export { isNotNumber } from 'isNotNumber'; +export { isNotNumeric } from 'isNotNumeric'; +export { isNotString } from 'isNotString'; +export { isTruthy } from 'isTruthy'; +export { isNotNull } from 'isNotNull'; +export { isNotUndefined } from 'isNotUndefined'; +export { isNotNullish } from 'isNotNullish'; +export { isBlank } from 'isBlank'; +export { isNotBlank } from 'isBlank'; diff --git a/packages/n4s/src/rules/inside.ts b/packages/n4s/src/rules/inside.ts deleted file mode 100644 index 0c7097eb4..000000000 --- a/packages/n4s/src/rules/inside.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { isStringValue as isString, bindNot, isArray } from 'vest-utils'; - -export function inside(value: unknown, arg1: string | unknown[]): boolean { - if (isArray(arg1)) { - return arg1.indexOf(value) !== -1; - } - - // both value and arg1 are strings - if (isString(arg1) && isString(value)) { - return arg1.indexOf(value) !== -1; - } - - return false; -} - -export const notInside = bindNot(inside); diff --git a/packages/n4s/src/rules/isBlank.ts b/packages/n4s/src/rules/isBlank.ts deleted file mode 100644 index 109901b03..000000000 --- a/packages/n4s/src/rules/isBlank.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isStringValue, bindNot, isNullish, BlankValue } from 'vest-utils'; - -export function isBlank(value: unknown): value is BlankValue { - return isNullish(value) || (isStringValue(value) && !value.trim()); -} - -export const isNotBlank = bindNot(isBlank); diff --git a/packages/n4s/src/rules/isBoolean.ts b/packages/n4s/src/rules/isBoolean.ts deleted file mode 100644 index 9bf70dc53..000000000 --- a/packages/n4s/src/rules/isBoolean.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { bindNot, isBoolean } from 'vest-utils'; - -export const isNotBoolean = bindNot(isBoolean); -export { isBoolean }; diff --git a/packages/n4s/src/rules/isEven.ts b/packages/n4s/src/rules/isEven.ts deleted file mode 100644 index 2757a7729..000000000 --- a/packages/n4s/src/rules/isEven.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isNumeric } from 'vest-utils'; - -import type { RuleValue } from 'runtimeRules'; - -/** - * Validates that a given value is an even number - */ -export const isEven = (value: RuleValue): boolean => { - if (isNumeric(value)) { - return value % 2 === 0; - } - return false; -}; diff --git a/packages/n4s/src/rules/isKeyOf.ts b/packages/n4s/src/rules/isKeyOf.ts deleted file mode 100644 index c6448cb40..000000000 --- a/packages/n4s/src/rules/isKeyOf.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { bindNot } from 'vest-utils'; - -export function isKeyOf(key: string | symbol | number, obj: any): boolean { - return key in obj; -} - -export const isNotKeyOf = bindNot(isKeyOf); diff --git a/packages/n4s/src/rules/isNaN.ts b/packages/n4s/src/rules/isNaN.ts deleted file mode 100644 index 661276123..000000000 --- a/packages/n4s/src/rules/isNaN.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { bindNot } from 'vest-utils'; - -export function isNaN(value: unknown): boolean { - return Number.isNaN(value); -} - -export const isNotNaN = bindNot(isNaN); diff --git a/packages/n4s/src/rules/isNegative.ts b/packages/n4s/src/rules/isNegative.ts deleted file mode 100644 index a513e2a40..000000000 --- a/packages/n4s/src/rules/isNegative.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { lessThan } from 'lessThan'; - -export function isNegative(value: number | string): boolean { - return lessThan(value, 0); -} diff --git a/packages/n4s/src/rules/isNumber.ts b/packages/n4s/src/rules/isNumber.ts deleted file mode 100644 index beb1bed1b..000000000 --- a/packages/n4s/src/rules/isNumber.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { bindNot } from 'vest-utils'; - -export function isNumber(value: unknown): value is number { - return Boolean(typeof value === 'number'); -} - -export const isNotNumber = bindNot(isNumber); diff --git a/packages/n4s/src/rules/isOdd.ts b/packages/n4s/src/rules/isOdd.ts deleted file mode 100644 index 5f90b7855..000000000 --- a/packages/n4s/src/rules/isOdd.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isNumeric } from 'vest-utils'; - -import type { RuleValue } from 'runtimeRules'; -/** - * Validates that a given value is an odd number - */ -export const isOdd = (value: RuleValue): boolean => { - if (isNumeric(value)) { - return value % 2 !== 0; - } - - return false; -}; diff --git a/packages/n4s/src/rules/isString.ts b/packages/n4s/src/rules/isString.ts deleted file mode 100644 index a98dc6762..000000000 --- a/packages/n4s/src/rules/isString.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { isStringValue as isString, bindNot } from 'vest-utils'; - -export const isNotString = bindNot(isString); -export { isString }; diff --git a/packages/n4s/src/rules/isTruthy.ts b/packages/n4s/src/rules/isTruthy.ts deleted file mode 100644 index b8afc85bd..000000000 --- a/packages/n4s/src/rules/isTruthy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { bindNot } from 'vest-utils'; - -export function isTruthy(value: unknown): boolean { - return !!value; -} - -export const isFalsy = bindNot(isTruthy); diff --git a/packages/n4s/src/rules/isValueOf.ts b/packages/n4s/src/rules/isValueOf.ts deleted file mode 100644 index a7b2e3b48..000000000 --- a/packages/n4s/src/rules/isValueOf.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { bindNot, isNullish } from 'vest-utils'; - -export function isValueOf(value: any, objectToCheck: any): boolean { - if (isNullish(objectToCheck)) { - return false; - } - - for (const key in objectToCheck) { - if (objectToCheck[key] === value) { - return true; - } - } - - return false; -} -export const isNotValueOf = bindNot(isValueOf); diff --git a/packages/n4s/src/rules/longerThanOrEquals.ts b/packages/n4s/src/rules/longerThanOrEquals.ts deleted file mode 100644 index 4e4855dde..000000000 --- a/packages/n4s/src/rules/longerThanOrEquals.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { greaterThanOrEquals } from 'greaterThanOrEquals'; - -export function longerThanOrEquals( - value: string | unknown[], - arg1: string | number, -): boolean { - return greaterThanOrEquals(value.length, arg1); -} diff --git a/packages/n4s/src/rules/matches.ts b/packages/n4s/src/rules/matches.ts deleted file mode 100644 index d462ad6ac..000000000 --- a/packages/n4s/src/rules/matches.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isStringValue as isString, bindNot } from 'vest-utils'; - -export function matches(value: string, regex: RegExp | string): boolean { - if (regex instanceof RegExp) { - return regex.test(value); - } else if (isString(regex)) { - return new RegExp(regex).test(value); - } - return false; -} - -export const notMatches = bindNot(matches); diff --git a/packages/n4s/src/rules/nullish/__tests__/isNotNull.test.ts b/packages/n4s/src/rules/nullish/__tests__/isNotNull.test.ts new file mode 100644 index 000000000..ccf568861 --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/isNotNull.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotNull', () => { + it('pass for undefined', () => { + const value: any = undefined; + expect(enforce.isNotNull().run(value).pass).toBe(true); + }); + + it('pass for all non-null values', () => { + const values: any[] = [ + 0, + '', + false, + NaN, + undefined, + 1, + 'text', + true, + {}, + [], + () => {}, + ]; + + values.forEach(value => { + expect(enforce.isNotNull().run(value).pass).toBe(true); + }); + }); + + it('fails only for null', () => { + expect(enforce.isNotNull().run(null).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/__tests__/isNotNullish.test.ts b/packages/n4s/src/rules/nullish/__tests__/isNotNullish.test.ts new file mode 100644 index 000000000..b964c52b9 --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/isNotNullish.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotNullish', () => { + it('pass for falsy non-nullish values', () => { + const values: any[] = [0, '', false, NaN]; + + values.forEach(value => { + expect(enforce.isNotNullish().run(value).pass).toBe(true); + }); + }); + + it('pass for truthy values', () => { + const values: any[] = [1, 'text', true, {}, [], () => {}]; + + values.forEach(value => { + expect(enforce.isNotNullish().run(value).pass).toBe(true); + }); + }); + + it('fails for null', () => { + expect(enforce.isNotNullish().run(null).pass).toBe(false); + }); + + it('fails for undefined', () => { + expect(enforce.isNotNullish().run(undefined).pass).toBe(false); + + let uninitialized: any; + expect(enforce.isNotNullish().run(uninitialized).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/__tests__/isNotUndefined.test.ts b/packages/n4s/src/rules/nullish/__tests__/isNotUndefined.test.ts new file mode 100644 index 000000000..3d8c1a412 --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/isNotUndefined.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotUndefined', () => { + it('pass for null', () => { + const value: any = null; + expect(enforce.isNotUndefined().run(value).pass).toBe(true); + }); + + it('pass for all defined values', () => { + const values: any[] = [ + null, + 0, + '', + false, + NaN, + 1, + 'text', + true, + {}, + [], + () => {}, + ]; + + values.forEach(value => { + expect(enforce.isNotUndefined().run(value).pass).toBe(true); + }); + }); + + it('fails only for undefined', () => { + expect(enforce.isNotUndefined().run(undefined).pass).toBe(false); + + let uninitialized: any; + expect(enforce.isNotUndefined().run(uninitialized).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/__tests__/isNull.test.ts b/packages/n4s/src/rules/nullish/__tests__/isNull.test.ts new file mode 100644 index 000000000..1760a8f41 --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/isNull.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNull', () => { + it('pass only for null', () => { + expect(enforce.isNull().run(null).pass).toBe(true); + }); + + it('fails for undefined', () => { + const value: null | undefined = undefined; + // Type test: - testing that undefined is rejected by isNull + expect(enforce.isNull().run(value).pass).toBe(false); + }); + + it('fails for falsy primitives', () => { + const zero: null | number = 0; + const emptyString: null | string = ''; + const falseBool: null | boolean = false; + const nanValue: null | number = NaN; + + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(zero).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(emptyString).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(falseBool).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(nanValue).pass).toBe(false); + }); + + it('fails for truthy values', () => { + const num: null | number = 42; + const str: null | string = 'hello'; + const bool: null | boolean = true; + const obj: null | object = {}; + const arr: null | any[] = []; + + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(num).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(str).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(bool).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(obj).pass).toBe(false); + // Type test: - testing that non-null values are rejected + expect(enforce.isNull().run(arr).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/__tests__/isNullish.test.ts b/packages/n4s/src/rules/nullish/__tests__/isNullish.test.ts new file mode 100644 index 000000000..6c9978ec0 --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/isNullish.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNullish', () => { + it('pass for null', () => { + expect(enforce.isNullish().run(null).pass).toBe(true); + }); + + it('pass for undefined', () => { + expect(enforce.isNullish().run(undefined).pass).toBe(true); + + let uninitialized: null | undefined | number; + // Type test: - uninitialized may be number | null | undefined + expect(enforce.isNullish().run(uninitialized).pass).toBe(true); + }); + + it('fails for falsy non-nullish primitives', () => { + const zero: null | undefined | number = 0; + const emptyString: null | undefined | string = ''; + const falseBool: null | undefined | boolean = false; + const nanValue: null | undefined | number = NaN; + + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(zero).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(emptyString).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(falseBool).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(nanValue).pass).toBe(false); + }); + + it('fails for truthy values', () => { + const num: null | undefined | number = 42; + const str: null | undefined | string = 'hello'; + const bool: null | undefined | boolean = true; + const obj: null | undefined | object = {}; + const arr: null | undefined | any[] = []; + + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(num).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(str).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(bool).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(obj).pass).toBe(false); + // Type test: - testing that non-nullish values are rejected + expect(enforce.isNullish().run(arr).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/__tests__/isUndefined.test.ts b/packages/n4s/src/rules/nullish/__tests__/isUndefined.test.ts new file mode 100644 index 000000000..14f7c13ca --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/isUndefined.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isUndefined', () => { + it('pass only for undefined', () => { + expect(enforce.isUndefined().run(undefined).pass).toBe(true); + + let uninitialized: undefined | number; + // Type test: - uninitialized may be number | undefined + expect(enforce.isUndefined().run(uninitialized).pass).toBe(true); + }); + + it('fails for null', () => { + const value: undefined | null = null; + // Type test: - testing that null is rejected by isUndefined + expect(enforce.isUndefined().run(value).pass).toBe(false); + }); + + it('fails for falsy primitives', () => { + const zero: undefined | number = 0; + const emptyString: undefined | string = ''; + const falseBool: undefined | boolean = false; + const nanValue: undefined | number = NaN; + + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(zero).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(emptyString).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(falseBool).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(nanValue).pass).toBe(false); + }); + + it('fails for truthy values', () => { + const num: undefined | number = 42; + const str: undefined | string = 'hello'; + const bool: undefined | boolean = true; + const obj: undefined | object = {}; + const arr: undefined | any[] = []; + + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(num).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(str).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(bool).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(obj).pass).toBe(false); + // Type test: - testing that non-undefined values are rejected + expect(enforce.isUndefined().run(arr).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/__tests__/nullishRules.test.ts b/packages/n4s/src/rules/nullish/__tests__/nullishRules.test.ts new file mode 100644 index 000000000..58caba8b5 --- /dev/null +++ b/packages/n4s/src/rules/nullish/__tests__/nullishRules.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('nullishRules', () => { + it('isNull / isNotNull', () => { + expect(enforce.isNull().run(null).pass).toBe(true); + expect(enforce.isNull().run(undefined).pass).toBe(false); + expect(enforce.isNotNull().run(0).pass).toBe(true); + expect(enforce.isNotNull().run(null).pass).toBe(false); + }); + + it('isUndefined / isNotUndefined', () => { + expect(enforce.isUndefined().run(undefined).pass).toBe(true); + expect(enforce.isUndefined().run(null).pass).toBe(false); + expect(enforce.isNotUndefined().run('x').pass).toBe(true); + expect(enforce.isNotUndefined().run(undefined).pass).toBe(false); + }); + + it('isNullish / isNotNullish', () => { + expect(enforce.isNullish().run(undefined).pass).toBe(true); + expect(enforce.isNullish().run(null).pass).toBe(true); + expect(enforce.isNullish().run(0).pass).toBe(false); + + expect(enforce.isNotNullish().run(0).pass).toBe(true); + expect(enforce.isNotNullish().run('').pass).toBe(true); + expect(enforce.isNotNullish().run(undefined).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/nullish/isNull.ts b/packages/n4s/src/rules/nullish/isNull.ts new file mode 100644 index 000000000..0ac4ace63 --- /dev/null +++ b/packages/n4s/src/rules/nullish/isNull.ts @@ -0,0 +1,25 @@ +import { isNull as isNullValue } from 'vest-utils'; + +/** + * Validates that a value is null. + * Type guard that narrows the type to null. + * + * @param value - Value to validate + * @returns True if value is null + * + * @example + * ```typescript + * // Eager API + * enforce(null).isNull(); // passes + * enforce(undefined).isNull(); // fails + * enforce(0).isNull(); // fails + * + * // Lazy API + * const nullRule = enforce.isNull(); + * nullRule.test(null); // true + * nullRule.test(undefined); // false + * ``` + */ +export function isNull(value: any): value is null { + return isNullValue(value); +} diff --git a/packages/n4s/src/rules/nullish/isNullish.ts b/packages/n4s/src/rules/nullish/isNullish.ts new file mode 100644 index 000000000..a8ef636e2 --- /dev/null +++ b/packages/n4s/src/rules/nullish/isNullish.ts @@ -0,0 +1,28 @@ +import { isNullish as isNullishValue } from 'vest-utils'; + +/** + * Validates that a value is null or undefined (nullish). + * Type guard that narrows the type to null | undefined. + * + * @param value - Value to validate + * @returns True if value is null or undefined + * + * @example + * ```typescript + * // Eager API + * enforce(null).isNullish(); // passes + * enforce(undefined).isNullish(); // passes + * enforce(0).isNullish(); // fails + * enforce('').isNullish(); // fails + * enforce(false).isNullish(); // fails + * + * // Lazy API + * const nullishRule = enforce.isNullish(); + * nullishRule.test(null); // true + * nullishRule.test(undefined); // true + * nullishRule.test(0); // false + * ``` + */ +export function isNullish(value: any): value is null | undefined { + return isNullishValue(value); +} diff --git a/packages/n4s/src/rules/nullish/isUndefined.ts b/packages/n4s/src/rules/nullish/isUndefined.ts new file mode 100644 index 000000000..65cb5ca1c --- /dev/null +++ b/packages/n4s/src/rules/nullish/isUndefined.ts @@ -0,0 +1,30 @@ +import { isUndefined as isUndefinedValue } from 'vest-utils'; + +/** + * Validates that a value is undefined. + * Type guard that narrows the type to undefined. + * + * @param value - Value to validate + * @returns True if value is undefined + * + * @example + * ```typescript + * // Eager API + * enforce(undefined).isUndefined(); // passes + * enforce(null).isUndefined(); // fails + * enforce('').isUndefined(); // fails + * + * // Lazy API + * const undefRule = enforce.isUndefined(); + * undefRule.test(undefined); // true + * undefRule.test(null); // false + * + * // Useful for optional properties + * const schema = enforce.shape({ + * optional: enforce.optional(enforce.isString()) + * }); + * ``` + */ +export function isUndefined(value: any): value is undefined { + return isUndefinedValue(value); +} diff --git a/packages/n4s/src/rules/nullishRules.ts b/packages/n4s/src/rules/nullishRules.ts new file mode 100644 index 000000000..46481b356 --- /dev/null +++ b/packages/n4s/src/rules/nullishRules.ts @@ -0,0 +1,16 @@ +import { RuleInstance } from 'RuleInstance'; + +// Backward-compatible re-exports to avoid breaking existing imports +export { isNull } from 'isNull'; + +export { isUndefined } from 'isUndefined'; + +export { isNullish } from 'isNullish'; + +export interface NullRuleInstance extends RuleInstance {} + +export interface UndefinedRuleInstance + extends RuleInstance {} + +export interface NullishRuleInstance + extends RuleInstance {} diff --git a/packages/n4s/src/rules/number/__tests__/between.test.ts b/packages/n4s/src/rules/number/__tests__/between.test.ts new file mode 100644 index 000000000..8e9c0a87f --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/between.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isBetween', () => { + it('pass when number is between bounds (inclusive)', () => { + expect(enforce.isNumber().isBetween(0, 10).run(5).pass).toBe(true); + expect(enforce.isNumber().isBetween(0, 10).run(0).pass).toBe(true); + expect(enforce.isNumber().isBetween(0, 10).run(10).pass).toBe(true); + expect(enforce.isNumber().isBetween(-5, 5).run(0).pass).toBe(true); + }); + + it('fails when number is outside bounds', () => { + expect(enforce.isNumber().isBetween(0, 10).run(-1).pass).toBe(false); + expect(enforce.isNumber().isBetween(0, 10).run(11).pass).toBe(false); + expect(enforce.isNumber().isBetween(5, 10).run(4).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/greaterThan.test.ts b/packages/n4s/src/rules/number/__tests__/greaterThan.test.ts new file mode 100644 index 000000000..77c3bc9aa --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/greaterThan.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('greaterThan', () => { + it('pass when number is greater', () => { + expect(enforce.isNumber().greaterThan(0).run(1).pass).toBe(true); + expect(enforce.isNumber().greaterThan(5).run(10).pass).toBe(true); + expect(enforce.isNumber().greaterThan(-10).run(-5).pass).toBe(true); + expect(enforce.isNumber().greaterThan(0).run(0.1).pass).toBe(true); + }); + + it('fails when number is not greater', () => { + expect(enforce.isNumber().greaterThan(1).run(0).pass).toBe(false); + expect(enforce.isNumber().greaterThan(5).run(5).pass).toBe(false); + expect(enforce.isNumber().greaterThan(10).run(5).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/greaterThanOrEquals.test.ts b/packages/n4s/src/rules/number/__tests__/greaterThanOrEquals.test.ts new file mode 100644 index 000000000..9731ef520 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/greaterThanOrEquals.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('greaterThanOrEquals', () => { + it('pass when number is greater or equal', () => { + expect(enforce.isNumber().greaterThanOrEquals(0).run(1).pass).toBe(true); + expect(enforce.isNumber().greaterThanOrEquals(5).run(5).pass).toBe(true); + expect(enforce.isNumber().greaterThanOrEquals(5).run(10).pass).toBe(true); + expect(enforce.isNumber().greaterThanOrEquals(0).run(0).pass).toBe(true); + }); + + it('fails when number is less', () => { + expect(enforce.isNumber().greaterThanOrEquals(5).run(4).pass).toBe(false); + expect(enforce.isNumber().greaterThanOrEquals(0).run(-1).pass).toBe(false); + expect(enforce.isNumber().greaterThanOrEquals(10).run(5).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/isEven.test.ts b/packages/n4s/src/rules/number/__tests__/isEven.test.ts new file mode 100644 index 000000000..30f43c589 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/isEven.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isEven', () => { + it('pass for even numbers', () => { + expect(enforce.isNumber().isEven().run(0).pass).toBe(true); + expect(enforce.isNumber().isEven().run(2).pass).toBe(true); + expect(enforce.isNumber().isEven().run(42).pass).toBe(true); + expect(enforce.isNumber().isEven().run(-2).pass).toBe(true); + expect(enforce.isNumber().isEven().run(-100).pass).toBe(true); + }); + + it('fails for odd numbers', () => { + expect(enforce.isNumber().isEven().run(1).pass).toBe(false); + expect(enforce.isNumber().isEven().run(3).pass).toBe(false); + expect(enforce.isNumber().isEven().run(-1).pass).toBe(false); + expect(enforce.isNumber().isEven().run(99).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/isNegative.test.ts b/packages/n4s/src/rules/number/__tests__/isNegative.test.ts new file mode 100644 index 000000000..93ac06b61 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/isNegative.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNegative', () => { + it('pass for negative numbers', () => { + expect(enforce.isNumber().isNegative().run(-1).pass).toBe(true); + expect(enforce.isNumber().isNegative().run(-42).pass).toBe(true); + expect(enforce.isNumber().isNegative().run(-Infinity).pass).toBe(true); + expect(enforce.isNumber().isNegative().run(-0.1).pass).toBe(true); + }); + + it('fails for positive numbers and zero', () => { + expect(enforce.isNumber().isNegative().run(0).pass).toBe(false); + expect(enforce.isNumber().isNegative().run(1).pass).toBe(false); + expect(enforce.isNumber().isNegative().run(42).pass).toBe(false); + expect(enforce.isNumber().isNegative().run(Infinity).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/isNumber.test.ts b/packages/n4s/src/rules/number/__tests__/isNumber.test.ts new file mode 100644 index 000000000..5619c9bad --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/isNumber.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNumber', () => { + describe('base predicate', () => { + it('pass for numbers (non-NaN)', () => { + expect(enforce.isNumber().run(0).pass).toBe(true); + expect(enforce.isNumber().run(1).pass).toBe(true); + expect(enforce.isNumber().run(42).pass).toBe(true); + expect(enforce.isNumber().run(-1).pass).toBe(true); + expect(enforce.isNumber().run(3.14).pass).toBe(true); + expect(enforce.isNumber().run(Infinity).pass).toBe(true); + expect(enforce.isNumber().run(-Infinity).pass).toBe(true); + }); + + it('fails for NaN', () => { + expect(enforce.isNumber().run(NaN).pass).toBe(false); + }); + + it('fails for non-numbers', () => { + const str: any = '1'; + const bool: any = true; + const obj: any = {}; + const arr: any = []; + expect(enforce.isNumber().run(str).pass).toBe(false); + expect(enforce.isNumber().run(bool).pass).toBe(false); + expect(enforce.isNumber().run(obj).pass).toBe(false); + expect(enforce.isNumber().run(arr).pass).toBe(false); + }); + }); + + describe('greaterThan', () => { + it('pass when number is greater', () => { + expect(enforce.isNumber().greaterThan(0).run(1).pass).toBe(true); + expect(enforce.isNumber().greaterThan(5).run(10).pass).toBe(true); + expect(enforce.isNumber().greaterThan(-10).run(-5).pass).toBe(true); + }); + + it('fails when number is not greater', () => { + expect(enforce.isNumber().greaterThan(1).run(0).pass).toBe(false); + expect(enforce.isNumber().greaterThan(5).run(5).pass).toBe(false); + expect(enforce.isNumber().greaterThan(10).run(5).pass).toBe(false); + }); + }); + + describe('greaterThanOrEquals', () => { + it('pass when number is greater or equal', () => { + expect(enforce.isNumber().greaterThanOrEquals(0).run(1).pass).toBe(true); + expect(enforce.isNumber().greaterThanOrEquals(5).run(5).pass).toBe(true); + expect(enforce.isNumber().greaterThanOrEquals(5).run(10).pass).toBe(true); + }); + + it('fails when number is less', () => { + expect(enforce.isNumber().greaterThanOrEquals(5).run(4).pass).toBe(false); + expect(enforce.isNumber().greaterThanOrEquals(0).run(-1).pass).toBe( + false, + ); + }); + }); + + describe('lessThan', () => { + it('pass when number is less', () => { + expect(enforce.isNumber().lessThan(5).run(4).pass).toBe(true); + expect(enforce.isNumber().lessThan(0).run(-1).pass).toBe(true); + expect(enforce.isNumber().lessThan(10).run(5).pass).toBe(true); + }); + + it('fails when number is not less', () => { + expect(enforce.isNumber().lessThan(5).run(5).pass).toBe(false); + expect(enforce.isNumber().lessThan(5).run(6).pass).toBe(false); + }); + }); + + describe('lessThanOrEquals', () => { + it('pass when number is less or equal', () => { + expect(enforce.isNumber().lessThanOrEquals(5).run(4).pass).toBe(true); + expect(enforce.isNumber().lessThanOrEquals(5).run(5).pass).toBe(true); + expect(enforce.isNumber().lessThanOrEquals(1).run(1).pass).toBe(true); + }); + + it('fails when number is greater', () => { + expect(enforce.isNumber().lessThanOrEquals(5).run(6).pass).toBe(false); + expect(enforce.isNumber().lessThanOrEquals(0).run(1).pass).toBe(false); + }); + }); + + describe('isBetween', () => { + it('pass when number is between bounds', () => { + expect(enforce.isNumber().isBetween(0, 10).run(5).pass).toBe(true); + expect(enforce.isNumber().isBetween(0, 10).run(0).pass).toBe(true); + expect(enforce.isNumber().isBetween(0, 10).run(10).pass).toBe(true); + }); + + it('fails when number is outside bounds', () => { + expect(enforce.isNumber().isBetween(0, 10).run(-1).pass).toBe(false); + expect(enforce.isNumber().isBetween(0, 10).run(11).pass).toBe(false); + }); + }); + + describe('numberEquals', () => { + it('pass when numbers are equal', () => { + expect(enforce.isNumber().numberEquals(5).run(5).pass).toBe(true); + expect(enforce.isNumber().numberEquals('2').run(2).pass).toBe(true); + expect(enforce.isNumber().numberEquals(0).run(0).pass).toBe(true); + }); + + it('fails when numbers are not equal', () => { + expect(enforce.isNumber().numberEquals(5).run(4).pass).toBe(false); + expect(enforce.isNumber().numberEquals('2').run(3).pass).toBe(false); + }); + }); + + describe('numberNotEquals', () => { + it('pass when numbers are not equal', () => { + expect(enforce.isNumber().numberNotEquals(5).run(4).pass).toBe(true); + expect(enforce.isNumber().numberNotEquals('2').run(3).pass).toBe(true); + expect(enforce.isNumber().numberNotEquals(0).run(1).pass).toBe(true); + }); + + it('fails when numbers are equal', () => { + expect(enforce.isNumber().numberNotEquals(5).run(5).pass).toBe(false); + expect(enforce.isNumber().numberNotEquals('2').run(2).pass).toBe(false); + }); + }); + + describe('isEven', () => { + it('pass for even numbers', () => { + expect(enforce.isNumber().isEven().run(0).pass).toBe(true); + expect(enforce.isNumber().isEven().run(2).pass).toBe(true); + expect(enforce.isNumber().isEven().run(42).pass).toBe(true); + expect(enforce.isNumber().isEven().run(-2).pass).toBe(true); + }); + + it('fails for odd numbers', () => { + expect(enforce.isNumber().isEven().run(1).pass).toBe(false); + expect(enforce.isNumber().isEven().run(3).pass).toBe(false); + expect(enforce.isNumber().isEven().run(-1).pass).toBe(false); + }); + }); + + describe('isOdd', () => { + it('pass for odd numbers', () => { + expect(enforce.isNumber().isOdd().run(1).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(3).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(-1).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(99).pass).toBe(true); + }); + + it('fails for even numbers', () => { + expect(enforce.isNumber().isOdd().run(0).pass).toBe(false); + expect(enforce.isNumber().isOdd().run(2).pass).toBe(false); + expect(enforce.isNumber().isOdd().run(-2).pass).toBe(false); + }); + }); + + describe('isNegative', () => { + it('pass for negative numbers', () => { + expect(enforce.isNumber().isNegative().run(-1).pass).toBe(true); + expect(enforce.isNumber().isNegative().run(-42).pass).toBe(true); + expect(enforce.isNumber().isNegative().run(-Infinity).pass).toBe(true); + }); + + it('fails for positive numbers and zero', () => { + expect(enforce.isNumber().isNegative().run(0).pass).toBe(false); + expect(enforce.isNumber().isNegative().run(1).pass).toBe(false); + expect(enforce.isNumber().isNegative().run(42).pass).toBe(false); + }); + }); + + describe('isPositive', () => { + it('pass for positive numbers', () => { + expect(enforce.isNumber().isPositive().run(1).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(42).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(Infinity).pass).toBe(true); + }); + + it('fails for zero and negative numbers', () => { + expect(enforce.isNumber().isPositive().run(0).pass).toBe(false); + expect(enforce.isNumber().isPositive().run(-1).pass).toBe(false); + expect(enforce.isNumber().isPositive().run(-42).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/isOdd.test.ts b/packages/n4s/src/rules/number/__tests__/isOdd.test.ts new file mode 100644 index 000000000..9549e58ab --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/isOdd.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isOdd', () => { + it('pass for odd numbers', () => { + expect(enforce.isNumber().isOdd().run(1).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(3).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(-1).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(99).pass).toBe(true); + expect(enforce.isNumber().isOdd().run(-99).pass).toBe(true); + }); + + it('fails for even numbers', () => { + expect(enforce.isNumber().isOdd().run(0).pass).toBe(false); + expect(enforce.isNumber().isOdd().run(2).pass).toBe(false); + expect(enforce.isNumber().isOdd().run(-2).pass).toBe(false); + expect(enforce.isNumber().isOdd().run(42).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/isPositive.test.ts b/packages/n4s/src/rules/number/__tests__/isPositive.test.ts new file mode 100644 index 000000000..1ad31e3d2 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/isPositive.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isPositive', () => { + it('pass for positive numbers', () => { + expect(enforce.isNumber().isPositive().run(1).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(42).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(Infinity).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(0.1).pass).toBe(true); + }); + + it('fails for zero and negative numbers', () => { + expect(enforce.isNumber().isPositive().run(0).pass).toBe(false); + expect(enforce.isNumber().isPositive().run(-1).pass).toBe(false); + expect(enforce.isNumber().isPositive().run(-42).pass).toBe(false); + expect(enforce.isNumber().isPositive().run(-Infinity).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/lessThan.test.ts b/packages/n4s/src/rules/number/__tests__/lessThan.test.ts new file mode 100644 index 000000000..a2cb6d0d2 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/lessThan.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lessThan', () => { + it('pass when number is less', () => { + expect(enforce.isNumber().lessThan(5).run(4).pass).toBe(true); + expect(enforce.isNumber().lessThan(0).run(-1).pass).toBe(true); + expect(enforce.isNumber().lessThan(10).run(5).pass).toBe(true); + expect(enforce.isNumber().lessThan(1).run(0.5).pass).toBe(true); + }); + + it('fails when number is not less', () => { + expect(enforce.isNumber().lessThan(5).run(5).pass).toBe(false); + expect(enforce.isNumber().lessThan(5).run(6).pass).toBe(false); + expect(enforce.isNumber().lessThan(0).run(0).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/lessThanOrEquals.test.ts b/packages/n4s/src/rules/number/__tests__/lessThanOrEquals.test.ts new file mode 100644 index 000000000..3fe5b25da --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/lessThanOrEquals.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lessThanOrEquals', () => { + it('pass when number is less or equal', () => { + expect(enforce.isNumber().lessThanOrEquals(5).run(4).pass).toBe(true); + expect(enforce.isNumber().lessThanOrEquals(5).run(5).pass).toBe(true); + expect(enforce.isNumber().lessThanOrEquals(1).run(1).pass).toBe(true); + expect(enforce.isNumber().lessThanOrEquals(10).run(5).pass).toBe(true); + }); + + it('fails when number is greater', () => { + expect(enforce.isNumber().lessThanOrEquals(5).run(6).pass).toBe(false); + expect(enforce.isNumber().lessThanOrEquals(0).run(1).pass).toBe(false); + expect(enforce.isNumber().lessThanOrEquals(5).run(10).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/notBetween.test.ts b/packages/n4s/src/rules/number/__tests__/notBetween.test.ts new file mode 100644 index 000000000..05dab6597 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/notBetween.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotBetween', () => { + it('pass when number is outside bounds', () => { + expect(enforce.isNumber().isNotBetween(0, 10).run(-1).pass).toBe(true); + expect(enforce.isNumber().isNotBetween(0, 10).run(11).pass).toBe(true); + expect(enforce.isNumber().isNotBetween(5, 10).run(4).pass).toBe(true); + expect(enforce.isNumber().isNotBetween(5, 10).run(15).pass).toBe(true); + }); + + it('fails when number is between bounds (inclusive)', () => { + expect(enforce.isNumber().isNotBetween(0, 10).run(0).pass).toBe(false); + expect(enforce.isNumber().isNotBetween(0, 10).run(5).pass).toBe(false); + expect(enforce.isNumber().isNotBetween(0, 10).run(10).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/numberEquals.test.ts b/packages/n4s/src/rules/number/__tests__/numberEquals.test.ts new file mode 100644 index 000000000..d870b1827 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/numberEquals.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('numberEquals', () => { + it('pass when numbers are equal', () => { + expect(enforce.isNumber().numberEquals(5).run(5).pass).toBe(true); + expect(enforce.isNumber().numberEquals('2').run(2).pass).toBe(true); + expect(enforce.isNumber().numberEquals(0).run(0).pass).toBe(true); + expect(enforce.isNumber().numberEquals(-5).run(-5).pass).toBe(true); + }); + + it('fails when numbers are not equal', () => { + expect(enforce.isNumber().numberEquals(5).run(4).pass).toBe(false); + expect(enforce.isNumber().numberEquals('2').run(3).pass).toBe(false); + expect(enforce.isNumber().numberEquals(0).run(1).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/numberNotEquals.test.ts b/packages/n4s/src/rules/number/__tests__/numberNotEquals.test.ts new file mode 100644 index 000000000..90110fe29 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/numberNotEquals.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('numberNotEquals', () => { + it('pass when numbers are not equal', () => { + expect(enforce.isNumber().numberNotEquals(5).run(4).pass).toBe(true); + expect(enforce.isNumber().numberNotEquals('2').run(3).pass).toBe(true); + expect(enforce.isNumber().numberNotEquals(0).run(1).pass).toBe(true); + expect(enforce.isNumber().numberNotEquals(10).run(-10).pass).toBe(true); + }); + + it('fails when numbers are equal', () => { + expect(enforce.isNumber().numberNotEquals(5).run(5).pass).toBe(false); + expect(enforce.isNumber().numberNotEquals('2').run(2).pass).toBe(false); + expect(enforce.isNumber().numberNotEquals(0).run(0).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/number/__tests__/numberRules.test.ts b/packages/n4s/src/rules/number/__tests__/numberRules.test.ts new file mode 100644 index 000000000..cceeb6571 --- /dev/null +++ b/packages/n4s/src/rules/number/__tests__/numberRules.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('numberRules', () => { + it('pass when all number predicates pass', () => { + expect( + enforce.isNumber().greaterThan(3).lessThanOrEquals(5).run(4).pass, + ).toBe(true); + expect(enforce.isNumber().isBetween(1, 10).isEven().run(4).pass).toBe(true); + expect(enforce.isNumber().isNotBetween(100, 200).run(4).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(1).pass).toBe(true); + }); + + it('fails when any number predicate fails', () => { + expect(enforce.isNumber().greaterThan(3).run(3).pass).toBe(false); + expect(enforce.isNumber().isBetween(1, 2).run(3).pass).toBe(false); + expect(enforce.isNumber().isNotBetween(0, 4).run(4).pass).toBe(false); + expect(enforce.isNumber().isOdd().run(4).pass).toBe(false); + }); + + it('rejects non-number inputs at the root', () => { + // Type test: testing runtime behavior + expect(enforce.isNumber().run('4' as any).pass).toBe(false); + }); + + it('numberEquals / numberNotEquals', () => { + expect(enforce.isNumber().numberEquals(4).run(4).pass).toBe(true); + // Type test: runtime path: string is not a number entry + expect( + enforce + .isNumber() + .numberEquals('4' as any) + .run(4 as any).pass, + ).toBe(true); + expect(enforce.isNumber().numberNotEquals(5).run(4).pass).toBe(true); + expect(enforce.isNumber().numberNotEquals(4).run(4).pass).toBe(false); + }); + + it('isNegative', () => { + expect(enforce.isNumber().isNegative().run(-1).pass).toBe(true); + expect(enforce.isNumber().isPositive().run(-1).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/greaterThanOrEquals.ts b/packages/n4s/src/rules/number/greaterThanOrEquals.ts similarity index 74% rename from packages/n4s/src/rules/greaterThanOrEquals.ts rename to packages/n4s/src/rules/number/greaterThanOrEquals.ts index 43b099cc8..d99fbca9b 100644 --- a/packages/n4s/src/rules/greaterThanOrEquals.ts +++ b/packages/n4s/src/rules/number/greaterThanOrEquals.ts @@ -1,5 +1,6 @@ import { greaterThan, numberEquals } from 'vest-utils'; +// Checks if numeric value is greater than or equal to the given threshold export function greaterThanOrEquals( value: string | number, gte: string | number, diff --git a/packages/n4s/src/rules/isBetween.ts b/packages/n4s/src/rules/number/isBetween.ts similarity index 85% rename from packages/n4s/src/rules/isBetween.ts rename to packages/n4s/src/rules/number/isBetween.ts index e3293a10b..12b2e40c2 100644 --- a/packages/n4s/src/rules/isBetween.ts +++ b/packages/n4s/src/rules/number/isBetween.ts @@ -3,6 +3,7 @@ import { bindNot } from 'vest-utils'; import { greaterThanOrEquals as gte } from 'greaterThanOrEquals'; import { lessThanOrEquals as lte } from 'lessThanOrEquals'; +// Checks if numeric value is within the given range (inclusive) export function isBetween( value: number | string, min: number | string, diff --git a/packages/n4s/src/rules/number/isEven.ts b/packages/n4s/src/rules/number/isEven.ts new file mode 100644 index 000000000..40d70c89c --- /dev/null +++ b/packages/n4s/src/rules/number/isEven.ts @@ -0,0 +1,18 @@ +import { isNumeric } from 'vest-utils'; + +import { toNumber } from 'toNumber'; + +/** + * Validates that a given value is an even number + */ +export const isEven = (value: string | number): boolean => { + if (isNumeric(value)) { + const asNumber = toNumber(value); + + if (asNumber !== null) { + return asNumber % 2 === 0; + } + } + + return false; +}; diff --git a/packages/n4s/src/rules/number/isNegative.ts b/packages/n4s/src/rules/number/isNegative.ts new file mode 100644 index 000000000..be1c7926c --- /dev/null +++ b/packages/n4s/src/rules/number/isNegative.ts @@ -0,0 +1,4 @@ +// Checks if number is less than zero +export function isNegative(value: number): boolean { + return value < 0; +} diff --git a/packages/n4s/src/rules/number/isNotBetween.ts b/packages/n4s/src/rules/number/isNotBetween.ts new file mode 100644 index 000000000..8ac844a62 --- /dev/null +++ b/packages/n4s/src/rules/number/isNotBetween.ts @@ -0,0 +1,3 @@ +export function isNotBetween(value: number, min: number, max: number): boolean { + return value < min || value > max; +} diff --git a/packages/n4s/src/rules/number/isNumber.ts b/packages/n4s/src/rules/number/isNumber.ts new file mode 100644 index 000000000..3b3ef2674 --- /dev/null +++ b/packages/n4s/src/rules/number/isNumber.ts @@ -0,0 +1,27 @@ +/** + * Validates that a value is a number (excluding NaN). + * Type guard that narrows the type to number. + * + * @param value - Value to validate + * @returns True if value is a number and not NaN + * + * @example + * ```typescript + * // Eager API + * enforce(42).isNumber(); // passes + * enforce('42').isNumber(); // fails (string) + * enforce(NaN).isNumber(); // fails (NaN is excluded) + * + * // Lazy API + * const numberRule = enforce.isNumber(); + * numberRule.test(42); // true + * numberRule.test(Infinity); // true + * numberRule.test(NaN); // false + * + * // Chains with number-specific rules + * enforce(25).isNumber().greaterThan(18); + * ``` + */ +export function isNumber(value: any): value is number { + return typeof value === 'number' && !Number.isNaN(value); +} diff --git a/packages/n4s/src/rules/number/isOdd.ts b/packages/n4s/src/rules/number/isOdd.ts new file mode 100644 index 000000000..9fd578515 --- /dev/null +++ b/packages/n4s/src/rules/number/isOdd.ts @@ -0,0 +1,17 @@ +import { isNumeric } from 'vest-utils'; + +import { toNumber } from 'toNumber'; + +/** + * Validates that a given value is an odd number + */ +export const isOdd = (value: string | number): boolean => { + if (isNumeric(value)) { + const asNumber = toNumber(value); + if (asNumber !== null) { + return asNumber % 2 !== 0; + } + } + + return false; +}; diff --git a/packages/n4s/src/rules/number/isPositive.ts b/packages/n4s/src/rules/number/isPositive.ts new file mode 100644 index 000000000..0b6654fff --- /dev/null +++ b/packages/n4s/src/rules/number/isPositive.ts @@ -0,0 +1,4 @@ +// Checks if number is greater than zero +export function isPositive(value: number): boolean { + return value > 0; +} diff --git a/packages/n4s/src/rules/lessThan.ts b/packages/n4s/src/rules/number/lessThan.ts similarity index 76% rename from packages/n4s/src/rules/lessThan.ts rename to packages/n4s/src/rules/number/lessThan.ts index 2e0cc8f22..47a63f99d 100644 --- a/packages/n4s/src/rules/lessThan.ts +++ b/packages/n4s/src/rules/number/lessThan.ts @@ -1,5 +1,6 @@ import { isNumeric } from 'vest-utils'; +// Checks if numeric value is less than the given threshold export function lessThan(value: string | number, lt: string | number): boolean { return isNumeric(value) && isNumeric(lt) && Number(value) < Number(lt); } diff --git a/packages/n4s/src/rules/lessThanOrEquals.ts b/packages/n4s/src/rules/number/lessThanOrEquals.ts similarity index 76% rename from packages/n4s/src/rules/lessThanOrEquals.ts rename to packages/n4s/src/rules/number/lessThanOrEquals.ts index c7aff9e94..7c028d068 100644 --- a/packages/n4s/src/rules/lessThanOrEquals.ts +++ b/packages/n4s/src/rules/number/lessThanOrEquals.ts @@ -2,6 +2,7 @@ import { numberEquals } from 'vest-utils'; import { lessThan } from 'lessThan'; +// Checks if numeric value is less than or equal to the given threshold export function lessThanOrEquals( value: string | number, lte: string | number, diff --git a/packages/n4s/src/rules/number/numberNotEquals.ts b/packages/n4s/src/rules/number/numberNotEquals.ts new file mode 100644 index 000000000..67f5cf714 --- /dev/null +++ b/packages/n4s/src/rules/number/numberNotEquals.ts @@ -0,0 +1,6 @@ +import { numberNotEquals as numberNotEqualsValue } from 'vest-utils'; + +// Checks if numeric value is not equal to the given number (with tolerance for floating-point) +export function numberNotEquals(value: number, n: number | string): boolean { + return numberNotEqualsValue(value, n as any); +} diff --git a/packages/n4s/src/rules/numberRules.ts b/packages/n4s/src/rules/numberRules.ts new file mode 100644 index 000000000..58a296561 --- /dev/null +++ b/packages/n4s/src/rules/numberRules.ts @@ -0,0 +1,89 @@ +import { BuildRuleInstance, ExtractRuleFunctions } from 'RuleInstanceBuilder'; +import { equals } from 'equals'; +import { greaterThanOrEquals } from 'greaterThanOrEquals'; +import { isBetween } from 'isBetween'; +import { isEven } from 'isEven'; +import { isNaN } from 'isNaN'; +import { isNegative } from 'isNegative'; +import { isNotBetween } from 'isNotBetween'; +import { isNotNaN } from 'isNotNaN'; +import { isNumber } from 'isNumber'; +import { isOdd } from 'isOdd'; +import { isPositive } from 'isPositive'; +import { lessThan } from 'lessThan'; +import { lessThanOrEquals } from 'lessThanOrEquals'; +import { numberNotEquals } from 'numberNotEquals'; +import { greaterThan, numberEquals } from 'vest-utils'; + +const gt = greaterThan; +const gte = greaterThanOrEquals; +const lt = lessThan; +const lte = lessThanOrEquals; +const eq = equals; +const neq = numberNotEquals; + +const aliases = { + gt, + gte, + lt, + lte, + eq, + neq, +}; + +export { + gt, + gte, + lt, + lte, + eq, + neq, + equals, + greaterThan, + greaterThanOrEquals, + isBetween, + isEven, + isNaN, + isNegative, + isNotBetween, + isNotNaN, + isNumber, + isOdd, + isPositive, + lessThan, + lessThanOrEquals, + numberEquals, + numberNotEquals, +}; + +const numberRules = { + ...aliases, + equals, + greaterThan, + greaterThanOrEquals, + isBetween, + isEven, + isNaN, + isNegative, + isNotBetween, + isNotNaN, + isNumber, + isOdd, + isPositive, + lessThan, + lessThanOrEquals, + numberEquals, + numberNotEquals, +} as const; + +export type NumberRuleInstance = BuildRuleInstance< + number, + [number], + ExtractRuleFunctions +>; + +export type NumericRuleInstance = BuildRuleInstance< + string | number, + [string | number], + ExtractRuleFunctions +>; diff --git a/packages/n4s/src/rules/numeric/__tests__/between.test.ts b/packages/n4s/src/rules/numeric/__tests__/between.test.ts new file mode 100644 index 000000000..104ba18ae --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/between.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('between (numeric)', () => { + it('pass when numeric string is between', () => { + expect(enforce.isNumeric().isBetween(0, 10).run('5').pass).toBe(true); + expect(enforce.isNumeric().isBetween(0, 10).run('0').pass).toBe(true); + expect(enforce.isNumeric().isBetween(0, 10).run('10').pass).toBe(true); + expect(enforce.isNumeric().isBetween(-5, 5).run('0').pass).toBe(true); + }); + + it('pass when number is between', () => { + expect(enforce.isNumeric().isBetween(0, 10).run(5).pass).toBe(true); + }); + + it('fails when value is outside range', () => { + expect(enforce.isNumeric().isBetween(0, 10).run('-1').pass).toBe(false); + expect(enforce.isNumeric().isBetween(0, 10).run('11').pass).toBe(false); + expect(enforce.isNumeric().isBetween(5, 10).run(4).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/greaterThan.test.ts b/packages/n4s/src/rules/numeric/__tests__/greaterThan.test.ts new file mode 100644 index 000000000..60f930f01 --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/greaterThan.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('greaterThan (numeric)', () => { + it('pass when numeric string is greater', () => { + expect(enforce.isNumeric().greaterThan(1).run('2').pass).toBe(true); + expect(enforce.isNumeric().greaterThan(0).run('5').pass).toBe(true); + expect(enforce.isNumeric().greaterThan(-10).run('-5').pass).toBe(true); + }); + + it('pass when number is greater', () => { + expect(enforce.isNumeric().greaterThan(1).run(2).pass).toBe(true); + expect(enforce.isNumeric().greaterThan(0).run(5).pass).toBe(true); + }); + + it('fails when value is not greater', () => { + expect(enforce.isNumeric().greaterThan(5).run('5').pass).toBe(false); + expect(enforce.isNumeric().greaterThan(5).run('3').pass).toBe(false); + expect(enforce.isNumeric().greaterThan(5).run(3).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/greaterThanOrEquals.test.ts b/packages/n4s/src/rules/numeric/__tests__/greaterThanOrEquals.test.ts new file mode 100644 index 000000000..d936a11eb --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/greaterThanOrEquals.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('greaterThanOrEquals (numeric)', () => { + it('pass when numeric string is greater or equal', () => { + expect(enforce.isNumeric().greaterThanOrEquals(1).run('2').pass).toBe(true); + expect(enforce.isNumeric().greaterThanOrEquals(5).run('5').pass).toBe(true); + expect(enforce.isNumeric().greaterThanOrEquals(0).run('0').pass).toBe(true); + }); + + it('pass when number is greater or equal', () => { + expect(enforce.isNumeric().greaterThanOrEquals(1).run(2).pass).toBe(true); + expect(enforce.isNumeric().greaterThanOrEquals(5).run(5).pass).toBe(true); + }); + + it('fails when value is less', () => { + expect(enforce.isNumeric().greaterThanOrEquals(5).run('4').pass).toBe( + false, + ); + expect(enforce.isNumeric().greaterThanOrEquals(0).run('-1').pass).toBe( + false, + ); + expect(enforce.isNumeric().greaterThanOrEquals(10).run(5).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/isEven.test.ts b/packages/n4s/src/rules/numeric/__tests__/isEven.test.ts new file mode 100644 index 000000000..7453373c7 --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/isEven.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isEven (numeric)', () => { + it('pass for even numeric strings', () => { + expect(enforce.isNumeric().isEven().run('0').pass).toBe(true); + expect(enforce.isNumeric().isEven().run('2').pass).toBe(true); + expect(enforce.isNumeric().isEven().run('42').pass).toBe(true); + expect(enforce.isNumeric().isEven().run('-2').pass).toBe(true); + }); + + it('pass for even numbers', () => { + expect(enforce.isNumeric().isEven().run(2).pass).toBe(true); + expect(enforce.isNumeric().isEven().run(42).pass).toBe(true); + expect(enforce.isNumeric().isEven().run(0).pass).toBe(true); + }); + + it('fails for odd values', () => { + expect(enforce.isNumeric().isEven().run('1').pass).toBe(false); + expect(enforce.isNumeric().isEven().run('3').pass).toBe(false); + expect(enforce.isNumeric().isEven().run(1).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/isNegative.test.ts b/packages/n4s/src/rules/numeric/__tests__/isNegative.test.ts new file mode 100644 index 000000000..1f992efd7 --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/isNegative.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNegative (numeric)', () => { + it('pass for negative numeric strings', () => { + expect(enforce.isNumeric().isNegative().run('-1').pass).toBe(true); + expect(enforce.isNumeric().isNegative().run('-42').pass).toBe(true); + expect(enforce.isNumeric().isNegative().run('-0.5').pass).toBe(true); + }); + + it('pass for negative numbers', () => { + expect(enforce.isNumeric().isNegative().run(-1).pass).toBe(true); + expect(enforce.isNumeric().isNegative().run(-42).pass).toBe(true); + expect(enforce.isNumeric().isNegative().run(-Infinity).pass).toBe(true); + }); + + it('fails for positive values and zero', () => { + expect(enforce.isNumeric().isNegative().run('0').pass).toBe(false); + expect(enforce.isNumeric().isNegative().run('1').pass).toBe(false); + expect(enforce.isNumeric().isNegative().run(1).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/isNumeric.test.ts b/packages/n4s/src/rules/numeric/__tests__/isNumeric.test.ts new file mode 100644 index 000000000..0e8fd4b99 --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/isNumeric.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNumeric', () => { + describe('base predicate', () => { + it('pass for numbers', () => { + expect(enforce.isNumeric().run(0).pass).toBe(true); + expect(enforce.isNumeric().run(143).pass).toBe(true); + expect(enforce.isNumeric().run(-42).pass).toBe(true); + expect(enforce.isNumeric().run(3.14).pass).toBe(true); + expect(enforce.isNumeric().run(Infinity).pass).toBe(true); + expect(enforce.isNumeric().run(-Infinity).pass).toBe(true); + }); + + it('pass for numeric strings', () => { + expect(enforce.isNumeric().run('0').pass).toBe(true); + expect(enforce.isNumeric().run('143').pass).toBe(true); + expect(enforce.isNumeric().run('-42').pass).toBe(true); + expect(enforce.isNumeric().run('3.14').pass).toBe(true); + }); + + it('fails for NaN', () => { + const nan: any = NaN; + expect(enforce.isNumeric().run(nan).pass).toBe(false); + }); + + it('fails for non-numeric strings', () => { + expect(enforce.isNumeric().run('1hello').pass).toBe(false); + expect(enforce.isNumeric().run('hi').pass).toBe(false); + expect(enforce.isNumeric().run('').pass).toBe(false); + expect(enforce.isNumeric().run('abc123').pass).toBe(false); + }); + + it('fails for other types', () => { + // Type test: - testing that non-numeric types are rejected + expect(enforce.isNumeric().run(true).pass).toBe(false); + // Type test: - testing that non-numeric types are rejected + expect(enforce.isNumeric().run(false).pass).toBe(false); + // Type test: - testing that non-numeric types are rejected + expect(enforce.isNumeric().run({}).pass).toBe(false); + // Type test: - testing that non-numeric types are rejected + expect(enforce.isNumeric().run([]).pass).toBe(false); + }); + }); + + describe('greaterThan', () => { + it('pass when numeric string is greater', () => { + expect(enforce.isNumeric().greaterThan(1).run('2').pass).toBe(true); + expect(enforce.isNumeric().greaterThan(0).run('5').pass).toBe(true); + }); + + it('pass when number is greater', () => { + expect(enforce.isNumeric().greaterThan(1).run(2).pass).toBe(true); + expect(enforce.isNumeric().greaterThan(0).run(5).pass).toBe(true); + }); + + it('fails when value is not greater', () => { + expect(enforce.isNumeric().greaterThan(5).run('5').pass).toBe(false); + expect(enforce.isNumeric().greaterThan(5).run('3').pass).toBe(false); + expect(enforce.isNumeric().greaterThan(5).run(3).pass).toBe(false); + }); + }); + + describe('lessThan', () => { + it('pass when numeric string is less', () => { + expect(enforce.isNumeric().lessThan(5).run('3').pass).toBe(true); + expect(enforce.isNumeric().lessThan(0).run('-1').pass).toBe(true); + }); + + it('pass when number is less', () => { + expect(enforce.isNumeric().lessThan(5).run(3).pass).toBe(true); + expect(enforce.isNumeric().lessThan(0).run(-1).pass).toBe(true); + }); + + it('fails when value is not less', () => { + expect(enforce.isNumeric().lessThan(5).run('5').pass).toBe(false); + expect(enforce.isNumeric().lessThan(5).run('6').pass).toBe(false); + }); + }); + + describe('isBetween', () => { + it('pass when numeric string is between', () => { + expect(enforce.isNumeric().isBetween(0, 10).run('5').pass).toBe(true); + expect(enforce.isNumeric().isBetween(0, 10).run('0').pass).toBe(true); + expect(enforce.isNumeric().isBetween(0, 10).run('10').pass).toBe(true); + }); + + it('pass when number is between', () => { + expect(enforce.isNumeric().isBetween(0, 10).run(5).pass).toBe(true); + }); + + it('fails when value is outside range', () => { + expect(enforce.isNumeric().isBetween(0, 10).run('-1').pass).toBe(false); + expect(enforce.isNumeric().isBetween(0, 10).run('11').pass).toBe(false); + }); + }); + + describe('numberEquals', () => { + it('pass when numeric strings are equal', () => { + expect(enforce.isNumeric().numberEquals('2').run('2').pass).toBe(true); + expect(enforce.isNumeric().numberEquals(5).run('5').pass).toBe(true); + }); + + it('pass when number matches', () => { + expect(enforce.isNumeric().numberEquals('2').run(2).pass).toBe(true); + }); + + it('fails when values are not equal', () => { + expect(enforce.isNumeric().numberEquals('2').run('3').pass).toBe(false); + expect(enforce.isNumeric().numberEquals(5).run('4').pass).toBe(false); + }); + }); + + describe('isEven', () => { + it('pass for even numeric strings', () => { + expect(enforce.isNumeric().isEven().run('0').pass).toBe(true); + expect(enforce.isNumeric().isEven().run('2').pass).toBe(true); + expect(enforce.isNumeric().isEven().run('42').pass).toBe(true); + }); + + it('pass for even numbers', () => { + expect(enforce.isNumeric().isEven().run(2).pass).toBe(true); + expect(enforce.isNumeric().isEven().run(42).pass).toBe(true); + }); + + it('fails for odd values', () => { + expect(enforce.isNumeric().isEven().run('1').pass).toBe(false); + expect(enforce.isNumeric().isEven().run('3').pass).toBe(false); + expect(enforce.isNumeric().isEven().run(1).pass).toBe(false); + }); + }); + + describe('isOdd', () => { + it('pass for odd numeric strings', () => { + expect(enforce.isNumeric().isOdd().run('1').pass).toBe(true); + expect(enforce.isNumeric().isOdd().run('3').pass).toBe(true); + expect(enforce.isNumeric().isOdd().run('99').pass).toBe(true); + }); + + it('pass for odd numbers', () => { + expect(enforce.isNumeric().isOdd().run(1).pass).toBe(true); + expect(enforce.isNumeric().isOdd().run(3).pass).toBe(true); + }); + + it('fails for even values', () => { + expect(enforce.isNumeric().isOdd().run('0').pass).toBe(false); + expect(enforce.isNumeric().isOdd().run('2').pass).toBe(false); + expect(enforce.isNumeric().isOdd().run(2).pass).toBe(false); + }); + }); + + describe('isNegative', () => { + it('pass for negative numeric strings', () => { + expect(enforce.isNumeric().isNegative().run('-1').pass).toBe(true); + expect(enforce.isNumeric().isNegative().run('-42').pass).toBe(true); + }); + + it('pass for negative numbers', () => { + expect(enforce.isNumeric().isNegative().run(-1).pass).toBe(true); + expect(enforce.isNumeric().isNegative().run(-42).pass).toBe(true); + }); + + it('fails for positive values and zero', () => { + expect(enforce.isNumeric().isNegative().run('0').pass).toBe(false); + expect(enforce.isNumeric().isNegative().run('1').pass).toBe(false); + expect(enforce.isNumeric().isNegative().run(1).pass).toBe(false); + }); + }); + + describe('isPositive', () => { + it('pass for positive numeric strings', () => { + expect(enforce.isNumeric().isPositive().run('1').pass).toBe(true); + expect(enforce.isNumeric().isPositive().run('42').pass).toBe(true); + }); + + it('pass for positive numbers', () => { + expect(enforce.isNumeric().isPositive().run(1).pass).toBe(true); + expect(enforce.isNumeric().isPositive().run(42).pass).toBe(true); + }); + + it('fails for zero and negative values', () => { + expect(enforce.isNumeric().isPositive().run('0').pass).toBe(false); + expect(enforce.isNumeric().isPositive().run('-1').pass).toBe(false); + expect(enforce.isNumeric().isPositive().run(-1).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/isOdd.test.ts b/packages/n4s/src/rules/numeric/__tests__/isOdd.test.ts new file mode 100644 index 000000000..3fb3bf1e7 --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/isOdd.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isOdd (numeric)', () => { + it('pass for odd numeric strings', () => { + expect(enforce.isNumeric().isOdd().run('1').pass).toBe(true); + expect(enforce.isNumeric().isOdd().run('3').pass).toBe(true); + expect(enforce.isNumeric().isOdd().run('99').pass).toBe(true); + expect(enforce.isNumeric().isOdd().run('-1').pass).toBe(true); + }); + + it('pass for odd numbers', () => { + expect(enforce.isNumeric().isOdd().run(1).pass).toBe(true); + expect(enforce.isNumeric().isOdd().run(3).pass).toBe(true); + expect(enforce.isNumeric().isOdd().run(99).pass).toBe(true); + }); + + it('fails for even values', () => { + expect(enforce.isNumeric().isOdd().run('0').pass).toBe(false); + expect(enforce.isNumeric().isOdd().run('2').pass).toBe(false); + expect(enforce.isNumeric().isOdd().run(2).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/isPositive.test.ts b/packages/n4s/src/rules/numeric/__tests__/isPositive.test.ts new file mode 100644 index 000000000..47235dc0a --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/isPositive.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isPositive (numeric)', () => { + it('pass for positive numeric strings', () => { + expect(enforce.isNumeric().isPositive().run('1').pass).toBe(true); + expect(enforce.isNumeric().isPositive().run('42').pass).toBe(true); + expect(enforce.isNumeric().isPositive().run('0.5').pass).toBe(true); + }); + + it('pass for positive numbers', () => { + expect(enforce.isNumeric().isPositive().run(1).pass).toBe(true); + expect(enforce.isNumeric().isPositive().run(42).pass).toBe(true); + expect(enforce.isNumeric().isPositive().run(Infinity).pass).toBe(true); + }); + + it('fails for zero and negative values', () => { + expect(enforce.isNumeric().isPositive().run('0').pass).toBe(false); + expect(enforce.isNumeric().isPositive().run('-1').pass).toBe(false); + expect(enforce.isNumeric().isPositive().run(-1).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/lessThan.test.ts b/packages/n4s/src/rules/numeric/__tests__/lessThan.test.ts new file mode 100644 index 000000000..b228f707a --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/lessThan.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lessThan (numeric)', () => { + it('pass when numeric string is less', () => { + expect(enforce.isNumeric().lessThan(5).run('3').pass).toBe(true); + expect(enforce.isNumeric().lessThan(0).run('-1').pass).toBe(true); + expect(enforce.isNumeric().lessThan(10).run('5').pass).toBe(true); + }); + + it('pass when number is less', () => { + expect(enforce.isNumeric().lessThan(5).run(3).pass).toBe(true); + expect(enforce.isNumeric().lessThan(0).run(-1).pass).toBe(true); + }); + + it('fails when value is not less', () => { + expect(enforce.isNumeric().lessThan(5).run('5').pass).toBe(false); + expect(enforce.isNumeric().lessThan(5).run('6').pass).toBe(false); + expect(enforce.isNumeric().lessThan(0).run(0).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/lessThanOrEquals.test.ts b/packages/n4s/src/rules/numeric/__tests__/lessThanOrEquals.test.ts new file mode 100644 index 000000000..586168c78 --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/lessThanOrEquals.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lessThanOrEquals (numeric)', () => { + it('pass when numeric string is less or equal', () => { + expect(enforce.isNumeric().lessThanOrEquals(5).run('4').pass).toBe(true); + expect(enforce.isNumeric().lessThanOrEquals(5).run('5').pass).toBe(true); + expect(enforce.isNumeric().lessThanOrEquals(10).run('5').pass).toBe(true); + }); + + it('pass when number is less or equal', () => { + expect(enforce.isNumeric().lessThanOrEquals(5).run(4).pass).toBe(true); + expect(enforce.isNumeric().lessThanOrEquals(5).run(5).pass).toBe(true); + }); + + it('fails when value is greater', () => { + expect(enforce.isNumeric().lessThanOrEquals(5).run('6').pass).toBe(false); + expect(enforce.isNumeric().lessThanOrEquals(0).run('1').pass).toBe(false); + expect(enforce.isNumeric().lessThanOrEquals(5).run(10).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/notBetween.test.ts b/packages/n4s/src/rules/numeric/__tests__/notBetween.test.ts new file mode 100644 index 000000000..8e599391e --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/notBetween.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('notBetween (numeric)', () => { + it('pass when numeric string is outside bounds', () => { + expect(enforce.isNumeric().isNotBetween(0, 10).run('-1').pass).toBe(true); + expect(enforce.isNumeric().isNotBetween(0, 10).run('11').pass).toBe(true); + expect(enforce.isNumeric().isNotBetween(5, 10).run('4').pass).toBe(true); + }); + + it('pass when number is outside bounds', () => { + expect(enforce.isNumeric().isNotBetween(0, 10).run(-1).pass).toBe(true); + expect(enforce.isNumeric().isNotBetween(0, 10).run(11).pass).toBe(true); + }); + + it('fails when value is between bounds', () => { + expect(enforce.isNumeric().isNotBetween(0, 10).run('0').pass).toBe(false); + expect(enforce.isNumeric().isNotBetween(0, 10).run('5').pass).toBe(false); + expect(enforce.isNumeric().isNotBetween(0, 10).run(10).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/numberEquals.test.ts b/packages/n4s/src/rules/numeric/__tests__/numberEquals.test.ts new file mode 100644 index 000000000..570e7c2da --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/numberEquals.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('numberEquals (numeric)', () => { + it('pass when numeric strings are equal', () => { + expect(enforce.isNumeric().numberEquals('2').run('2').pass).toBe(true); + expect(enforce.isNumeric().numberEquals(5).run('5').pass).toBe(true); + expect(enforce.isNumeric().numberEquals(0).run('0').pass).toBe(true); + }); + + it('pass when number matches', () => { + expect(enforce.isNumeric().numberEquals('2').run(2).pass).toBe(true); + }); + + it('fails when values are not equal', () => { + expect(enforce.isNumeric().numberEquals('2').run('3').pass).toBe(false); + expect(enforce.isNumeric().numberEquals(5).run('4').pass).toBe(false); + expect(enforce.isNumeric().numberEquals(0).run(1).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/numberNotEquals.test.ts b/packages/n4s/src/rules/numeric/__tests__/numberNotEquals.test.ts new file mode 100644 index 000000000..6582b3eda --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/numberNotEquals.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('numberNotEquals (numeric)', () => { + it('pass when numeric strings are not equal', () => { + expect(enforce.isNumeric().numberNotEquals('2').run('3').pass).toBe(true); + expect(enforce.isNumeric().numberNotEquals(5).run('4').pass).toBe(true); + expect(enforce.isNumeric().numberNotEquals(0).run('1').pass).toBe(true); + }); + + it('pass when number does not match', () => { + expect(enforce.isNumeric().numberNotEquals('2').run(3).pass).toBe(true); + }); + + it('fails when values are equal', () => { + expect(enforce.isNumeric().numberNotEquals('2').run('2').pass).toBe(false); + expect(enforce.isNumeric().numberNotEquals(5).run('5').pass).toBe(false); + expect(enforce.isNumeric().numberNotEquals('0').run(0).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/__tests__/numericRules.test.ts b/packages/n4s/src/rules/numeric/__tests__/numericRules.test.ts new file mode 100644 index 000000000..63a6c406c --- /dev/null +++ b/packages/n4s/src/rules/numeric/__tests__/numericRules.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('numericRules', () => { + it('accepts numeric strings and numbers', () => { + expect(enforce.isNumeric().isBetween(1, 5).run('3').pass).toBe(true); + expect(enforce.isNumeric().greaterThan(2).run(3).pass).toBe(true); + }); + + it('fails on non-numeric values', () => { + expect(enforce.isNumeric().run('abc').pass).toBe(false); + }); + + it('applies chained predicates after numeric coercion', () => { + expect(enforce.isNumeric().lessThan(10).isEven().run('8').pass).toBe(true); + expect(enforce.isNumeric().isOdd().run('8').pass).toBe(false); + expect(enforce.isNumeric().isNotBetween(1, 8).run('8').pass).toBe(false); // edge excluded for notBetween + expect(enforce.isNumeric().isNotBetween(9, 100).run('8').pass).toBe(true); + }); + + it('numberEquals / numberNotEquals work across numbers and strings', () => { + expect(enforce.isNumeric().numberEquals(8).run('8').pass).toBe(true); + expect(enforce.isNumeric().numberEquals('8').run(8).pass).toBe(true); + expect(enforce.isNumeric().numberNotEquals(9).run('8').pass).toBe(true); + expect(enforce.isNumeric().numberNotEquals('8').run(8).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/numeric/isNumeric.ts b/packages/n4s/src/rules/numeric/isNumeric.ts new file mode 100644 index 000000000..cf9940fd9 --- /dev/null +++ b/packages/n4s/src/rules/numeric/isNumeric.ts @@ -0,0 +1,38 @@ +import { isNumeric as isNumericValue } from 'vest-utils'; + +/** + * Validates that a value is numeric (a number or a numeric string). + * Type guard that narrows the type to number | string. + * Excludes NaN but includes Infinity and numeric strings. + * + * @param value - Value to validate + * @returns True if value is a number or numeric string + * + * @example + * ```typescript + * // Eager API + * enforce(42).isNumeric(); // passes + * enforce('42').isNumeric(); // passes (numeric string) + * enforce('42.5').isNumeric(); // passes + * enforce(Infinity).isNumeric(); // passes + * enforce('hello').isNumeric(); // fails + * enforce(NaN).isNumeric(); // fails + * + * // Lazy API + * const numericRule = enforce.isNumeric(); + * numericRule.test(100); // true + * numericRule.test('100'); // true + * numericRule.test('abc'); // false + * + * // Chains with numeric comparison rules + * enforce('25').isNumeric().greaterThan(18); + * ``` + */ +export function isNumeric(value: any): value is number | string { + // Accept numbers (including Infinity) and numeric strings + if (typeof value === 'number') { + return !Number.isNaN(value); + } + // For strings, use the vest-utils isNumeric which excludes Infinity strings + return isNumericValue(value); +} diff --git a/packages/n4s/src/rules/object/__tests__/isKeyOf.test.ts b/packages/n4s/src/rules/object/__tests__/isKeyOf.test.ts new file mode 100644 index 000000000..a3968bfe7 --- /dev/null +++ b/packages/n4s/src/rules/object/__tests__/isKeyOf.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('object: isKeyOf / isNotKeyOf', () => { + describe('isKeyOf', () => { + it('pass when key exists in object', () => { + const obj = { a: 1, b: 2, c: 3 }; + const keyA: any = 'a'; + const keyB: any = 'b'; + const keyC: any = 'c'; + expect(enforce.isKeyOf(obj).run(keyA).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(keyB).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(keyC).pass).toBe(true); + }); + + it('pass when key exists with falsy value', () => { + const obj = { a: 0, b: false, c: null, d: undefined }; + const keyA: any = 'a'; + const keyB: any = 'b'; + const keyC: any = 'c'; + const keyD: any = 'd'; + expect(enforce.isKeyOf(obj).run(keyA).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(keyB).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(keyC).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(keyD).pass).toBe(true); + }); + + it('pass for numeric keys', () => { + const obj = { 0: 'zero', 1: 'one', 42: 'answer' }; + const key0: any = 0; + const key1: any = 1; + const key42: any = 42; + const keyStr0: any = '0'; + expect(enforce.isKeyOf(obj).run(key0).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(key1).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(key42).pass).toBe(true); + expect(enforce.isKeyOf(obj).run(keyStr0).pass).toBe(true); + }); + + it('pass for symbol keys', () => { + const sym = Symbol('test'); + const obj = { [sym]: 'value' }; + const key: any = sym; + expect(enforce.isKeyOf(obj).run(key).pass).toBe(true); + }); + + it('fails when key does not exist', () => { + const obj = { a: 1, b: 2 }; + const keyC: any = 'c'; + const keyX: any = 'x'; + expect(enforce.isKeyOf(obj).run(keyC).pass).toBe(false); + expect(enforce.isKeyOf(obj).run(keyX).pass).toBe(false); + }); + + it('fails for keys in prototype chain', () => { + const obj = Object.create({ inherited: 'value' }); + obj.own = 'value'; + const inherited: any = 'inherited'; + const own: any = 'own'; + expect(enforce.isKeyOf(obj).run(inherited).pass).toBe(false); + expect(enforce.isKeyOf(obj).run(own).pass).toBe(true); + }); + + it('works with empty objects', () => { + const obj = {}; + const key: any = 'a'; + expect(enforce.isKeyOf(obj).run(key).pass).toBe(false); + }); + + it('works with arrays', () => { + const arr = ['a', 'b', 'c']; + const key0: any = 0; + const key2: any = 2; + const key3: any = 3; + expect(enforce.isKeyOf(arr).run(key0).pass).toBe(true); + expect(enforce.isKeyOf(arr).run(key2).pass).toBe(true); + expect(enforce.isKeyOf(arr).run(key3).pass).toBe(false); + }); + }); + + describe('isNotKeyOf', () => { + it('pass when key does not exist in object', () => { + const obj = { a: 1, b: 2 }; + const keyC: any = 'c'; + const keyX: any = 'x'; + const keyZ: any = 'z'; + expect(enforce.isNotKeyOf(obj).run(keyC).pass).toBe(true); + expect(enforce.isNotKeyOf(obj).run(keyX).pass).toBe(true); + expect(enforce.isNotKeyOf(obj).run(keyZ).pass).toBe(true); + }); + + it('pass for keys in prototype chain', () => { + const obj = Object.create({ inherited: 'value' }); + obj.own = 'value'; + const inherited: any = 'inherited'; + expect(enforce.isNotKeyOf(obj).run(inherited).pass).toBe(true); + }); + + it('fails when key exists in object', () => { + const obj = { a: 1, b: 2, c: 3 }; + const keyA: any = 'a'; + const keyB: any = 'b'; + expect(enforce.isNotKeyOf(obj).run(keyA).pass).toBe(false); + expect(enforce.isNotKeyOf(obj).run(keyB).pass).toBe(false); + }); + + it('fails when key exists with falsy value', () => { + const obj = { a: 0, b: false, c: null }; + const keyA: any = 'a'; + const keyB: any = 'b'; + const keyC: any = 'c'; + expect(enforce.isNotKeyOf(obj).run(keyA).pass).toBe(false); + expect(enforce.isNotKeyOf(obj).run(keyB).pass).toBe(false); + expect(enforce.isNotKeyOf(obj).run(keyC).pass).toBe(false); + }); + + it('works with empty objects', () => { + const obj = {}; + const key: any = 'a'; + expect(enforce.isNotKeyOf(obj).run(key).pass).toBe(true); + }); + + it('works with numeric keys', () => { + const obj = { 0: 'zero', 1: 'one' }; + const key0: any = 0; + const key2: any = 2; + expect(enforce.isNotKeyOf(obj).run(key0).pass).toBe(false); + expect(enforce.isNotKeyOf(obj).run(key2).pass).toBe(true); + }); + }); +}); diff --git a/packages/n4s/src/rules/object/__tests__/isValueOf.test.ts b/packages/n4s/src/rules/object/__tests__/isValueOf.test.ts new file mode 100644 index 000000000..75555474e --- /dev/null +++ b/packages/n4s/src/rules/object/__tests__/isValueOf.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('object: isValueOf / isNotValueOf', () => { + describe('isValueOf', () => { + it('pass when value exists in object', () => { + const obj = { a: 1, b: 2, c: 3 }; + expect(enforce.isValueOf(obj).run(1).pass).toBe(true); + expect(enforce.isValueOf(obj).run(2).pass).toBe(true); + expect(enforce.isValueOf(obj).run(3).pass).toBe(true); + }); + + it('pass for falsy values', () => { + const obj = { a: 0, b: false, c: null, d: undefined, e: '' }; + expect(enforce.isValueOf(obj).run(0).pass).toBe(true); + expect(enforce.isValueOf(obj).run(false).pass).toBe(true); + const nul: any = null; + const undef: any = undefined; + expect(enforce.isValueOf(obj).run(nul).pass).toBe(true); + expect(enforce.isValueOf(obj).run(undef).pass).toBe(true); + expect(enforce.isValueOf(obj).run('').pass).toBe(true); + }); + + it('pass for string values', () => { + const obj = { a: 'hello', b: 'world', c: 'test' }; + expect(enforce.isValueOf(obj).run('hello').pass).toBe(true); + expect(enforce.isValueOf(obj).run('world').pass).toBe(true); + expect(enforce.isValueOf(obj).run('test').pass).toBe(true); + }); + + it('pass for object values', () => { + const subObj1 = { x: 1 }; + const subObj2 = { y: 2 }; + const obj = { a: subObj1, b: subObj2 }; + expect(enforce.isValueOf(obj).run(subObj1).pass).toBe(true); + expect(enforce.isValueOf(obj).run(subObj2).pass).toBe(true); + }); + + it('pass for array values', () => { + const arr1 = [1, 2]; + const arr2 = [3, 4]; + const obj = { a: arr1, b: arr2 }; + expect(enforce.isValueOf(obj).run(arr1).pass).toBe(true); + expect(enforce.isValueOf(obj).run(arr2).pass).toBe(true); + }); + + it('fails when value does not exist', () => { + const obj = { a: 1, b: 2 }; + const val3: any = 3; + const val5: any = 5; + expect(enforce.isValueOf(obj).run(val3).pass).toBe(false); + expect(enforce.isValueOf(obj).run(val5).pass).toBe(false); + }); + + it('fails for values in prototype chain', () => { + const proto = { inherited: 'value' }; + const obj = Object.create(proto); + obj.own = 'ownValue'; + expect(enforce.isValueOf(obj).run('ownValue').pass).toBe(true); + const inherited: any = 'value'; + expect(enforce.isValueOf(obj).run(inherited).pass).toBe(false); + }); + + it('works with empty objects', () => { + const obj = {}; + const val: any = 1; + expect(enforce.isValueOf(obj).run(val).pass).toBe(false); + }); + + it('works with duplicate values', () => { + const obj = { a: 1, b: 1, c: 2 }; + expect(enforce.isValueOf(obj).run(1).pass).toBe(true); + expect(enforce.isValueOf(obj).run(2).pass).toBe(true); + }); + + it('uses strict equality for objects', () => { + const obj = { a: { x: 1 }, b: { x: 1 } }; + const differentObj: any = { x: 1 }; + expect(enforce.isValueOf(obj).run(differentObj).pass).toBe(false); + }); + }); + + describe('isNotValueOf', () => { + it('pass when value does not exist in object', () => { + const obj = { a: 1, b: 2 }; + const val3: any = 3; + const val5: any = 5; + const valStr: any = 'test'; + expect(enforce.isNotValueOf(obj).run(val3).pass).toBe(true); + expect(enforce.isNotValueOf(obj).run(val5).pass).toBe(true); + expect(enforce.isNotValueOf(obj).run(valStr).pass).toBe(true); + }); + + it('pass for values in prototype chain', () => { + const proto = { inherited: 'value' }; + const obj = Object.create(proto); + obj.own = 'ownValue'; + const inherited: any = 'value'; + expect(enforce.isNotValueOf(obj).run(inherited).pass).toBe(true); + }); + + it('fails when value exists in object', () => { + const obj = { a: 1, b: 2, c: 3 }; + expect(enforce.isNotValueOf(obj).run(1).pass).toBe(false); + expect(enforce.isNotValueOf(obj).run(2).pass).toBe(false); + expect(enforce.isNotValueOf(obj).run(3).pass).toBe(false); + }); + + it('fails for falsy values that exist', () => { + const obj = { a: 0, b: false, c: null }; + expect(enforce.isNotValueOf(obj).run(0).pass).toBe(false); + expect(enforce.isNotValueOf(obj).run(false).pass).toBe(false); + const nul: any = null; + expect(enforce.isNotValueOf(obj).run(nul).pass).toBe(false); + }); + + it('works with empty objects', () => { + const obj = {}; + const val: any = 1; + expect(enforce.isNotValueOf(obj).run(val).pass).toBe(true); + }); + + it('uses strict equality for objects', () => { + const targetObj = { x: 1 }; + const obj = { a: targetObj }; + const differentObj: any = { x: 1 }; + expect(enforce.isNotValueOf(obj).run(differentObj).pass).toBe(true); + expect(enforce.isNotValueOf(obj).run(targetObj).pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/object/__tests__/objectRules.test.ts b/packages/n4s/src/rules/object/__tests__/objectRules.test.ts new file mode 100644 index 000000000..75c42553b --- /dev/null +++ b/packages/n4s/src/rules/object/__tests__/objectRules.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('objectRules', () => { + it('checkKey: isKeyOf / isNotKeyOf', () => { + const obj = { id: 1, name: 'Alice' }; + expect(enforce.isKeyOf(obj).run('id').pass).toBe(true); + expect(enforce.isKeyOf(obj).run('age').pass).toBe(false); + expect(enforce.isNotKeyOf(obj).run('age').pass).toBe(true); + expect(enforce.isNotKeyOf(obj).run('name').pass).toBe(false); + }); + + it('checkValue: isValueOf / isNotValueOf', () => { + const obj = { a: 10, b: 20, c: 30 }; + expect(enforce.isValueOf(obj).run(20).pass).toBe(true); + expect(enforce.isValueOf(obj).run(40).pass).toBe(false); + expect(enforce.isNotValueOf(obj).run(40).pass).toBe(true); + expect(enforce.isNotValueOf(obj).run(10).pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/object/isKeyOf.ts b/packages/n4s/src/rules/object/isKeyOf.ts new file mode 100644 index 000000000..b257fa0db --- /dev/null +++ b/packages/n4s/src/rules/object/isKeyOf.ts @@ -0,0 +1,20 @@ +// Checks if value is a key that exists in the given object +export function isKeyOf(key: string | number | symbol, obj: object): boolean { + return ( + obj != null && + typeof obj === 'object' && + Object.prototype.hasOwnProperty.call(obj, key) + ); +} + +// Checks if value is not a key in the given object +export function isNotKeyOf( + key: string | number | symbol, + obj: object, +): boolean { + return !( + obj != null && + typeof obj === 'object' && + Object.prototype.hasOwnProperty.call(obj, key) + ); +} diff --git a/packages/n4s/src/rules/object/isValueOf.ts b/packages/n4s/src/rules/object/isValueOf.ts new file mode 100644 index 000000000..4f6f93b8d --- /dev/null +++ b/packages/n4s/src/rules/object/isValueOf.ts @@ -0,0 +1,18 @@ +// Checks if value exists in the given object's values +export function isValueOf(value: T, obj: Record): boolean { + return ( + obj != null && typeof obj === 'object' && Object.values(obj).includes(value) + ); +} + +// Checks if value does not exist in the given object's values +export function isNotValueOf( + value: T, + obj: Record, +): boolean { + return ( + obj != null && + typeof obj === 'object' && + !Object.values(obj).includes(value) + ); +} diff --git a/packages/n4s/src/rules/objectRules.ts b/packages/n4s/src/rules/objectRules.ts new file mode 100644 index 000000000..435b01a1c --- /dev/null +++ b/packages/n4s/src/rules/objectRules.ts @@ -0,0 +1,17 @@ +import { RuleInstance } from 'RuleInstance'; + +export interface ObjectRuleInstance extends RuleInstance {} + +export interface KeyOfRuleInstance + extends RuleInstance {} + +export interface ValueOfRuleInstance extends RuleInstance {} + +export type ObjectRulesUnion = + | ObjectRuleInstance + | KeyOfRuleInstance + | ValueOfRuleInstance; + +export { isKeyOf, isNotKeyOf } from 'isKeyOf'; + +export { isValueOf, isNotValueOf } from 'isValueOf'; diff --git a/packages/n4s/src/rules/ruleCondition.ts b/packages/n4s/src/rules/ruleCondition.ts deleted file mode 100644 index 690faf835..000000000 --- a/packages/n4s/src/rules/ruleCondition.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RuleReturn } from 'ruleReturn'; - -export function condition( - value: any, - callback: (value: any) => RuleReturn, -): RuleReturn { - try { - return callback(value); - } catch { - return false; - } -} diff --git a/packages/n4s/src/rules/schemaRules/__tests__/eager.schemaRules.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/eager.schemaRules.test.ts new file mode 100644 index 000000000..704538e7e --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/eager.schemaRules.test.ts @@ -0,0 +1,764 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('Schema Rules - Eager Notation', () => { + describe('enforce.shape() - eager', () => { + it('should pass with exact matching object', () => { + expect(() => + enforce({ + name: 'John', + age: 30, + }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).not.toThrow(); + }); + + it('should fail with extra properties', () => { + expect(() => + enforce({ + name: 'John', + age: 30, + extra: 'property', + }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).toThrow(); + }); + + it('should fail if a property is missing', () => { + expect(() => + enforce({ + name: 'John', + }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).toThrow(); + }); + + it('should fail if a property has wrong type', () => { + expect(() => + enforce({ + name: 'John', + age: '30', + }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).toThrow(); + }); + + it('should work with chained rules', () => { + expect(() => + enforce({ + email: 'test@example.com', + age: 25, + }).shape({ + email: enforce.isString().matches(/@/), + age: enforce.isNumber().greaterThan(18), + }), + ).not.toThrow(); + }); + + it('should fail when chained rule fails', () => { + expect(() => + enforce({ + age: 15, + }).shape({ + age: enforce.isNumber().greaterThan(18), + }), + ).toThrow(); + }); + + it('should work with nested shapes', () => { + expect(() => + enforce({ + user: { + name: { + first: 'Joseph', + last: 'Weil', + }, + }, + }).shape({ + user: enforce.shape({ + name: enforce.shape({ + first: enforce.isString(), + last: enforce.isString(), + }), + }), + }), + ).not.toThrow(); + }); + + it('should fail with nested shape violation', () => { + expect(() => + enforce({ + user: { + name: { + first: 'Joseph', + last: 123, + }, + }, + }).shape({ + user: enforce.shape({ + name: enforce.shape({ + first: enforce.isString(), + last: enforce.isString(), + }), + }), + }), + ).toThrow(); + }); + + it('should pass with empty schema and empty object', () => { + expect(() => enforce({}).shape({})).not.toThrow(); + }); + + it('should fail with empty schema and non-empty object', () => { + expect(() => enforce({ any: 'value' }).shape({})).toThrow(); + }); + }); + + describe('enforce.loose() - eager', () => { + it('should pass with exact matching object', () => { + expect(() => + enforce({ + name: 'John', + age: 30, + }).loose({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).not.toThrow(); + }); + + it('should pass with extra properties', () => { + expect(() => + enforce({ + name: 'Laura', + code: 'x23', + }).loose({ + name: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should fail if a required property is missing', () => { + expect(() => + enforce({ + name: 'John', + }).loose({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).toThrow(); + }); + + it('should fail if a property has wrong type', () => { + expect(() => + enforce({ + name: 'John', + age: '30', + }).loose({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).toThrow(); + }); + + it('should pass with empty schema and any object', () => { + expect(() => + enforce({ any: 'value', extra: 'fields' }).loose({}), + ).not.toThrow(); + }); + + it('should work with chained rules', () => { + expect(() => + enforce({ + name: 'John Doe', + age: 30, + extra: 'allowed', + }).loose({ + name: enforce.isString().longerThan(5), + age: enforce.isNumber().isBetween(18, 100), + }), + ).not.toThrow(); + }); + }); + + describe('enforce.isArrayOf() - eager', () => { + it('should pass for an array of matching type', () => { + expect(() => + enforce([1, 2, 3]).isArrayOf(enforce.isNumber()), + ).not.toThrow(); + }); + + it('should fail for an array with mixed types', () => { + expect(() => + enforce([1, '2', 3]).isArrayOf(enforce.isNumber()), + ).toThrow(); + }); + + it('should pass for an empty array', () => { + expect(() => enforce([]).isArrayOf(enforce.isNumber())).not.toThrow(); + }); + + it('should fail if not an array', () => { + expect(() => + // Type test: - intentionally testing invalid input + enforce({ not: 'an array' }).isArrayOf(enforce.isNumber()), + ).toThrow(); + }); + + it('should pass for mixed types when multiple rules are provided', () => { + expect(() => + enforce([1, '2', 3]).isArrayOf(enforce.isNumber(), enforce.isString()), + ).not.toThrow(); + }); + + it('should fail when a type is not in the allowed rules', () => { + expect(() => + enforce([1, '2', true]).isArrayOf( + enforce.isNumber(), + enforce.isString(), + ), + ).toThrow(); + }); + + it('should work with chained rules', () => { + expect(() => + enforce(['test@example.com', 'another@example.com']).isArrayOf( + enforce.isString().matches(/@/), + ), + ).not.toThrow(); + }); + + it('should fail when chained rule fails for any element', () => { + expect(() => + enforce(['test@example.com', 'invalid']).isArrayOf( + enforce.isString().matches(/@/), + ), + ).toThrow(); + }); + + it('should combine with other array rules', () => { + expect(() => + enforce([1, 2, 3]) + .isArrayOf(enforce.isNumber().lessThan(10)) + .longerThan(2), + ).not.toThrow(); + }); + + it('should work within shape', () => { + expect(() => + enforce({ + data: [1, 2, 3], + }).shape({ + data: enforce.isArrayOf(enforce.isNumber()), + }), + ).not.toThrow(); + }); + + it('should fail within shape when array content is invalid', () => { + expect(() => + enforce({ + data: [1, '2', 3], + }).shape({ + data: enforce.isArrayOf(enforce.isNumber()), + }), + ).toThrow(); + }); + }); + + describe('enforce.optional() - eager', () => { + it('should pass with null value', () => { + expect(() => + enforce({ + firstName: 'Rick', + lastName: 'Sanchez', + middleName: null, + }).shape({ + firstName: enforce.isString(), + middleName: enforce.optional(enforce.isString()), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should pass with undefined value', () => { + expect(() => + enforce({ + firstName: 'Rick', + lastName: 'Sanchez', + middleName: undefined, + }).shape({ + firstName: enforce.isString(), + middleName: enforce.optional(enforce.isString()), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should pass with missing property', () => { + expect(() => + enforce({ + firstName: 'Rick', + lastName: 'Sanchez', + }).shape({ + firstName: enforce.isString(), + middleName: enforce.optional(enforce.isString()), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should pass with valid value', () => { + expect(() => + enforce({ + firstName: 'Rick', + middleName: 'C-137', + lastName: 'Sanchez', + }).shape({ + firstName: enforce.isString(), + middleName: enforce.optional(enforce.isString()), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should fail with invalid value type', () => { + expect(() => + enforce({ + firstName: 'Rick', + middleName: 123, + lastName: 'Sanchez', + }).shape({ + firstName: enforce.isString(), + middleName: enforce.optional(enforce.isString()), + lastName: enforce.isString(), + }), + ).toThrow(); + }); + + it('should work with chained rules', () => { + expect(() => + enforce({ + name: 'John', + email: 'test@example.com', + }).shape({ + name: enforce.isString(), + email: enforce.optional(enforce.isString().matches(/@/)), + }), + ).not.toThrow(); + }); + + it('should fail when optional chained rule fails', () => { + expect(() => + enforce({ + name: 'John', + email: 'invalid-email', + }).shape({ + name: enforce.isString(), + email: enforce.optional(enforce.isString().matches(/@/)), + }), + ).toThrow(); + }); + }); + + describe('enforce.partial() - eager', () => { + it('should pass with subset of properties', () => { + expect(() => + enforce({ + firstName: 'John', + }).partial({ + firstName: enforce.isString(), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should pass with empty object (Partial semantics)', () => { + expect(() => + enforce({}).partial({ + firstName: enforce.isString(), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should pass with all properties', () => { + expect(() => + enforce({ + firstName: 'John', + lastName: 'Doe', + }).partial({ + firstName: enforce.isString(), + lastName: enforce.isString(), + }), + ).not.toThrow(); + }); + + it('should fail with wrong type for provided property', () => { + expect(() => + enforce({ + // Type test: + firstName: 123, + }).partial({ + firstName: enforce.isString(), + lastName: enforce.isString(), + }), + ).toThrow(); + }); + + it('should work with chained rules', () => { + expect(() => + enforce({ + age: 25, + }) + .partial({ + age: enforce.isNumber().greaterThan(18), + name: enforce.isString().longerThan(2), + }) + .isNotEmpty(), + ).not.toThrow(); + }); + + it('should fail when chained rule fails on provided property', () => { + expect(() => + enforce({ + age: 15, + }) + .partial({ + age: enforce.isNumber().greaterThan(18), + name: enforce.isString(), + }) + .isEmpty(), + ).toThrow(); + }); + }); + + describe('Complex integration scenarios - eager', () => { + it('should validate deeply nested objects with shape', () => { + expect(() => + enforce({ + user: { + profile: { + contact: { + email: 'test@example.com', + }, + age: 25, + }, + }, + }).shape({ + user: enforce.shape({ + profile: enforce.shape({ + contact: enforce.shape({ + email: enforce.isString().matches(/@/), + }), + age: enforce.isNumber().greaterThan(18), + }), + }), + }), + ).not.toThrow(); + }); + + it('should combine shape with isArrayOf', () => { + expect(() => + enforce({ + username: 'johndoe', + tags: ['javascript', 'typescript', 'node'], + }).shape({ + username: enforce.isString(), + tags: enforce.isArrayOf(enforce.isString().longerThan(2)), + }), + ).not.toThrow(); + }); + + it('should combine loose with optional and isArrayOf', () => { + expect(() => + enforce({ + name: 'Product', + categories: ['tech', 'gadgets'], + extraField: 'allowed', + }).loose({ + name: enforce.isString(), + categories: enforce.isArrayOf(enforce.isString()), + description: enforce.optional(enforce.isString()), + }), + ).not.toThrow(); + }); + + it('should work with allOf in shape', () => { + expect(() => + enforce({ + password: 'SecureP@ss123', + }).shape({ + password: enforce.allOf( + enforce.isString(), + enforce.isString().longerThan(8), + enforce.isString().matches(/[A-Z]/), + enforce.isString().matches(/[0-9]/), + ), + }), + ).not.toThrow(); + }); + + it('should work with anyOf in shape', () => { + expect(() => + enforce({ + identifier: 'user@example.com', + }).shape({ + identifier: enforce.anyOf( + enforce.isString().matches(/@/), + enforce.isString().matches(/^\d+$/), + ), + }), + ).not.toThrow(); + }); + + it('should validate array of objects with isArrayOf and shape', () => { + expect(() => + enforce({ + users: [ + { name: 'John', age: 30 }, + { name: 'Jane', age: 25 }, + ], + }).shape({ + users: enforce.isArrayOf( + enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ), + }), + ).not.toThrow(); + }); + + it('should fail when array of objects has invalid nested property', () => { + expect(() => + enforce({ + users: [ + { name: 'John', age: 30 }, + { name: 'Jane', age: '25' }, + ], + }).shape({ + users: enforce.isArrayOf( + enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ), + }), + ).toThrow(); + }); + }); + + describe('Custom rules with schema rules - eager', () => { + beforeEach(() => { + enforce.extend({ + isEmail: (value: string) => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value), + isAdult: (value: number) => value >= 18, + isFriendTheSameAsUser: (value: string) => { + const context = enforce.context(); + if (value === context?.parent()?.parent()?.value.username) { + return { + pass: false, + message: () => 'Friend cannot be the same as username', + }; + } + return true; + }, + }); + }); + + it('should work with custom rules in shape', () => { + expect(() => + enforce({ + email: 'test@example.com', + age: 25, + }).shape({ + email: enforce.isString().isEmail(), + age: enforce.isNumber().isAdult(), + }), + ).not.toThrow(); + }); + + it('should fail when custom rule fails in shape', () => { + expect(() => + enforce({ + email: 'invalid', + age: 16, + }).shape({ + email: enforce.isString().isEmail(), + age: enforce.isNumber().isAdult(), + }), + ).toThrow(); + }); + + it('should work with custom rules in isArrayOf', () => { + expect(() => + enforce({ + emails: ['test@example.com', 'another@example.com'], + }).shape({ + emails: enforce.isArrayOf(enforce.isString().isEmail()), + }), + ).not.toThrow(); + }); + + it('should work with context-aware custom rules', () => { + expect(() => + enforce({ + username: 'johndoe', + friends: ['Mike', 'Jim'], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).not.toThrow(); + }); + + // Note: Context traversal in nested schema rules (shape/isArrayOf) is not yet fully implemented + it('should fail when context-aware custom rule fails', () => { + expect(() => + enforce({ + username: 'johndoe', + friends: ['Mike', 'Jim', 'johndoe'], + }).shape({ + username: enforce.isString(), + friends: enforce.isArrayOf( + enforce.isString().isFriendTheSameAsUser(), + ), + }), + ).toThrow(/enforce/); + }); + }); + + describe('Error messages with schema rules - eager', () => { + it('should throw descriptive error for shape validation', () => { + expect(() => + enforce({ + name: 'John', + age: '30', + }).shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }), + ).toThrow(/enforce/); + }); + + it('should support custom messages with .message()', () => { + expect(() => + enforce({ + age: 15, + }) + .message('Age must be valid') + .shape({ + age: enforce.isNumber().greaterThan(18), + }), + ).toThrow('Age must be valid'); + }); + + it('should support custom messages on nested rules', () => { + expect(() => + enforce({ + user: { + age: 15, + }, + }).shape({ + user: enforce.shape({ + age: enforce.isNumber().greaterThan(18), + }), + }), + ).toThrow(/enforce/); + }); + }); + + describe('Edge cases - eager', () => { + it('should handle null and undefined values in shape', () => { + expect(() => + enforce({ + name: null, + }).shape({ + name: enforce.optional(enforce.isString()), + }), + ).not.toThrow(); + }); + + it('should handle empty arrays in isArrayOf', () => { + expect(() => + enforce({ + items: [], + }).shape({ + items: enforce.isArrayOf(enforce.isNumber()), + }), + ).not.toThrow(); + }); + + it('should handle deeply nested optional fields', () => { + expect(() => + enforce({ + user: { + profile: {}, + }, + }).shape({ + user: enforce.shape({ + profile: enforce.shape({ + bio: enforce.optional(enforce.isString()), + }), + }), + }), + ).not.toThrow(); + }); + + it('should handle mixed optional and required fields', () => { + expect(() => + enforce({ + required: 'value', + }).shape({ + required: enforce.isString(), + optional1: enforce.optional(enforce.isString()), + optional2: enforce.optional(enforce.isNumber()), + }), + ).not.toThrow(); + }); + + it('should handle arrays of different types with multiple isArrayOf rules', () => { + expect(() => + enforce([1, '2', 3, 'four']).isArrayOf( + enforce.isNumber(), + enforce.isString(), + ), + ).not.toThrow(); + }); + }); + + describe('Chaining schema rules - eager', () => { + it('should allow chaining after shape validation', () => { + expect(() => + enforce({ + items: [1, 2, 3], + }) + .shape({ + items: enforce.isArray(), + }) + .isNotEmpty(), + ).not.toThrow(); + }); + + it('should allow chaining multiple validations', () => { + expect(() => + enforce([1, 2, 3]) + .isArrayOf(enforce.isNumber()) + .longerThan(2) + .shorterThan(10), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.deep.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.deep.test.ts new file mode 100644 index 000000000..06f7b69a9 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.deep.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +// schema combinators are consumed via enforce + +describe('integration: extensive schema + combinators', () => { + it('deep object: user profile with addresses, contacts and preferences', () => { + const Roles = { admin: 'admin', user: 'user', guest: 'guest' } as const; + const Envs = { dev: 1, prod: 2, stage: 3 } as const; + + const addressSchema = enforce.shape({ + city: enforce.isString().isNotBlank(), + country: enforce.isString().longerThan(1), + street: enforce.isString().isNotBlank(), + zip: enforce.anyOf( + enforce.allOf( + enforce.isString(), + enforce.isString().matches(/^\d{5}$/), + ), + enforce.allOf( + enforce.isNumber(), + enforce.isNumber().greaterThanOrEquals(10000), + enforce.isNumber().lessThanOrEquals(99999), + ), + ), + }); + + const contactSchema = enforce.shape({ + metaEnvKey: enforce.isKeyOf(Envs), + metaRoleValue: enforce.isValueOf(Roles as any), + method: enforce.oneOf( + enforce.isString().equals('email'), + enforce.isString().equals('phone'), + ), + value: enforce.anyOf( + enforce.allOf(enforce.isString(), enforce.isString().isNotBlank()), + enforce.allOf(enforce.isNumeric().greaterThanOrEquals(1_000_000_000)), + enforce.allOf(enforce.isNumber().greaterThanOrEquals(1_000_000_000)), + ), + }); + + const preferencesSchema = enforce.loose({ + darkMode: enforce.isBoolean(), + language: enforce.optional( + enforce.anyOf( + enforce.isString().inside(['en', 'es', 'he', 'fr']), + enforce.isString().matches(/^[a-z]{2}$/), + ), + ), + thresholds: enforce.optional( + enforce.isArrayOf( + enforce.isArrayOf( + enforce.isNumeric().greaterThanOrEquals(0), + enforce.isNumber().greaterThanOrEquals(0), + ), + ), + ), + }); + + const userSchema = enforce.shape({ + addresses: enforce.isArrayOf(addressSchema), + contacts: enforce.isArrayOf(contactSchema), + favoriteNumbers: enforce.isArrayOf( + enforce.isNumeric(), + enforce.isNumber(), + ), + id: enforce.anyOf( + enforce.isNumber().greaterThan(0), + enforce.allOf( + enforce.isString(), + enforce.isString().matches(/^[1-9]\d*$/), + ), + ), + preferences: enforce.optional(preferencesSchema), + username: enforce.allOf( + enforce.isString().minLength(3), + enforce.noneOf( + enforce.isString().equals('admin'), + enforce.isString().equals('root'), + ), + ), + }); + + expect( + userSchema.run({ + addresses: [ + { + city: 'Star City', + country: 'US', + street: '3 Third St', + zip: '67890', + }, + ], + contacts: [ + { + metaEnvKey: 'dev' as any, + metaRoleValue: 'user' as any, + method: 'email', + value: 'jane@example.com', + }, + { + metaEnvKey: 'prod' as any, + metaRoleValue: 'admin' as any, + method: 'phone', + value: 1234567890, + }, + ], + preferences: { + darkMode: false, + thresholds: [ + [0, '1'], + ['2', 3], + ], + }, + favoriteNumbers: ['1', 2, '3'], + id: '100', + username: 'jane_doe', + }).pass, + ).toBe(true); + + expect( + userSchema.run({ + addresses: [{ city: 'b', country: 'US', street: 'a', zip: '12345' }], + contacts: [ + { + metaEnvKey: 'dev' as any, + metaRoleValue: 'user' as any, + method: 'email', + value: 'x', + }, + ], + favoriteNumbers: [1], + id: 1, + username: 'root', + } as any).pass, + ).toBe(false); + + expect( + userSchema.run({ + addresses: [{ city: 'b', country: 'US', street: 'a', zip: '12345' }], + contacts: [ + { + metaEnvKey: 'dev' as any, + metaRoleValue: 'user' as any, + method: 'sms', + value: '1234567890', + }, + ], + favoriteNumbers: [1], + id: 2, + username: 'ok_user', + } as any).pass, + ).toBe(false); + + expect( + userSchema.run({ + addresses: [ + { + city: 'b', + country: 'US', + extra: true, + street: 'a', + zip: '12345', + } as any, + ], + contacts: [ + { + metaEnvKey: 'dev' as any, + metaRoleValue: 'user' as any, + method: 'email', + value: 'x@y', + }, + ], + favoriteNumbers: [1], + id: 3, + username: 'user3', + } as any).pass, + ).toBe(false); + + expect( + userSchema.run({ + addresses: [{ city: 'b', country: 'US', street: 'a', zip: '12345' }], + contacts: [ + { + metaEnvKey: 'dev' as any, + metaRoleValue: 'user' as any, + method: 'email', + value: 'x@y', + }, + ], + favoriteNumbers: [1, 'two'], + id: 4, + username: 'user4', + } as any).pass, + ).toBe(false); + }); + + it('partial nested object with optional children and nested arrays of shapes', () => { + const itemSchema = enforce.shape({ + price: enforce.anyOf( + enforce.isNumber(), + enforce.allOf( + enforce.isString(), + enforce.isString().matches(/^\d+(?:\.\d+)?$/), + ), + ), + qty: enforce.isNumber().greaterThan(0), + sku: enforce.isString().minLength(3), + tags: enforce.optional( + enforce.isArrayOf(enforce.isString().isNotBlank()), + ), + }); + + const orderBase = { + id: enforce.anyOf( + enforce.isNumber(), + enforce.allOf( + enforce.isString(), + enforce.isString().matches(/^[+-]?\d+(?:\.\d+)?$/), + ), + ), + items: enforce.isArrayOf(itemSchema), + shipping: enforce.optional( + enforce.shape({ + address: enforce.shape({ + line1: enforce.isString().isNotBlank(), + line2: enforce.optional(enforce.isString()), + zip: enforce.anyOf( + enforce.isNumber().isBetween(10000, 99999), + enforce.isString().matches(/^\d{5}$/), + ), + }), + }), + ), + totals: enforce.loose({ + discounts: enforce.optional( + enforce.isArrayOf( + enforce.isNumber().greaterThanOrEquals(0), + enforce.isNumeric().greaterThanOrEquals(0), + ), + ), + subtotal: enforce.isNumber().greaterThanOrEquals(0), + tax: enforce.isNumber().greaterThanOrEquals(0), + }), + } as const; + + const orderSchema = enforce.partial(orderBase); + + expect( + orderSchema.run({ + id: '1001', + items: [ + { sku: 'AAA', qty: 1, price: '9.99' }, + { sku: 'BBB', qty: 2, price: 5 }, + ], + totals: { discounts: undefined, subtotal: 10, tax: 0.5 }, + }).pass, + ).toBe(true); + + expect( + orderSchema.run({ + id: 1002, + items: [{ price: 3, qty: 3, sku: 'CCC', tags: ['sale', 'new'] }], + shipping: { address: { line1: 'Somewhere', line2: '', zip: '12345' } }, + totals: { discounts: ['1', 2, 0], subtotal: 9, tax: 1 }, + }).pass, + ).toBe(true); + + expect( + orderSchema.run({ + id: 1003, + items: [{ price: 1, qty: 1, sku: 'DDD', tags: [''] }], + shipping: { address: { line1: 'X', zip: 'ABCDE' } }, + totals: { subtotal: 1, tax: 0 }, + } as any).pass, + ).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.test.ts new file mode 100644 index 000000000..c7cf4a443 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('integration: rules with schema combinators', () => { + it('shape: combine isString with notBlank and length', () => { + const userSchema = enforce.shape({ + name: enforce.allOf( + enforce.isString(), + enforce.isString().isNotBlank(), + enforce.isString().minLength(2), + ), + tags: enforce.isArray().isNotEmpty(), + }); + + expect(userSchema.run({ name: 'Alice', tags: ['dev'] }).pass).toBe(true); + expect(userSchema.run({ name: ' ', tags: ['dev'] }).pass).toBe(false); + expect(userSchema.run({ name: 'A', tags: ['dev'] }).pass).toBe(false); + // extra field should fail shape + expect( + userSchema.run({ name: 'Alice', tags: ['dev'], extra: 1 } as any).pass, + ).toBe(false); + }); + + it('optional + nullish with numbers', () => { + const schema = enforce.shape({ + id: enforce.isNumber().greaterThan(0), + deletedAt: enforce.optional(enforce.isNullish()), + }); + + expect(schema.run({ id: 1 }).pass).toBe(true); + expect(schema.run({ id: 1, deletedAt: null }).pass).toBe(true); + // non-nullish value fails optional(isNullish()) + expect(schema.run({ id: 1, deletedAt: 'now' as any }).pass).toBe(false); + }); + + it('isArrayOf with numeric acceptance (numbers or numeric strings)', () => { + const arrRule = enforce.isArrayOf(enforce.isNumeric(), enforce.isNumber()); + expect(arrRule.run([1, '2', 3]).pass).toBe(true); + expect(arrRule.run([1, 'two']).pass).toBe(false); + }); + + it('anyOf mixing negative and positive rules', () => { + // accept values that are not numeric, or numeric >= 10 + const rule = enforce.anyOf( + enforce.isNotNumeric(), + // numbers only chain + enforce.isNumeric().greaterThanOrEquals(10), + ); + + expect(rule.run('abc').pass).toBe(true); // not numeric + expect(rule.run('9').pass).toBe(false); + expect(rule.run('10').pass).toBe(true); + }); + + it('checkKey / checkValue inside shape fields', () => { + const ENV = { dev: 1, prod: 2 } as const; + + const schema = enforce.loose({ + envKey: enforce.isKeyOf(ENV), + envValue: enforce.isValueOf({ a: 1, b: 2, c: 3 }), + }); + + expect(schema.run({ envKey: 'dev', envValue: 2 } as any).pass).toBe(true); + expect(schema.run({ envKey: 'stage', envValue: 4 } as any).pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.types.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.types.test.ts new file mode 100644 index 000000000..02a85565d --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/integrationSchemaRules.types.test.ts @@ -0,0 +1,164 @@ +import type { ShapeType } from 'shape'; +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +// schema combinators are consumed via enforce + +// This suite focuses on compile-time type checks using @ts-expect-error to ensure +// incorrect types produce red squigglies when using the rules and combinators. + +describe('types: compile-time mismatches across rules and composed schemas', () => { + it('primitive rule unions and arrays (number | numeric-string)', () => { + const arrRule = enforce.isArrayOf(enforce.isNumeric(), enforce.isNumber()); + type Arr = typeof arrRule.infer; + + const ok1: Arr = [1, '2', 3]; + void ok1; + + // Type test: boolean is not allowed in (number | string)[] + const badArr1: Arr = [true]; + void badArr1; + + // Type test: object is not allowed in (number | string)[] + const badArr2: Arr = [{}]; + void badArr2; + expect(true).toBe(true); + }); + + it('shape: exact fields and correct types', () => { + const addrSchema = enforce.shape({ + city: enforce.isString(), + country: enforce.isString(), + street: enforce.isString(), + zip: enforce.anyOf( + enforce.allOf( + enforce.isString(), + enforce.isString().matches(/^\d{5}$/), + ), + enforce.allOf( + enforce.isNumber(), + enforce.isNumber().greaterThanOrEquals(10000), + enforce.isNumber().lessThanOrEquals(99999), + ), + ), + }); + + type Addr = typeof addrSchema.infer; + + const ok: Addr = { city: 'a', country: 'US', street: 'x', zip: '12345' }; + void ok; + + const extra1 = { + city: 'a', + country: 'US', + // Type test: extra property not allowed by exact shape + extra: true, + street: 'x', + zip: '12345', + } satisfies Addr; + void extra1; + + const badZip = { + city: 'a', + country: 'US', + street: 'x', + // Type test: boolean is not assignable to string + zip: true, + } satisfies Addr; + void badZip; + expect(true).toBe(true); + }); + + it('optional + base shape: wrong inner types should error', () => { + const base = { + count: enforce.isNumber(), + maybeName: enforce.optional(enforce.isString()), + totals: enforce.shape({ + subtotal: enforce.isNumber(), + tax: enforce.isNumber(), + }), + } as const; + + // Use shape-inferred type for compile-time checks + type T = ShapeType; + + const good: T = { + count: 1, + totals: { subtotal: 1, tax: 0 }, + }; + void good; + + const badCount = { + // Type test: count must be number + count: '1', + totals: { subtotal: 1, tax: 0 }, + } satisfies T; + void badCount; + + const badTotals = { + count: 1, + // Type test: totals.tax must be number + totals: { subtotal: 1, tax: '0' }, + } satisfies T; + void badTotals; + + const badMaybe = { + count: 1, + // Type test: maybeName may be string | null | undefined, not boolean + maybeName: false, + totals: { subtotal: 1, tax: 0 }, + } satisfies T; + void badMaybe; + expect(true).toBe(true); + }); + + it('anyOf/noneOf: union types vs mismatches', () => { + const strOrNum = enforce.anyOf(enforce.isString(), enforce.isNumber()); + type SOrN = typeof strOrNum.infer; // string | number + + const okA: SOrN = 'a'; + const okB: SOrN = 1; + void okA; + void okB; + + // Type test: boolean is not string | number + const badC: SOrN = true; + void badC; + + const notString = enforce.noneOf(enforce.isString()); + type NotStr = typeof notString.infer; // string (by design of combinator typing) + + // Type test: expects string inferred type, assigning number + const badNotStr: NotStr = 1; + void badNotStr; + expect(true).toBe(true); + }); + + it('composed shapes with nested arrays: incorrect element type', () => { + const lineItem = enforce.shape({ + price: enforce.anyOf(enforce.isNumber(), enforce.isNumeric()), + qty: enforce.isNumber().greaterThan(0), + sku: enforce.isString(), + }); + const cart = enforce.shape({ + items: enforce.isArrayOf(lineItem), + }); + + type Cart = typeof cart.infer; + + const ok: Cart = { items: [{ price: 1, qty: 1, sku: 'x' }] }; + void ok; + + // Type test: items must be array of lineItem + const badCart1 = { items: ['x'] } satisfies Cart; + void badCart1; + + const badCart2 = { + // Type test: qty must be number > 0 (type-wise: number, so boolean is invalid) + items: [{ price: 1, qty: true, sku: 'x' }], + } satisfies Cart; + void badCart2; + expect(true).toBe(true); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/isArrayOf.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/isArrayOf.test.ts new file mode 100644 index 000000000..1b2b6eab6 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/isArrayOf.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isArrayOf', () => { + it('should return a rule instance', () => { + const rule = enforce.isArrayOf(enforce.isNumber()); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass for an array of numbers', () => { + const rule = enforce.isArrayOf(enforce.isNumber()); + const result = rule.run([1, 2, 3]); + expect(result.pass).toBe(true); + }); + + it('should fail for an array with mixed types', () => { + const rule = enforce.isArrayOf(enforce.isNumber()); + // Type test: + const result = rule.run([1, '2', 3]); + expect(result.pass).toBe(false); + }); + + it('should pass for an empty array', () => { + const rule = enforce.isArrayOf(enforce.isNumber()); + const result = rule.run([]); + expect(result.pass).toBe(true); + }); + + it('should fail if not an array', () => { + const rule = enforce.isArrayOf(enforce.isNumber()); + // Type test: + const result = rule.run({ not: 'an array' }); + expect(result.pass).toBe(false); + }); + + it('should pass for an array of mixed types when multiple rules are provided', () => { + const rule = enforce.isArrayOf(enforce.isNumber(), enforce.isString()); + const result = rule.run([1, '2', 3]); + expect(result.pass).toBe(true); + }); + + it('should fail for an array of mixed types when a type is not in the rules', () => { + const rule = enforce.isArrayOf(enforce.isNumber(), enforce.isString()); + // Type test: + const result = rule.run([1, '2', true]); + expect(result.pass).toBe(false); + }); + + it('should chain array methods after isArrayOf (lazy API)', () => { + const rule = enforce + .isArrayOf(enforce.isNumber()) + .minLength(1) + .maxLength(10); + + expect(rule.run([1, 2, 3])).toMatchObject({ pass: true }); + expect(rule.run([])).toMatchObject({ pass: false }); // fails minLength + expect(rule.run([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])).toMatchObject({ + pass: false, + }); // fails maxLength + }); +}); + +describe('isArrayOf - eager API', () => { + it('should chain array methods after isArrayOf (eager API)', () => { + expect(() => { + enforce([1, 2, 3]).isArrayOf(enforce.isNumber()).minLength(1); + }).not.toThrow(); + + expect(() => { + enforce([]).isArrayOf(enforce.isNumber()).minLength(1); + }).toThrow(); + + expect(() => { + enforce([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + .isArrayOf(enforce.isNumber()) + .maxLength(10); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/loose.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/loose.test.ts new file mode 100644 index 000000000..75d4db358 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/loose.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('loose', () => { + const schema = { + name: enforce.isString(), + age: enforce.isNumber(), + }; + + it('should return a rule instance', () => { + const rule = enforce.loose(schema); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass with exact matching object', () => { + const rule = enforce.loose(schema); + const result = rule.run({ name: 'John', age: 30 }); + expect(result.pass).toBe(true); + }); + + it('should pass with extra properties', () => { + const rule = enforce.loose(schema); + const result = rule.run({ name: 'John', age: 30, extra: 'property' }); + expect(result.pass).toBe(true); + }); + + it('should fail if a property is missing', () => { + const rule = enforce.loose(schema); + // Type test: + const result = rule.run({ name: 'John' }); + expect(result.pass).toBe(false); + }); + + it('should fail if a property has wrong type', () => { + const rule = enforce.loose(schema); + // Type test: + const result = rule.run({ name: 'John', age: '30' }); + expect(result.pass).toBe(false); + }); + + it('should fail with empty object', () => { + const rule = enforce.loose(schema); + // Type test: + const result = rule.run({}); + expect(result.pass).toBe(false); + }); + + it('should pass with an empty schema', () => { + const rule = enforce.loose({}); + const result = rule.run({ any: 'value' }); + expect(result.pass).toBe(true); + }); +}); + +describe('loose - eager API', () => { + const schema = { + name: enforce.isString(), + age: enforce.isNumber(), + }; + + it('should pass with exact matching object (eager)', () => { + expect(() => { + enforce({ name: 'John', age: 30 }).loose(schema); + }).not.toThrow(); + }); + + it('should pass with extra properties (eager)', () => { + expect(() => { + enforce({ name: 'John', age: 30, extra: 'property' }).loose(schema); + }).not.toThrow(); + }); + + it('should fail if a property is missing (eager)', () => { + expect(() => { + enforce({ name: 'John' }).loose(schema); + }).toThrow(); + }); + + it('should fail if a property has wrong type (eager)', () => { + expect(() => { + enforce({ name: 'John', age: '30' }).loose(schema); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/optional.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/optional.test.ts new file mode 100644 index 000000000..866f9f930 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/optional.test.ts @@ -0,0 +1,62 @@ +import { enforce } from 'n4s'; +import { describe, it, expect } from 'vitest'; + +// schema combinators are accessed via enforce + +describe('optional', () => { + it('should return a rule instance', () => { + const rule = enforce.optional(enforce.isNumber()); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass for null', () => { + const rule = enforce.optional(enforce.isNumber()); + const result = rule.run(null); + expect(result.pass).toBe(true); + }); + + it('should pass for undefined', () => { + const rule = enforce.optional(enforce.isNumber()); + const result = rule.run(undefined); + expect(result.pass).toBe(true); + }); + + it('should pass for a valid value', () => { + const rule = enforce.optional(enforce.isNumber()); + const result = rule.run(123); + expect(result.pass).toBe(true); + }); + + it('should fail for an invalid value', () => { + const rule = enforce.optional(enforce.isNumber()); + const result = rule.run('not a number'); + expect(result.pass).toBe(false); + }); +}); + +describe('optional - eager API', () => { + it('should pass for null (eager)', () => { + expect(() => { + enforce(null).optional(enforce.isNumber()); + }).not.toThrow(); + }); + + it('should pass for undefined (eager)', () => { + expect(() => { + enforce(undefined).optional(enforce.isNumber()); + }).not.toThrow(); + }); + + it('should pass for a valid value (eager)', () => { + expect(() => { + enforce(123).optional(enforce.isNumber()); + }).not.toThrow(); + }); + + it('should fail for an invalid value (eager)', () => { + expect(() => { + enforce('not a number').optional(enforce.isNumber()); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/partial.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/partial.test.ts new file mode 100644 index 000000000..aac3fa1ec --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/partial.test.ts @@ -0,0 +1,102 @@ +import { RuleInstance } from 'RuleInstance'; +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +const longerThan = (n: number): RuleInstance => ({ + run: (v: any) => ({ pass: typeof v === 'string' && v.length > n, type: v }), + infer: {} as string, +}); + +describe('partial', () => { + const schema = { + name: enforce.isString(), + age: enforce.isNumber(), + }; + + it('validates subset; empty object is allowed', () => { + const rule = enforce.partial(schema); + + expect(rule.run({ name: 'John' }).pass).toBe(true); + expect(rule.run({ age: 30 }).pass).toBe(true); + expect(rule.run({ name: 'John', age: 30 }).pass).toBe(true); + expect(rule.run({}).pass).toBe(true); + }); + + it('disallows extra properties', () => { + const rule = enforce.partial(schema); + // Type test: runtime check for extra property + expect(rule.run({ name: 'John', extra: true }).pass).toBe(false); + }); + + it('fails when object has none of the original fields', () => { + const rule = enforce.partial(schema); + // none of the keys match schema, should fail + // Type test: runtime check for unrelated fields + expect(rule.run({ foo: 'bar' }).pass).toBe(false); + }); + + it('fails when provided field has wrong type', () => { + const rule = enforce.partial(schema); + // Type test: runtime check for wrong type + expect(rule.run({ name: 123 }).pass).toBe(false); + // Type test: runtime check for wrong type + expect(rule.run({ age: '30' }).pass).toBe(false); + }); + + it('works with custom rules', () => { + const rule = enforce.partial({ + username: longerThan(3), + id: enforce.isNumber(), + }); + + expect(rule.run({ username: 'foobar' }).pass).toBe(true); + expect(rule.run({ id: 1 }).pass).toBe(true); + expect(rule.run({ username: 'foo' }).pass).toBe(false); + // Type test: + expect(rule.run({ id: '1' }).pass).toBe(false); + }); +}); + +describe('partial - eager API', () => { + const schema = { + name: enforce.isString(), + age: enforce.isNumber(), + }; + + it('should pass with subset of properties (eager)', () => { + expect(() => { + enforce({ name: 'John' }).partial(schema); + }).not.toThrow(); + + expect(() => { + enforce({ age: 30 }).partial(schema); + }).not.toThrow(); + + expect(() => { + enforce({ name: 'John', age: 30 }).partial(schema); + }).not.toThrow(); + }); + + it('should pass with empty object (eager)', () => { + expect(() => { + enforce({}).partial(schema); + }).not.toThrow(); + }); + + it('should fail with extra properties (eager)', () => { + expect(() => { + enforce({ name: 'John', extra: true }).partial(schema); + }).toThrow(); + }); + + it('should fail when provided field has wrong type (eager)', () => { + expect(() => { + enforce({ name: 123 }).partial(schema); + }).toThrow(); + + expect(() => { + enforce({ age: '30' }).partial(schema); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/__tests__/shape.test.ts b/packages/n4s/src/rules/schemaRules/__tests__/shape.test.ts new file mode 100644 index 000000000..194d1003a --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/__tests__/shape.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('shape', () => { + const schema = { + name: enforce.isString(), + age: enforce.isNumber(), + }; + + it('should return a rule instance', () => { + const rule = enforce.shape(schema); + expect(rule).toHaveProperty('run'); + expect(rule).toHaveProperty('infer'); + }); + + it('should pass with exact matching object', () => { + const rule = enforce.shape(schema); + const result = rule.run({ name: 'John', age: 30 }); + expect(result.pass).toBe(true); + }); + + it('should fail with extra properties', () => { + const rule = enforce.shape(schema); + // Type test: + const result = rule.run({ name: 'John', age: 30, extra: 'property' }); + expect(result.pass).toBe(false); + }); + + it('should fail if a property is missing', () => { + const rule = enforce.shape(schema); + // Type test: + const result = rule.run({ name: 'John' }); + expect(result.pass).toBe(false); + }); + + it('should fail if a property has wrong type', () => { + const rule = enforce.shape(schema); + // Type test: + const result = rule.run({ name: 'John', age: '30' }); + expect(result.pass).toBe(false); + }); + + it('should fail with empty object', () => { + const rule = enforce.shape(schema); + // Type test: + const result = rule.run({}); + expect(result.pass).toBe(false); + }); + + it('should pass with an empty schema and empty object', () => { + const rule = enforce.shape({}); + const result = rule.run({}); + expect(result.pass).toBe(true); + }); + + it('should fail with an empty schema and non-empty object', () => { + const rule = enforce.shape({}); + const result = rule.run({ any: 'value' }); + expect(result.pass).toBe(false); + }); +}); + +describe('shape - eager API', () => { + const schema = { + name: enforce.isString(), + age: enforce.isNumber(), + }; + + it('should pass with exact matching object (eager)', () => { + expect(() => { + enforce({ name: 'John', age: 30 }).shape(schema); + }).not.toThrow(); + }); + + it('should fail with extra properties (eager)', () => { + expect(() => { + enforce({ name: 'John', age: 30, extra: 'property' }).shape(schema); + }).toThrow(); + }); + + it('should fail if a property is missing (eager)', () => { + expect(() => { + enforce({ name: 'John' }).shape(schema); + }).toThrow(); + }); + + it('should fail if a property has wrong type (eager)', () => { + expect(() => { + enforce({ name: 'John', age: '30' }).shape(schema); + }).toThrow(); + }); +}); diff --git a/packages/n4s/src/rules/schemaRules/isArrayOf.ts b/packages/n4s/src/rules/schemaRules/isArrayOf.ts new file mode 100644 index 000000000..d7da7818b --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/isArrayOf.ts @@ -0,0 +1,70 @@ +/* eslint-disable max-nested-callbacks */ +import { ctx } from 'enforceContext'; +import { mapFirst } from 'vest-utils'; + +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Validates that a value is an array and all elements match at least one of the provided rules. + * Each array element must pass at least one of the validation rules. + * + * @template T - The element type of the array + * @param value - The array to validate + * @param rules - One or more RuleInstances that elements should match + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API - array of strings + * enforce(['a', 'b', 'c']) + * .isArrayOf(enforce.isString()); // passes + * + * enforce([1, 2, 'three']) + * .isArrayOf(enforce.isString()); // fails + * + * // Lazy API - array of numbers or strings + * const mixedArrayRule = enforce.isArrayOf( + * enforce.isNumber(), + * enforce.isString() + * ); + * + * mixedArrayRule.test([1, 'two', 3, 'four']); // true + * mixedArrayRule.test([1, 2, true]); // false (boolean not allowed) + * + * // Complex schema validation + * const usersRule = enforce.isArrayOf( + * enforce.shape({ + * name: enforce.isString(), + * age: enforce.isNumber() + * }) + * ); + * + * usersRule.test([ + * { name: 'John', age: 30 }, + * { name: 'Jane', age: 25 } + * ]); // true + * ``` + */ +// eslint-disable-next-line max-nested-callbacks +export function isArrayOf(value: T[], ...rules: any[]): RuleRunReturn { + if (!Array.isArray(value)) { + return RuleRunReturn.Failing(value); + } + + return ( + mapFirst(value, (item, breakout, index) => { + const res = ctx.run({ value: item, set: true, meta: { index } }, () => { + // Try each rule with the item - any rule passing is OK + const anyPass = rules.some(rule => rule.run(item).pass); + return anyPass + ? RuleRunReturn.Passing(item) + : RuleRunReturn.Failing(item); + }); + breakout(!res.pass, res); + }) || RuleRunReturn.Passing(value) + ); +} + +// Type for isArrayOf rule instance - should chain array rules like isArray does +export type IsArrayOfRuleInstance = + import('arrayRules').ArrayRuleInstance; diff --git a/packages/n4s/src/rules/schemaRules/loose.ts b/packages/n4s/src/rules/schemaRules/loose.ts new file mode 100644 index 000000000..76ab5374f --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/loose.ts @@ -0,0 +1,61 @@ +import { ctx } from 'enforceContext'; +import type { ShapeType } from 'shape'; + +import type { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Validates that an object matches a schema loosely - all schema keys required, extra keys allowed. + * Like shape() but permits additional properties not defined in the schema. + * + * @template T - The object type to validate + * @param value - The object to validate + * @param schema - Schema mapping keys to validation rules + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce({ name: 'John', age: 30, extra: 'allowed' }) + * .loose({ + * name: enforce.isString(), + * age: enforce.isNumber() + * }); // passes (extra key is ok) + * + * // Lazy API + * const partialUserSchema = enforce.loose({ + * name: enforce.isString(), + * email: enforce.isString() + * }); + * + * // All schema keys must be present and valid + * partialUserSchema.test({ name: 'Jane', email: 'jane@example.com' }); // true + * partialUserSchema.test({ name: 'Jane', email: 'jane@example.com', age: 30 }); // true (extra ok) + * partialUserSchema.test({ name: 'Jane' }); // false (missing email) + * ``` + */ +export function loose>( + value: T, + schema: Record, +): RuleRunReturn { + for (const key in schema) { + const fieldValue = key in value ? value[key] : undefined; + const res = ctx.run({ value: fieldValue, set: true, meta: { key } }, () => + schema[key].run(fieldValue), + ); + if (!res.pass) { + return res as RuleRunReturn; + } + } + return RuleRunReturn.Passing(value); +} + +// Types colocated with loose rule +export type LooseRuleInstance>> = + RuleInstance< + ShapeType & Record, + [ShapeType & Record] + >; + +export type LooseShapeValue>> = + ShapeType & Record; diff --git a/packages/n4s/src/rules/schemaRules/optional.ts b/packages/n4s/src/rules/schemaRules/optional.ts new file mode 100644 index 000000000..26cb92eb3 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/optional.ts @@ -0,0 +1,51 @@ +import { isNullish } from 'vest-utils'; + +import { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Makes a validation rule optional by allowing null or undefined values to pass. + * If the value is null or undefined, validation passes without running the inner rule. + * Otherwise, the inner rule is executed. + * + * @template T - The value type to validate + * @param value - The value to validate (may be null/undefined) + * @param rule - The RuleInstance to apply if value is not nullish + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce(undefined).optional(enforce.isString()); // passes + * enforce(null).optional(enforce.isString()); // passes + * enforce('hello').optional(enforce.isString()); // passes + * enforce(123).optional(enforce.isString()); // fails + * + * // Lazy API - useful in schemas + * const userSchema = enforce.shape({ + * name: enforce.isString(), + * middleName: enforce.optional(enforce.isString()), + * age: enforce.isNumber() + * }); + * + * userSchema.test({ name: 'John', age: 30 }); // true (middleName optional) + * userSchema.test({ name: 'John', middleName: null, age: 30 }); // true + * userSchema.test({ name: 'John', middleName: 'Q', age: 30 }); // true + * userSchema.test({ name: 'John', middleName: 123, age: 30 }); // false + * ``` + */ +export function optional( + value: T | undefined | null, + rule: any, +): RuleRunReturn { + if (isNullish(value)) { + return RuleRunReturn.Passing(value); + } + return rule.run(value); +} + +// Type for optional rule instance +export type OptionalRuleInstance = RuleInstance< + T | undefined | null, + [T | undefined | null] +>; diff --git a/packages/n4s/src/rules/schemaRules/partial.ts b/packages/n4s/src/rules/schemaRules/partial.ts new file mode 100644 index 000000000..3bc829248 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/partial.ts @@ -0,0 +1,104 @@ +import { ctx } from 'enforceContext'; +import type { ShapeType } from 'shape'; + +import type { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Checks if value has any keys not present in schema. + */ +function hasExtraKeys>( + value: T, + schema: Record, +): boolean { + for (const key in value) { + if (!(key in schema)) { + return true; + } + } + return false; +} + +/** + * Validates provided keys against their schema rules. + * Missing keys are allowed (partial validation). + */ +function validateProvidedKeys>( + value: T, + schema: Record, +): RuleRunReturn | null { + for (const key in schema) { + if (key in value) { + const fieldValue = value[key]; + const res = ctx.run({ value: fieldValue, set: true, meta: { key } }, () => + schema[key].run(fieldValue), + ); + if (!res.pass) { + return res as RuleRunReturn; + } + } + } + return null; +} + +/** + * partial(value, schema) validates that: + * 1. value's keys are a subset of schema's keys (no extras) + * 2. Zero or more keys may be present (empty object is allowed) + * 3. For each provided key, the corresponding rule passes + */ +/** + * Validates that an object partially matches a schema - schema keys are optional, no extra keys allowed. + * All provided keys must exist in schema and pass their validation rules. + * Missing keys are allowed (making all fields optional). + * + * @template T - The object type to validate + * @param value - The object to validate + * @param schema - Schema mapping keys to validation rules + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce({ name: 'John' }) + * .partial({ + * name: enforce.isString(), + * age: enforce.isNumber(), + * email: enforce.isString() + * }); // passes (age and email are optional) + * + * // Lazy API + * const updateSchema = enforce.partial({ + * name: enforce.isString(), + * email: enforce.isString().matches(/@/), + * age: enforce.isNumber() + * }); + * + * updateSchema.test({}); // true (all fields optional) + * updateSchema.test({ name: 'Jane' }); // true (partial update) + * updateSchema.test({ name: 'Jane', email: 'jane@example.com' }); // true + * updateSchema.test({ name: 'Jane', extra: 'x' }); // false (extra key not in schema) + * ``` + */ +export function partial>( + value: T, + schema: Record, +): RuleRunReturn { + if (hasExtraKeys(value, schema)) { + return RuleRunReturn.Failing(value); + } + + const validationResult = validateProvidedKeys(value, schema); + if (validationResult) { + return validationResult; + } + + return RuleRunReturn.Passing(value); +} + +// Types colocated with partial rule +export type PartialRuleInstance>> = + RuleInstance>, [Partial>]>; + +export type PartialShapeValue>> = + Partial>; diff --git a/packages/n4s/src/rules/schemaRules/schemaRules.ts b/packages/n4s/src/rules/schemaRules/schemaRules.ts new file mode 100644 index 000000000..e916d85c2 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/schemaRules.ts @@ -0,0 +1,8 @@ +import 'schemaRulesLazyTypes'; + +export { isArrayOf, type IsArrayOfRuleInstance } from 'isArrayOf'; +export { loose, type LooseRuleInstance } from 'loose'; +export { optional, type OptionalRuleInstance } from 'optional'; +export { partial, type PartialRuleInstance } from 'partial'; +export { shape, type ShapeRuleInstance } from 'shape'; +export type { SchemaRuleLazyTypes } from 'schemaRulesLazyTypes'; diff --git a/packages/n4s/src/rules/schemaRules/schemaRulesLazyTypes.ts b/packages/n4s/src/rules/schemaRules/schemaRulesLazyTypes.ts new file mode 100644 index 000000000..7e3b8a6be --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/schemaRulesLazyTypes.ts @@ -0,0 +1,34 @@ +/** + * Schema rules type declarations. + */ +import 'isArrayOf'; +import 'loose'; +import 'optional'; +import 'partial'; +import 'shape'; + +import type { RuleInstance } from 'RuleInstance'; +import type { + IsArrayOfRuleInstance, + LooseRuleInstance, + OptionalRuleInstance, + PartialRuleInstance, + ShapeRuleInstance, +} from 'schemaRules'; + +/** + * Type mappings for schema rule lazy API return types + */ +export type SchemaRuleLazyTypes = { + isArrayOf: (...rules: any[]) => IsArrayOfRuleInstance; + loose: >>( + schema: S, + ) => LooseRuleInstance; + optional: (rule: any) => OptionalRuleInstance; + partial: >>( + schema: S, + ) => PartialRuleInstance; + shape: >>( + schema: S, + ) => ShapeRuleInstance; +}; diff --git a/packages/n4s/src/rules/schemaRules/schemaRulesTypes.ts b/packages/n4s/src/rules/schemaRules/schemaRulesTypes.ts new file mode 100644 index 000000000..7da077b54 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/schemaRulesTypes.ts @@ -0,0 +1,36 @@ +import type { LooseShapeValue } from 'loose'; +import type { PartialShapeValue } from 'partial'; +import type { ShapeValue } from 'shape'; + +import { RuleInstance } from 'RuleInstance'; + +export type InferShape = T extends RuleInstance ? R : never; + +export type SchemaInfer>> = { + [K in keyof T as undefined extends InferShape ? never : K]: InferShape< + T[K] + >; +} & { + [K in keyof T as undefined extends InferShape ? K : never]?: InferShape< + T[K] + >; +}; + +export type ShapeType>> = + SchemaInfer; + +export type MultiTypeInput[]> = + InferShape extends never ? unknown : InferShape; + +// Schema rules for object validation +// Centralized mapping of schema rule names to their result value forms. +export type SchemaResultMap>> = { + shape: ShapeValue; + loose: LooseShapeValue; + partial: PartialShapeValue; +}; + +// Schema rules for array validation +export type ArraySchemaResultMap[]> = { + isArrayOf: MultiTypeInput[]; +}; diff --git a/packages/n4s/src/rules/schemaRules/shape.ts b/packages/n4s/src/rules/schemaRules/shape.ts new file mode 100644 index 000000000..8fe6c2ce7 --- /dev/null +++ b/packages/n4s/src/rules/schemaRules/shape.ts @@ -0,0 +1,80 @@ +import { loose } from 'loose'; +import { hasOwnProperty } from 'vest-utils'; + +import type { RuleInstance } from 'RuleInstance'; +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Validates that an object matches a schema exactly - all keys required, no extra keys allowed. + * Each field value is validated against its corresponding RuleInstance in the schema. + * + * @template T - The object type to validate + * @param value - The object to validate + * @param schema - Schema mapping keys to validation rules + * @returns RuleRunReturn indicating success or failure + * + * @example + * ```typescript + * // Eager API + * enforce({ name: 'John', age: 30 }) + * .shape({ + * name: enforce.isString(), + * age: enforce.isNumber().greaterThan(0) + * }); // passes + * + * // Lazy API + * const userSchema = enforce.shape({ + * name: enforce.isString(), + * email: enforce.isString().matches(/@/), + * age: enforce.isNumber().greaterThanOrEquals(18) + * }); + * + * userSchema.test({ name: 'Jane', email: 'jane@example.com', age: 25 }); // true + * userSchema.test({ name: 'Jane', age: 25 }); // false (missing email) + * userSchema.test({ name: 'Jane', email: 'jane@example.com', age: 25, extra: 'x' }); // false (extra key) + * ``` + */ +export function shape>( + value: T, + schema: Record, +): RuleRunReturn { + const baseRes = loose(value, schema); + if (!baseRes.pass) { + return baseRes; + } + + for (const key in value) { + if (!hasOwnProperty(schema, key)) { + return RuleRunReturn.Failing(value); + } + } + + return RuleRunReturn.Passing(value); +} + +// Types colocated with shape rule +export type InferShape = T extends RuleInstance ? R : never; + +export type SchemaInfer>> = { + [K in keyof T as undefined extends InferShape ? never : K]: InferShape< + T[K] + >; +} & { + [K in keyof T as undefined extends InferShape ? K : never]?: InferShape< + T[K] + >; +}; + +export type ShapeType>> = + SchemaInfer; + +export type ShapeRuleInstance>> = + RuleInstance, [ShapeType]>; + +export type ShapeValue>> = + ShapeType; + +export type SchemaValidationRule = >( + value: T, + schema: Record>, +) => RuleRunReturn; diff --git a/packages/n4s/src/rules/shorterThan.ts b/packages/n4s/src/rules/shorterThan.ts deleted file mode 100644 index def0f72f6..000000000 --- a/packages/n4s/src/rules/shorterThan.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { lessThan } from 'lessThan'; - -export function shorterThan( - value: string | unknown[], - arg1: string | number, -): boolean { - return lessThan(value.length, arg1); -} diff --git a/packages/n4s/src/rules/shorterThanOrEquals.ts b/packages/n4s/src/rules/shorterThanOrEquals.ts deleted file mode 100644 index af317db43..000000000 --- a/packages/n4s/src/rules/shorterThanOrEquals.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { lessThanOrEquals } from 'lessThanOrEquals'; - -export function shorterThanOrEquals( - value: string | unknown[], - arg1: string | number, -): boolean { - return lessThanOrEquals(value.length, arg1); -} diff --git a/packages/n4s/src/rules/startsWith.ts b/packages/n4s/src/rules/startsWith.ts deleted file mode 100644 index f70515a6a..000000000 --- a/packages/n4s/src/rules/startsWith.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isStringValue as isString, bindNot } from 'vest-utils'; - -export function startsWith(value: string, arg1: string): boolean { - return isString(value) && isString(arg1) && value.startsWith(arg1); -} - -export const doesNotStartWith = bindNot(startsWith); diff --git a/packages/n4s/src/rules/string/__tests__/doesNotEndWith.test.ts b/packages/n4s/src/rules/string/__tests__/doesNotEndWith.test.ts new file mode 100644 index 000000000..a21c797a3 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/doesNotEndWith.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('doesNotEndWith', () => { + it('pass when string does not end with suffix', () => { + expect(enforce.isString().doesNotEndWith('x').run('hello').pass).toBe(true); + expect(enforce.isString().doesNotEndWith('he').run('hello').pass).toBe( + true, + ); + }); + + it('fails when string ends with suffix', () => { + expect(enforce.isString().doesNotEndWith('lo').run('hello').pass).toBe( + false, + ); + expect(enforce.isString().doesNotEndWith('llo').run('hello').pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/doesNotStartWith.test.ts b/packages/n4s/src/rules/string/__tests__/doesNotStartWith.test.ts new file mode 100644 index 000000000..bd25e6f98 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/doesNotStartWith.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('doesNotStartWith', () => { + it('pass when string does not start with prefix', () => { + expect(enforce.isString().doesNotStartWith('x').run('hello').pass).toBe( + true, + ); + expect(enforce.isString().doesNotStartWith('lo').run('hello').pass).toBe( + true, + ); + }); + + it('fails when string starts with prefix', () => { + expect(enforce.isString().doesNotStartWith('he').run('hello').pass).toBe( + false, + ); + expect(enforce.isString().doesNotStartWith('hel').run('hello').pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/endsWith.test.ts b/packages/n4s/src/rules/string/__tests__/endsWith.test.ts new file mode 100644 index 000000000..547318353 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/endsWith.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('endsWith', () => { + it('pass when string ends with suffix', () => { + expect(enforce.isString().endsWith('lo').run('hello').pass).toBe(true); + expect(enforce.isString().endsWith('').run('hello').pass).toBe(true); + expect(enforce.isString().endsWith('llo').run('hello').pass).toBe(true); + }); + + it('fails when string does not end with suffix', () => { + expect(enforce.isString().endsWith('x').run('hello').pass).toBe(false); + expect(enforce.isString().endsWith('he').run('hello').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/isBlankString.test.ts b/packages/n4s/src/rules/string/__tests__/isBlankString.test.ts new file mode 100644 index 000000000..f590eb7ad --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/isBlankString.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isBlank', () => { + it('pass for empty strings', () => { + expect(enforce.isString().isBlank().run('').pass).toBe(true); + }); + + it('pass for whitespace-only strings', () => { + expect(enforce.isString().isBlank().run(' ').pass).toBe(true); + expect(enforce.isString().isBlank().run(' ').pass).toBe(true); + expect(enforce.isString().isBlank().run('\t').pass).toBe(true); + expect(enforce.isString().isBlank().run('\n').pass).toBe(true); + expect(enforce.isString().isBlank().run('\r\n').pass).toBe(true); + expect(enforce.isString().isBlank().run(' \t \n ').pass).toBe(true); + }); + + it('fails for strings with content', () => { + expect(enforce.isString().isBlank().run('x').pass).toBe(false); + expect(enforce.isString().isBlank().run(' x ').pass).toBe(false); + expect(enforce.isString().isBlank().run('hello').pass).toBe(false); + expect(enforce.isString().isBlank().run(' text ').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/isNotBlank.test.ts b/packages/n4s/src/rules/string/__tests__/isNotBlank.test.ts new file mode 100644 index 000000000..4e680ec7f --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/isNotBlank.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isNotBlank', () => { + it('pass for strings with content', () => { + expect(enforce.isString().isNotBlank().run('x').pass).toBe(true); + expect(enforce.isString().isNotBlank().run('hello').pass).toBe(true); + expect(enforce.isString().isNotBlank().run(' x ').pass).toBe(true); + expect(enforce.isString().isNotBlank().run(' text ').pass).toBe(true); + }); + + it('fails for empty strings', () => { + expect(enforce.isString().isNotBlank().run('').pass).toBe(false); + }); + + it('fails for whitespace-only strings', () => { + expect(enforce.isString().isNotBlank().run(' ').pass).toBe(false); + expect(enforce.isString().isNotBlank().run(' ').pass).toBe(false); + expect(enforce.isString().isNotBlank().run('\t').pass).toBe(false); + expect(enforce.isString().isNotBlank().run('\n').pass).toBe(false); + expect(enforce.isString().isNotBlank().run(' \t \n ').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/isString.test.ts b/packages/n4s/src/rules/string/__tests__/isString.test.ts new file mode 100644 index 000000000..2099e4448 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/isString.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('isString', () => { + describe('base predicate', () => { + it('pass for strings', () => { + expect(enforce.isString().run('a').pass).toBe(true); + expect(enforce.isString().run('').pass).toBe(true); + expect(enforce.isString().run('hello').pass).toBe(true); + expect(enforce.isString().run('123').pass).toBe(true); + }); + + it('fails for non-strings', () => { + const num: any = 1; + const bool: any = true; + const obj: any = {}; + const arr: any = []; + const nul: any = null; + const undef: any = undefined; + expect(enforce.isString().run(num).pass).toBe(false); + expect(enforce.isString().run(bool).pass).toBe(false); + expect(enforce.isString().run(obj).pass).toBe(false); + expect(enforce.isString().run(arr).pass).toBe(false); + expect(enforce.isString().run(nul).pass).toBe(false); + expect(enforce.isString().run(undef).pass).toBe(false); + }); + }); + + describe('startsWith', () => { + it('pass when string starts with prefix', () => { + expect(enforce.isString().startsWith('he').run('hello').pass).toBe(true); + expect(enforce.isString().startsWith('').run('hello').pass).toBe(true); + expect(enforce.isString().startsWith('hel').run('hello').pass).toBe(true); + }); + + it('fails when string does not start with prefix', () => { + expect(enforce.isString().startsWith('x').run('hello').pass).toBe(false); + expect(enforce.isString().startsWith('lo').run('hello').pass).toBe(false); + }); + }); + + describe('doesNotStartWith', () => { + it('pass when string does not start with prefix', () => { + expect(enforce.isString().doesNotStartWith('x').run('hello').pass).toBe( + true, + ); + expect(enforce.isString().doesNotStartWith('lo').run('hello').pass).toBe( + true, + ); + }); + + it('fails when string starts with prefix', () => { + expect(enforce.isString().doesNotStartWith('he').run('hello').pass).toBe( + false, + ); + expect(enforce.isString().doesNotStartWith('hel').run('hello').pass).toBe( + false, + ); + }); + }); + + describe('endsWith', () => { + it('pass when string ends with suffix', () => { + expect(enforce.isString().endsWith('lo').run('hello').pass).toBe(true); + expect(enforce.isString().endsWith('').run('hello').pass).toBe(true); + expect(enforce.isString().endsWith('llo').run('hello').pass).toBe(true); + }); + + it('fails when string does not end with suffix', () => { + expect(enforce.isString().endsWith('x').run('hello').pass).toBe(false); + expect(enforce.isString().endsWith('he').run('hello').pass).toBe(false); + }); + }); + + describe('doesNotEndWith', () => { + it('pass when string does not end with suffix', () => { + expect(enforce.isString().doesNotEndWith('x').run('hello').pass).toBe( + true, + ); + expect(enforce.isString().doesNotEndWith('he').run('hello').pass).toBe( + true, + ); + }); + + it('fails when string ends with suffix', () => { + expect(enforce.isString().doesNotEndWith('lo').run('hello').pass).toBe( + false, + ); + expect(enforce.isString().doesNotEndWith('llo').run('hello').pass).toBe( + false, + ); + }); + }); + + describe('matches', () => { + it('pass when string matches regex', () => { + expect(enforce.isString().matches(/^h/).run('hello').pass).toBe(true); + expect(enforce.isString().matches(/o$/).run('hello').pass).toBe(true); + expect(enforce.isString().matches(/\d+/).run('abc123').pass).toBe(true); + }); + + it('pass with string pattern', () => { + expect(enforce.isString().matches('^h').run('hello').pass).toBe(true); + expect(enforce.isString().matches('o$').run('hello').pass).toBe(true); + }); + + it('fails when string does not match', () => { + expect(enforce.isString().matches(/^x/).run('hello').pass).toBe(false); + expect(enforce.isString().matches(/\d+/).run('hello').pass).toBe(false); + }); + }); + + describe('notMatches', () => { + it('pass when string does not match regex', () => { + expect(enforce.isString().notMatches(/^x/).run('hello').pass).toBe(true); + expect(enforce.isString().notMatches(/\d+/).run('hello').pass).toBe(true); + }); + + it('fails when string matches', () => { + expect(enforce.isString().notMatches(/^h/).run('hello').pass).toBe(false); + expect(enforce.isString().notMatches(/o$/).run('hello').pass).toBe(false); + }); + }); + + describe('isBlank', () => { + it('pass for empty strings', () => { + expect(enforce.isString().isBlank().run('').pass).toBe(true); + }); + + it('pass for whitespace-only strings', () => { + expect(enforce.isString().isBlank().run(' ').pass).toBe(true); + expect(enforce.isString().isBlank().run(' ').pass).toBe(true); + expect(enforce.isString().isBlank().run('\t').pass).toBe(true); + expect(enforce.isString().isBlank().run('\n').pass).toBe(true); + }); + + it('fails for strings with content', () => { + expect(enforce.isString().isBlank().run('x').pass).toBe(false); + expect(enforce.isString().isBlank().run(' x ').pass).toBe(false); + expect(enforce.isString().isBlank().run('hello').pass).toBe(false); + }); + }); + + describe('isNotBlank', () => { + it('pass for strings with content', () => { + expect(enforce.isString().isNotBlank().run('x').pass).toBe(true); + expect(enforce.isString().isNotBlank().run('hello').pass).toBe(true); + expect(enforce.isString().isNotBlank().run(' x ').pass).toBe(true); + }); + + it('fails for empty strings', () => { + expect(enforce.isString().isNotBlank().run('').pass).toBe(false); + }); + + it('fails for whitespace-only strings', () => { + expect(enforce.isString().isNotBlank().run(' ').pass).toBe(false); + expect(enforce.isString().isNotBlank().run(' ').pass).toBe(false); + expect(enforce.isString().isNotBlank().run('\t').pass).toBe(false); + }); + }); + + describe('minLength', () => { + it('pass when string length is greater than or equal to minimum', () => { + expect(enforce.isString().minLength(2).run('hi').pass).toBe(true); + expect(enforce.isString().minLength(2).run('hello').pass).toBe(true); + expect(enforce.isString().minLength(0).run('').pass).toBe(true); + }); + + it('fails when string length is less than minimum', () => { + expect(enforce.isString().minLength(3).run('hi').pass).toBe(false); + expect(enforce.isString().minLength(1).run('').pass).toBe(false); + }); + }); + + describe('maxLength', () => { + it('pass when string length is less than or equal to maximum', () => { + expect(enforce.isString().maxLength(2).run('hi').pass).toBe(true); + expect(enforce.isString().maxLength(5).run('hi').pass).toBe(true); + expect(enforce.isString().maxLength(0).run('').pass).toBe(true); + }); + + it('fails when string length is greater than maximum', () => { + expect(enforce.isString().maxLength(1).run('hi').pass).toBe(false); + expect(enforce.isString().maxLength(2).run('hello').pass).toBe(false); + }); + }); + + describe('lengthEquals', () => { + it('pass when string length equals the specified value', () => { + expect(enforce.isString().lengthEquals(5).run('hello').pass).toBe(true); + expect(enforce.isString().lengthEquals(0).run('').pass).toBe(true); + expect(enforce.isString().lengthEquals(3).run('abc').pass).toBe(true); + }); + + it('fails when string length does not equal the specified value', () => { + expect(enforce.isString().lengthEquals(3).run('hello').pass).toBe(false); + expect(enforce.isString().lengthEquals(1).run('').pass).toBe(false); + }); + }); + + describe('lengthNotEquals', () => { + it('pass when string length does not equal the specified value', () => { + expect(enforce.isString().lengthNotEquals(3).run('hello').pass).toBe( + true, + ); + expect(enforce.isString().lengthNotEquals(1).run('').pass).toBe(true); + }); + + it('fails when string length equals the specified value', () => { + expect(enforce.isString().lengthNotEquals(5).run('hello').pass).toBe( + false, + ); + expect(enforce.isString().lengthNotEquals(0).run('').pass).toBe(false); + }); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/lengthEquals.test.ts b/packages/n4s/src/rules/string/__tests__/lengthEquals.test.ts new file mode 100644 index 000000000..fa3493efa --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/lengthEquals.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lengthEquals', () => { + it('pass when string length equals the specified value', () => { + expect(enforce.isString().lengthEquals(5).run('hello').pass).toBe(true); + expect(enforce.isString().lengthEquals(0).run('').pass).toBe(true); + expect(enforce.isString().lengthEquals(3).run('abc').pass).toBe(true); + expect(enforce.isString().lengthEquals(4).run('test').pass).toBe(true); + }); + + it('fails when string length does not equal the specified value', () => { + expect(enforce.isString().lengthEquals(3).run('hello').pass).toBe(false); + expect(enforce.isString().lengthEquals(1).run('').pass).toBe(false); + expect(enforce.isString().lengthEquals(5).run('test').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/lengthNotEquals.test.ts b/packages/n4s/src/rules/string/__tests__/lengthNotEquals.test.ts new file mode 100644 index 000000000..74e2bbc83 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/lengthNotEquals.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('lengthNotEquals', () => { + it('pass when string length does not equal the specified value', () => { + expect(enforce.isString().lengthNotEquals(3).run('hello').pass).toBe(true); + expect(enforce.isString().lengthNotEquals(1).run('').pass).toBe(true); + expect(enforce.isString().lengthNotEquals(5).run('test').pass).toBe(true); + expect(enforce.isString().lengthNotEquals(10).run('abc').pass).toBe(true); + }); + + it('fails when string length equals the specified value', () => { + expect(enforce.isString().lengthNotEquals(5).run('hello').pass).toBe(false); + expect(enforce.isString().lengthNotEquals(0).run('').pass).toBe(false); + expect(enforce.isString().lengthNotEquals(4).run('test').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/longerThan.test.ts b/packages/n4s/src/rules/string/__tests__/longerThan.test.ts new file mode 100644 index 000000000..c6c05ffa4 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/longerThan.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('longerThan', () => { + it('pass when string length is greater than specified value', () => { + expect(enforce.isString().longerThan(2).run('hello').pass).toBe(true); + expect(enforce.isString().longerThan(0).run('a').pass).toBe(true); + expect(enforce.isString().longerThan(3).run('test').pass).toBe(true); + }); + + it('fails when string length is not greater', () => { + expect(enforce.isString().longerThan(5).run('hello').pass).toBe(false); + expect(enforce.isString().longerThan(5).run('hi').pass).toBe(false); + expect(enforce.isString().longerThan(0).run('').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/longerThanOrEquals.test.ts b/packages/n4s/src/rules/string/__tests__/longerThanOrEquals.test.ts new file mode 100644 index 000000000..e10952a39 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/longerThanOrEquals.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('longerThanOrEquals', () => { + it('pass when string length is greater than or equal to specified value', () => { + expect(enforce.isString().longerThanOrEquals(5).run('hello').pass).toBe( + true, + ); + expect(enforce.isString().longerThanOrEquals(3).run('hello').pass).toBe( + true, + ); + expect(enforce.isString().longerThanOrEquals(0).run('').pass).toBe(true); + }); + + it('fails when string length is less than specified value', () => { + expect(enforce.isString().longerThanOrEquals(6).run('hello').pass).toBe( + false, + ); + expect(enforce.isString().longerThanOrEquals(5).run('test').pass).toBe( + false, + ); + expect(enforce.isString().longerThanOrEquals(1).run('').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/matches.test.ts b/packages/n4s/src/rules/string/__tests__/matches.test.ts new file mode 100644 index 000000000..020b7e2cf --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/matches.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('matches', () => { + it('pass when string matches regex', () => { + expect(enforce.isString().matches(/^h/).run('hello').pass).toBe(true); + expect(enforce.isString().matches(/o$/).run('hello').pass).toBe(true); + expect(enforce.isString().matches(/\d+/).run('abc123').pass).toBe(true); + }); + + it('pass with string pattern', () => { + expect(enforce.isString().matches('^h').run('hello').pass).toBe(true); + expect(enforce.isString().matches('o$').run('hello').pass).toBe(true); + }); + + it('fails when string does not match', () => { + expect(enforce.isString().matches(/^x/).run('hello').pass).toBe(false); + expect(enforce.isString().matches(/\d+/).run('hello').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/maxLength.test.ts b/packages/n4s/src/rules/string/__tests__/maxLength.test.ts new file mode 100644 index 000000000..2a9baf03a --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/maxLength.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('maxLength', () => { + it('pass when string length is less than or equal to maximum', () => { + expect(enforce.isString().maxLength(2).run('hi').pass).toBe(true); + expect(enforce.isString().maxLength(5).run('hi').pass).toBe(true); + expect(enforce.isString().maxLength(0).run('').pass).toBe(true); + expect(enforce.isString().maxLength(5).run('hello').pass).toBe(true); + }); + + it('fails when string length is greater than maximum', () => { + expect(enforce.isString().maxLength(1).run('hi').pass).toBe(false); + expect(enforce.isString().maxLength(2).run('hello').pass).toBe(false); + expect(enforce.isString().maxLength(3).run('test').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/minLength.test.ts b/packages/n4s/src/rules/string/__tests__/minLength.test.ts new file mode 100644 index 000000000..ef101976d --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/minLength.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('minLength', () => { + it('pass when string length is greater than or equal to minimum', () => { + expect(enforce.isString().minLength(2).run('hi').pass).toBe(true); + expect(enforce.isString().minLength(2).run('hello').pass).toBe(true); + expect(enforce.isString().minLength(0).run('').pass).toBe(true); + expect(enforce.isString().minLength(3).run('abc').pass).toBe(true); + }); + + it('fails when string length is less than minimum', () => { + expect(enforce.isString().minLength(3).run('hi').pass).toBe(false); + expect(enforce.isString().minLength(1).run('').pass).toBe(false); + expect(enforce.isString().minLength(5).run('test').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/notMatches.test.ts b/packages/n4s/src/rules/string/__tests__/notMatches.test.ts new file mode 100644 index 000000000..40b1ea1ea --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/notMatches.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('notMatches', () => { + it('pass when string does not match regex', () => { + expect(enforce.isString().notMatches(/^x/).run('hello').pass).toBe(true); + expect(enforce.isString().notMatches(/\d+/).run('hello').pass).toBe(true); + }); + + it('pass with string pattern', () => { + expect(enforce.isString().notMatches('^x').run('hello').pass).toBe(true); + expect(enforce.isString().notMatches('\\d+').run('hello').pass).toBe(true); + }); + + it('fails when string matches', () => { + expect(enforce.isString().notMatches(/^h/).run('hello').pass).toBe(false); + expect(enforce.isString().notMatches(/o$/).run('hello').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/shorterThan.test.ts b/packages/n4s/src/rules/string/__tests__/shorterThan.test.ts new file mode 100644 index 000000000..619d676d3 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/shorterThan.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('shorterThan', () => { + it('pass when string length is less than specified value', () => { + expect(enforce.isString().shorterThan(6).run('hello').pass).toBe(true); + expect(enforce.isString().shorterThan(5).run('test').pass).toBe(true); + expect(enforce.isString().shorterThan(1).run('').pass).toBe(true); + }); + + it('fails when string length is not less', () => { + expect(enforce.isString().shorterThan(5).run('hello').pass).toBe(false); + expect(enforce.isString().shorterThan(3).run('hello').pass).toBe(false); + expect(enforce.isString().shorterThan(0).run('').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/shorterThanOrEquals.test.ts b/packages/n4s/src/rules/string/__tests__/shorterThanOrEquals.test.ts new file mode 100644 index 000000000..589004799 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/shorterThanOrEquals.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('shorterThanOrEquals', () => { + it('pass when string length is less than or equal to specified value', () => { + expect(enforce.isString().shorterThanOrEquals(5).run('hello').pass).toBe( + true, + ); + expect(enforce.isString().shorterThanOrEquals(6).run('hello').pass).toBe( + true, + ); + expect(enforce.isString().shorterThanOrEquals(0).run('').pass).toBe(true); + }); + + it('fails when string length is greater than specified value', () => { + expect(enforce.isString().shorterThanOrEquals(4).run('hello').pass).toBe( + false, + ); + expect(enforce.isString().shorterThanOrEquals(3).run('test').pass).toBe( + false, + ); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/startsWith.test.ts b/packages/n4s/src/rules/string/__tests__/startsWith.test.ts new file mode 100644 index 000000000..e34db8ce8 --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/startsWith.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('startsWith', () => { + it('pass when string starts with prefix', () => { + expect(enforce.isString().startsWith('he').run('hello').pass).toBe(true); + expect(enforce.isString().startsWith('').run('hello').pass).toBe(true); + expect(enforce.isString().startsWith('hel').run('hello').pass).toBe(true); + }); + + it('fails when string does not start with prefix', () => { + expect(enforce.isString().startsWith('x').run('hello').pass).toBe(false); + expect(enforce.isString().startsWith('lo').run('hello').pass).toBe(false); + }); +}); diff --git a/packages/n4s/src/rules/string/__tests__/stringRules.test.ts b/packages/n4s/src/rules/string/__tests__/stringRules.test.ts new file mode 100644 index 000000000..e7b6261ef --- /dev/null +++ b/packages/n4s/src/rules/string/__tests__/stringRules.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { enforce } from 'n4s'; + +describe('stringRules', () => { + it('should return true when all rules pass', () => { + expect(enforce.isString().endsWith('log').run('catalog').pass).toBe(true); + expect(enforce.isString().startsWith('cat').run('catalog').pass).toBe(true); + expect( + enforce.isString().startsWith('cat').endsWith('log').run('catalog').pass, + ).toBe(true); + }); + + it('should return false when any rule fails', () => { + expect(enforce.isString().endsWith('log').run('cat').pass).toBe(false); + expect(enforce.isString().startsWith('dog').run('catalog').pass).toBe( + false, + ); + expect( + enforce.isString().startsWith('cat').endsWith('dog').run('catalog').pass, + ).toBe(false); + }); + + it('should handle multiple rules', () => { + expect(enforce.isString().minLength(3).maxLength(5).run('four').pass).toBe( + true, + ); + expect( + enforce.isString().minLength(3).maxLength(5).run('more_than_five').pass, + ).toBe(false); + expect(enforce.isString().minLength(3).maxLength(5).run('a').pass).toBe( + false, + ); + expect(enforce.isString().lengthEquals(4).run('four').pass).toBe(true); + expect(enforce.isString().lengthNotEquals(4).run('four').pass).toBe(false); + expect(enforce.isString().longerThan(3).run('four').pass).toBe(true); + expect(enforce.isString().longerThanOrEquals(4).run('four').pass).toBe( + true, + ); + expect(enforce.isString().shorterThan(5).run('four').pass).toBe(true); + expect(enforce.isString().shorterThanOrEquals(4).run('four').pass).toBe( + true, + ); + }); + + it('should handle regex matching', () => { + expect( + enforce + .isString() + .matches(/^[a-z]+$/) + .run('abc').pass, + ).toBe(true); + expect( + enforce + .isString() + .matches(/^[a-z]+$/) + .run('ab1c').pass, + ).toBe(false); + expect(enforce.isString().matches('[a-z]+').run('abc').pass).toBe(true); + expect(enforce.isString().notMatches('[0-9]+').run('abc').pass).toBe(true); + }); + + it('should handle isBlank / isNotBlank for strings', () => { + expect(enforce.isString().isBlank().run(' ').pass).toBe(true); + expect(enforce.isString().isBlank().run('').pass).toBe(true); + expect(enforce.isString().isBlank().run(' a ').pass).toBe(false); + + expect(enforce.isString().isNotBlank().run('a').pass).toBe(true); + expect(enforce.isString().isNotBlank().run(' ').pass).toBe(false); + }); + + it('should handle doesNotStartWith / doesNotEndWith', () => { + expect(enforce.isString().doesNotStartWith('dog').run('catalog').pass).toBe( + true, + ); + expect(enforce.isString().doesNotStartWith('cat').run('catalog').pass).toBe( + false, + ); + expect(enforce.isString().doesNotEndWith('dog').run('catalog').pass).toBe( + true, + ); + expect(enforce.isString().doesNotEndWith('log').run('catalog').pass).toBe( + false, + ); + }); + + it('inside / notInside with string and array containers', () => { + // string container + expect(enforce.isString().inside('hello world').run('world').pass).toBe( + true, + ); + expect(enforce.isString().notInside('hello world').run('mars').pass).toBe( + true, + ); + + // array-of-strings container + expect(enforce.isString().inside(['red', 'green']).run('red').pass).toBe( + true, + ); + expect( + enforce.isString().notInside(['red', 'green']).run('blue').pass, + ).toBe(true); + }); + + it('should handle complex chaining', () => { + expect( + enforce + .isString() + .minLength(5) + .maxLength(10) + .startsWith('start') + .endsWith('end') + .run('start-middle-end').pass, + ).toBe(false); + expect( + enforce + .isString() + .minLength(5) + .maxLength(20) + .startsWith('start') + .endsWith('end') + .run('start-middle-end').pass, + ).toBe(true); + }); +}); diff --git a/packages/n4s/src/rules/string/doesNotEndWith.ts b/packages/n4s/src/rules/string/doesNotEndWith.ts new file mode 100644 index 000000000..5044e97cc --- /dev/null +++ b/packages/n4s/src/rules/string/doesNotEndWith.ts @@ -0,0 +1,6 @@ +import { endsWith } from 'endsWith'; + +// Checks if string does not end with the given suffix +export function doesNotEndWith(str: string, ending: string): boolean { + return !endsWith(str, ending); +} diff --git a/packages/n4s/src/rules/string/doesNotStartWith.ts b/packages/n4s/src/rules/string/doesNotStartWith.ts new file mode 100644 index 000000000..355dc1e5e --- /dev/null +++ b/packages/n4s/src/rules/string/doesNotStartWith.ts @@ -0,0 +1,6 @@ +import { startsWith } from 'startsWith'; + +// Checks if string does not start with the given prefix +export function doesNotStartWith(str: string, start: string): boolean { + return !startsWith(str, start); +} diff --git a/packages/n4s/src/rules/string/endsWith.ts b/packages/n4s/src/rules/string/endsWith.ts new file mode 100644 index 000000000..b59ba6ffe --- /dev/null +++ b/packages/n4s/src/rules/string/endsWith.ts @@ -0,0 +1,4 @@ +// Checks if string ends with the given suffix +export function endsWith(str: string, ending: string): boolean { + return str.endsWith(ending); +} diff --git a/packages/n4s/src/rules/string/isBlankString.ts b/packages/n4s/src/rules/string/isBlankString.ts new file mode 100644 index 000000000..f86fb4274 --- /dev/null +++ b/packages/n4s/src/rules/string/isBlankString.ts @@ -0,0 +1,7 @@ +import { isBlank } from 'isBlank'; +import { isStringValue } from 'vest-utils'; + +// Checks if string contains only whitespace characters +export function isBlankString(str: string): boolean { + return isStringValue(str) && isBlank(str); +} diff --git a/packages/n4s/src/rules/string/isNotBlank.ts b/packages/n4s/src/rules/string/isNotBlank.ts new file mode 100644 index 000000000..129c42465 --- /dev/null +++ b/packages/n4s/src/rules/string/isNotBlank.ts @@ -0,0 +1,4 @@ +// Checks if string contains non-whitespace characters +export function isNotBlank(str: string): boolean { + return str.trim().length > 0; +} diff --git a/packages/n4s/src/rules/string/isString.ts b/packages/n4s/src/rules/string/isString.ts new file mode 100644 index 000000000..750496f14 --- /dev/null +++ b/packages/n4s/src/rules/string/isString.ts @@ -0,0 +1,26 @@ +import { isStringValue } from 'vest-utils'; + +/** + * Validates that a value is a string. + * Type guard that narrows the type to string. + * + * @param value - Value to validate + * @returns True if value is a string + * + * @example + * ```typescript + * // Eager API + * enforce('hello').isString(); // passes + * enforce(123).isString(); // fails + * + * // Lazy API + * const stringRule = enforce.isString(); + * stringRule.test('hello'); // true + * + * // Chains with string-specific rules + * enforce('hello').isString().longerThan(3); + * ``` + */ +export function isString(value: any): value is string { + return isStringValue(value); +} diff --git a/packages/n4s/src/rules/string/matches.ts b/packages/n4s/src/rules/string/matches.ts new file mode 100644 index 000000000..15e3af978 --- /dev/null +++ b/packages/n4s/src/rules/string/matches.ts @@ -0,0 +1,7 @@ +import { toRegExp } from 'regex'; + +// Checks if string matches the given regular expression pattern +export function matches(str: string, regex: RegExp | string): boolean { + const r = toRegExp(regex); + return !!r && r.test(str); +} diff --git a/packages/n4s/src/rules/string/notMatches.ts b/packages/n4s/src/rules/string/notMatches.ts new file mode 100644 index 000000000..48f8172a0 --- /dev/null +++ b/packages/n4s/src/rules/string/notMatches.ts @@ -0,0 +1,7 @@ +import { toRegExp } from 'regex'; + +// Checks if string does not match the given regular expression pattern +export function notMatches(str: string, regex: RegExp | string): boolean { + const r = toRegExp(regex); + return !!r && !r.test(str); +} diff --git a/packages/n4s/src/rules/string/startsWith.ts b/packages/n4s/src/rules/string/startsWith.ts new file mode 100644 index 000000000..2aa656910 --- /dev/null +++ b/packages/n4s/src/rules/string/startsWith.ts @@ -0,0 +1,4 @@ +// Checks if string starts with the given prefix +export function startsWith(str: string, start: string): boolean { + return str.startsWith(start); +} diff --git a/packages/n4s/src/rules/stringRules.ts b/packages/n4s/src/rules/stringRules.ts new file mode 100644 index 000000000..6864dc91f --- /dev/null +++ b/packages/n4s/src/rules/stringRules.ts @@ -0,0 +1,75 @@ +import { BuildRuleInstance, ExtractRuleFunctions } from 'RuleInstanceBuilder'; +import { equals, notEquals } from 'commonComparison'; +import { inside, notInside } from 'commonContainer'; +import { + lengthEquals, + lengthNotEquals, + longerThan, + longerThanOrEquals, + maxLength, + minLength, + shorterThan, + shorterThanOrEquals, +} from 'commonLength'; +import { doesNotEndWith } from 'doesNotEndWith'; +import { doesNotStartWith } from 'doesNotStartWith'; +import { endsWith } from 'endsWith'; +import { isBlankString as isBlank } from 'isBlankString'; +import { isNotBlank } from 'isNotBlank'; +import { isString } from 'isString'; +import { matches } from 'matches'; +import { notMatches } from 'notMatches'; +import { startsWith } from 'startsWith'; + +export { + doesNotEndWith, + doesNotStartWith, + endsWith, + equals, + inside, + isBlank, + isNotBlank, + isString, + lengthEquals, + lengthNotEquals, + longerThan, + longerThanOrEquals, + matches, + maxLength, + minLength, + notEquals, + notInside, + notMatches, + shorterThan, + shorterThanOrEquals, + startsWith, +}; + +const stringRules = { + doesNotEndWith, + doesNotStartWith, + endsWith, + equals, + inside, + isBlank, + isNotBlank, + lengthEquals, + lengthNotEquals, + longerThan, + longerThanOrEquals, + matches, + maxLength, + minLength, + notEquals, + notInside, + notMatches, + shorterThan, + shorterThanOrEquals, + startsWith, +} as const; + +export type StringRuleInstance = BuildRuleInstance< + string, + [string], + ExtractRuleFunctions +>; diff --git a/packages/n4s/src/runtime/__tests__/enforceContext.test.ts b/packages/n4s/src/runtime/__tests__/enforceContext.test.ts deleted file mode 100644 index 8dfce618f..000000000 --- a/packages/n4s/src/runtime/__tests__/enforceContext.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { enforce } from 'enforce'; -import * as ruleReturn from 'ruleReturn'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import 'schema'; -import 'compounds'; - -let keepContext = vi.fn(); - -describe('enforce.context', () => { - beforeEach(() => { - keepContext = vi.fn(); - }); - - describe('base structure', () => { - it('Should match snapshot', () => { - enforce({}).someCustomRule(); - expect(keepContext.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "meta": {}, - "parent": [Function], - "value": {}, - } - `); - }); - }); - - describe('When in top level', () => { - it('Should return top level value when not in a nested rule', () => { - enforce('some_value').someCustomRule(); - - expect(keepContext.mock.calls[0][0].value).toBe('some_value'); - }); - - test('context.parent() returns null when in top level', () => { - enforce('some_value').someCustomRule(); - expect(keepContext.mock.calls[0][0].parent()).toBeNull(); - }); - }); - - describe('context.parent traversal', () => { - it('Allows traversal to parent values via "parent"', () => { - enforce({ - name: { - first: 'Elle', - }, - siblings: ['Danny'], - }).loose({ - name: enforce - .shape({ - first: enforce.isString().someCustomRule(), - }) - .someCustomRule(), - siblings: enforce - .isArrayOf(enforce.isString().someCustomRule()) - .someCustomRule(), - }); - - // first.parent() === name - expect(keepContext.mock.calls[0][0].parent()).toEqual( - keepContext.mock.calls[1][0], - ); - - // siblings[0].parent() === siblings - expect(keepContext.mock.calls[2][0].parent()).toEqual( - keepContext.mock.calls[3][0], - ); - }); - - it('Should return null when no further parents to traverse to', () => { - enforce({ - name: { - first: 'Elle', - }, - siblings: ['Danny'], - }) - .loose({ - name: enforce - .shape({ - first: enforce.isString().someCustomRule(), - }) - .someCustomRule(), - siblings: enforce - .isArrayOf(enforce.isString().someCustomRule()) - .someCustomRule(), - }) - .someCustomRule(); - - expect( - keepContext.mock.calls[0][0].parent().parent().parent(), - ).toBeNull(); - expect(keepContext.mock.calls[1][0].parent().parent()).toBeNull(); - expect(keepContext.mock.calls[4][0].parent()).toBeNull(); - }); - }); - - describe('In schema rules', () => { - describe.each(['shape', 'loose'])('enforce.%s', (methodName: string) => { - it('Should add the current value within shape rules', () => { - enforce({ - name: { - first: 'Elle', - last: 'Tester', - middle: 'Sophie', - }, - }) - [methodName]({ - name: enforce[methodName]({ - first: enforce.isString().someCustomRule(), - last: enforce.isString().someCustomRule(), - middle: enforce.optional(enforce.isString().someCustomRule()), - }).someCustomRule(), - }) - .someCustomRule(); - - expect(keepContext.mock.calls[0][0].value).toBe('Elle'); // first - expect(keepContext.mock.calls[1][0].value).toBe('Tester'); // last - expect(keepContext.mock.calls[2][0].value).toBe('Sophie'); // middle - expect(keepContext.mock.calls[3][0].value).toEqual({ - first: 'Elle', - last: 'Tester', - middle: 'Sophie', - }); // name - expect(keepContext.mock.calls[4][0].value).toEqual({ - name: { - first: 'Elle', - last: 'Tester', - middle: 'Sophie', - }, - }); // top level shape - }); - it('Adds name of current key to "meta"', () => { - enforce({ - name: { - first: 'Elle', - last: 'Tester', - middle: 'Sophie', - }, - })[methodName]({ - name: enforce[methodName]({ - first: enforce.isString().someCustomRule(), - last: enforce.isString().someCustomRule(), - middle: enforce.optional(enforce.isString().someCustomRule()), - }).someCustomRule(), - }); - - expect(keepContext.mock.calls[0][0].meta).toEqual({ key: 'first' }); - expect(keepContext.mock.calls[1][0].meta).toEqual({ key: 'last' }); - expect(keepContext.mock.calls[2][0].meta).toEqual({ key: 'middle' }); - expect(keepContext.mock.calls[3][0].meta).toEqual({ key: 'name' }); - }); - }); - - describe('enforce.isArrayOf', () => { - it('passes the current value into the context', () => { - enforce(['Elle', 'Tester', 'Sophie']).isArrayOf( - enforce.isString().someCustomRule(), - ); - - expect(keepContext.mock.calls[0][0].value).toBe('Elle'); - expect(keepContext.mock.calls[1][0].value).toBe('Tester'); - expect(keepContext.mock.calls[2][0].value).toBe('Sophie'); - }); - - it('passes the current index into the context meta field', () => { - enforce(['Elle', 'Tester', 'Sophie']).isArrayOf( - enforce.isString().someCustomRule(), - ); - expect(keepContext.mock.calls[0][0].meta).toEqual({ index: 0 }); - expect(keepContext.mock.calls[1][0].meta).toEqual({ index: 1 }); - expect(keepContext.mock.calls[2][0].meta).toEqual({ index: 2 }); - }); - }); - }); - - describe('real usecase example', () => { - it('Should fail if username is in the friends list', () => { - expect(() => - enforce({ - username: 'johndoe', - friends: ['Mike', 'Jim', 'johndoe'], - }).shape({ - username: enforce.isString(), - friends: enforce.isArrayOf( - enforce.isString().isFriendTheSameAsUser(), - ), - }), - ).toThrow(); - }); - - it('Should pass if username is not in the friends list', () => { - enforce({ - username: 'johndoe', - friends: ['Mike', 'Jim'], - }).shape({ - username: enforce.isString(), - friends: enforce.isArrayOf(enforce.isString().isFriendTheSameAsUser()), - }); - }); - }); -}); - -enforce.extend({ - someCustomRule: () => { - const context = enforce.context(); - - // Just an easy way of peeking - // into the context from the test - keepContext(context); - return true; - }, - isFriendTheSameAsUser: value => { - const context = enforce.context(); - - if (value === context?.parent()?.parent()?.value.username) { - return ruleReturn.failing(); - } - - return true; - }, -}); diff --git a/packages/n4s/src/runtime/__tests__/message.test.ts b/packages/n4s/src/runtime/__tests__/message.test.ts deleted file mode 100644 index d8a40a93c..000000000 --- a/packages/n4s/src/runtime/__tests__/message.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { enforce } from 'enforce'; -import ruleReturn from 'ruleReturn'; -import { describe, it, expect, vi } from 'vitest'; - -describe('enforce..message()', () => { - it('Should set the failure message in builtin rules', () => { - expect( - enforce.equals(false).message('oof. Expected true to be false').run(true), - ).toEqual(ruleReturn(false, 'oof. Expected true to be false')); - - expect( - enforce - .equals(false) - .message(() => 'oof. Expected true to be false') - .run(true), - ).toEqual(ruleReturn(false, 'oof. Expected true to be false')); - }); - - it('Should set the failure message in custom rules', () => { - expect( - enforce.ruleWithFailureMessage().message('oof. Failed again!').run(true), - ).toEqual(ruleReturn(false, 'oof. Failed again!')); - - expect( - enforce - .ruleWithFailureMessage() - .message(() => 'oof. Failed again!') - .run(true), - ).toEqual(ruleReturn(false, 'oof. Failed again!')); - }); - - describe('.message callback', () => { - it('Should be passed the rule value as the first argument', () => { - const msg = vi.fn(() => 'some message'); - const arg = {}; - expect(enforce.equals(false).message(msg).run(arg)).toEqual( - ruleReturn(false, 'some message'), - ); - expect(msg).toHaveBeenCalledWith(arg, undefined); - }); - - it('Should pass original messages the second argument if exists', () => { - const msg = vi.fn(() => 'some message'); - const arg = {}; - expect( - enforce.ruleWithFailureMessage(false).message(msg).run(arg), - ).toEqual(ruleReturn(false, 'some message')); - expect(msg).toHaveBeenCalledWith(arg, 'This should not be seen!'); - }); - }); -}); - -describe('enforce().message()', () => { - it('should return message as a function', () => { - expect(enforce(3).message).toBeInstanceOf(Function); - }); - it('should return message after chainning', () => { - expect(enforce(1).equals(1).message).toBeInstanceOf(Function); - }); - - it('Should throw a literal string', () => { - let i; - try { - enforce(1).message('oogie booogie').equals(2); - } catch (e) { - i = e; - } - expect(i).toBe('oogie booogie'); - }); - - it('should throw the message error on failure', () => { - expect(() => { - enforce('').message('octopus').equals('evyatar'); - }).toThrow('octopus'); - }); - it('should throw the message error on failure with the last message that failed', () => { - expect(() => { - enforce(10) - .message('must be a number!') - .isNumeric() - .message('too high') - .lessThan(8); - }).toThrow('too high'); - }); -}); - -enforce.extend({ - ruleWithFailureMessage: () => ({ - pass: false, - message: 'This should not be seen!', - }), -}); diff --git a/packages/n4s/src/runtime/enforce.ts b/packages/n4s/src/runtime/enforce.ts deleted file mode 100644 index 352a815df..000000000 --- a/packages/n4s/src/runtime/enforce.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { assign } from 'vest-utils'; - -import { ctx, EnforceContext } from 'enforceContext'; -import enforceEager, { EnforceEager } from 'enforceEager'; -import genEnforceLazy, { LazyRules } from 'genEnforceLazy'; -import { Rule, baseRules, getRule } from 'runtimeRules'; -/** - * Enforce is quite complicated, I want to explain it in detail. - * It is dynamic in nature, so a lot of proxy objects are involved. - * - * Enforce has two main interfaces - * 1. eager - * 2. lazy - * - * The eager interface is the most commonly used, and the easier to understand. - * It throws an error when a rule is not satisfied. - * The eager interface is declared in enforceEager.ts and it is quite simple to understand. - * enforce is called with a value, and the return value is a proxy object that points back to all the rules. - * When a rule is called, the value is mapped as its first argument, and if the rule passes, the same - * proxy object is returned. Otherwise, an error is thrown. - * - * The lazy interface works quite differently. It is declared in genEnforceLazy.ts. - * Rather than calling enforce directly, the lazy interface has all the rules as "methods" (only by proxy). - * Calling the first function in the chain will initialize an array of calls. It stores the different rule calls - * and the parameters passed to them. None of the rules are called yet. - * The rules are only invoked in sequence once either of these chained functions are called: - * 1. test(value) - * 2. run(value) - * - * Calling run or test will call all the rules in sequence, with the difference that test will only return a boolean value, - * while run will return an object with the validation result and an optional message created by the rule. - */ - -function genEnforce(): Enforce { - const target = { - context: () => ctx.useX(), - extend: (customRules: Rule) => { - assign(baseRules, customRules); - }, - } as Enforce; - - return new Proxy(assign(enforceEager, target) as Enforce, { - get: (target: Enforce, key: string) => { - if (key in target) { - return target[key]; - } - - if (!getRule(key)) { - return; - } - - // Only on the first rule access - start the chain of calls - return genEnforceLazy(key); - }, - }); -} - -export const enforce = genEnforce(); - -type Enforce = EnforceMethods & LazyRules & EnforceEager; - -type EnforceMethods = { - context: () => EnforceContext; - extend: (customRules: Rule) => void; -}; diff --git a/packages/n4s/src/runtime/enforceEager.ts b/packages/n4s/src/runtime/enforceEager.ts deleted file mode 100644 index b66194a93..000000000 --- a/packages/n4s/src/runtime/enforceEager.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { invariant, StringObject, isNullish, Maybe } from 'vest-utils'; - -import { ctx } from 'enforceContext'; -import { getRule, RuleValue, Args, RuleBase } from 'runtimeRules'; -import { transformResult } from 'transformResult'; - -type IRules = n4s.IRules & EnforceEagerReturn>; -type TModifiers = { - message: (input: string) => EnforceEagerReturn; -}; - -type EnforceEagerReturn = IRules & - TModifiers & { - pass: boolean; - }; - -// eslint-disable-next-line max-lines-per-function -export default function enforceEager(value: RuleValue): EnforceEagerReturn { - const target = { - message, - pass: false, - } as EnforceEagerReturn; - let customMessage: Maybe = undefined; - - // We create a proxy intercepting access to the target object (which is empty). - const proxy: EnforceEagerReturn = new Proxy(target, { - get: (_, key: string) => { - // On property access, we identify if it is a rule or not. - const rule = getRule(key); - - // If it is a rule, we wrap it with `genRuleCall` that adds the base enforce behavior - if (rule) { - return genRuleCall(proxy, rule, key); - } - return target[key]; - }, - }); - - return proxy; - - // This function is used to wrap a rule with the base enforce behavior - // It takes the target object, the rule function, and the rule name - // It then returns the rule, in a manner that can be used by enforce - function genRuleCall( - target: EnforceEagerReturn, - rule: RuleBase, - ruleName: string, - ) { - return function ruleCall(...args: Args): EnforceEagerReturn { - // Order of operation: - // 1. Create a context with the value being enforced - // 2. Call the rule within the context, and pass over the arguments passed to it - // 3. Transform the result to the correct output format - const transformedResult = ctx.run({ value }, () => { - return transformResult(rule(value, ...args), ruleName, value, ...args); - }); - - function enforceMessage() { - if (!isNullish(customMessage)) return StringObject(customMessage); - if (isNullish(transformedResult.message)) { - return `enforce/${ruleName} failed with ${JSON.stringify(value)}`; - } - return StringObject(transformedResult.message); - } - - // On rule failure (the result is false), we either throw an error - // or throw a string value if the rule has a message defined in it. - invariant(transformedResult.pass, enforceMessage()); - - // This is not really needed because it will always be true - // As we're throwing an error on failure - // but it is here so that users have a sense of what is happening - // when they try to log the result of enforce and not just see a proxy object - target.pass = transformedResult.pass; - - return target; - }; - } - - function message(input: string): EnforceEagerReturn { - customMessage = input; - return proxy; - } -} - -export type EnforceEager = typeof enforceEager; diff --git a/packages/n4s/src/runtime/genEnforceLazy.ts b/packages/n4s/src/runtime/genEnforceLazy.ts deleted file mode 100644 index 70e2c2a81..000000000 --- a/packages/n4s/src/runtime/genEnforceLazy.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - mapFirst, - optionalFunctionValue, - CB, - Stringable, - Maybe, - DynamicValue, -} from 'vest-utils'; - -import { ctx } from 'enforceContext'; -import ruleReturn, { defaultToPassing, RuleDetailedResult } from 'ruleReturn'; -import { RuleValue, Args, getRule } from 'runtimeRules'; -import { transformResult } from 'transformResult'; - -// eslint-disable-next-line max-lines-per-function -export default function genEnforceLazy(key: string) { - const registeredRules: RegisteredRules = []; - let lazyMessage: Maybe; - - return addLazyRule(key); - - // eslint-disable-next-line max-lines-per-function - function addLazyRule(ruleName: string) { - // eslint-disable-next-line max-lines-per-function - return (...args: Args): Lazy => { - const rule = getRule(ruleName); - - registeredRules.push((value: RuleValue) => - transformResult(rule(value, ...args), ruleName, value, ...args), - ); - - let proxy = { - run: (value: RuleValue): RuleDetailedResult => { - return defaultToPassing( - mapFirst(registeredRules, (rule, breakout) => { - const res = ctx.run({ value }, () => rule(value)); - - breakout( - !res.pass, - ruleReturn( - !!res.pass, - optionalFunctionValue(lazyMessage, value, res.message) ?? - res.message, - ), - ); - }), - ); - }, - test: (value: RuleValue): boolean => proxy.run(value).pass, - message: (message: Stringable): Lazy => { - if (message) { - lazyMessage = message; - } - - return proxy; - }, - } as Lazy; - - // reassigning the proxy here is not pretty - // but it's a cleaner way of getting `run` and `test` for free - proxy = new Proxy(proxy, { - get: (target, key: string) => { - if (getRule(key)) { - return addLazyRule(key); - } - - return target[key]; // already has `run` and `test` on it - }, - }); - return proxy; - }; - } -} - -export type LazyRules = n4s.IRules; - -export type Lazy = LazyRules & - LazyRuleMethods & - // This is a "catch all" hack to make TS happy while not - // losing type hints - Record; - -type LazyRuleMethods = LazyRuleRunners & { - message: (message: LazyMessage) => Lazy; -}; - -export type LazyRuleRunners = { - test: (value: unknown) => boolean; - run: (value: unknown) => RuleDetailedResult; -}; - -export type ComposeResult = LazyRuleRunners & ((value: any) => void); - -type RegisteredRules = Array<(value: RuleValue) => RuleDetailedResult>; -type LazyMessage = DynamicValue< - string, - [value: unknown, originalMessage?: Stringable] ->; diff --git a/packages/n4s/src/runtime/rules.ts b/packages/n4s/src/runtime/rules.ts deleted file mode 100644 index e01138786..000000000 --- a/packages/n4s/src/runtime/rules.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - greaterThan, - isNull, - isNotNull, - isNullish, - isNotNullish, - isNumeric, - isNotNumeric, - isUndefined, - isNotUndefined, - lengthEquals, - lengthNotEquals, - longerThan, - numberEquals, - numberNotEquals, - isArray, - isNotArray, - isPositive, - isEmpty, - isNotEmpty, -} from 'vest-utils'; - -import { endsWith, doesNotEndWith } from 'endsWith'; -import { equals, notEquals } from 'equals'; -import { greaterThanOrEquals } from 'greaterThanOrEquals'; -import { inside, notInside } from 'inside'; -import { isBetween, isNotBetween } from 'isBetween'; -import { isBlank, isNotBlank } from 'isBlank'; -import { isBoolean, isNotBoolean } from 'isBoolean'; -import { isEven } from 'isEven'; -import { isKeyOf, isNotKeyOf } from 'isKeyOf'; -import { isNaN, isNotNaN } from 'isNaN'; -import { isNegative } from 'isNegative'; -import { isNumber, isNotNumber } from 'isNumber'; -import { isOdd } from 'isOdd'; -import { isString, isNotString } from 'isString'; -import { isTruthy, isFalsy } from 'isTruthy'; -import { isValueOf, isNotValueOf } from 'isValueOf'; -import { lessThan } from 'lessThan'; -import { lessThanOrEquals } from 'lessThanOrEquals'; -import { longerThanOrEquals } from 'longerThanOrEquals'; -import { matches, notMatches } from 'matches'; -import { condition } from 'ruleCondition'; -import { shorterThan } from 'shorterThan'; -import { shorterThanOrEquals } from 'shorterThanOrEquals'; -import { startsWith, doesNotStartWith } from 'startsWith'; - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-lines-per-function -export default function rules() { - return { - condition, - doesNotEndWith, - doesNotStartWith, - endsWith, - equals, - greaterThan, - greaterThanOrEquals, - gt: greaterThan, - gte: greaterThanOrEquals, - inside, - isArray, - isBetween, - isBlank, - isBoolean, - isEmpty, - isEven, - isFalsy, - isKeyOf, - isNaN, - isNegative, - isNotArray, - isNotBetween, - isNotBlank, - isNotBoolean, - isNotEmpty, - isNotKeyOf, - isNotNaN, - isNotNull, - isNotNullish, - isNotNumber, - isNotNumeric, - isNotString, - isNotUndefined, - isNotValueOf, - isNull, - isNullish, - isNumber, - isNumeric, - isOdd, - isPositive, - isString, - isTruthy, - isUndefined, - isValueOf, - lengthEquals, - lengthNotEquals, - lessThan, - lessThanOrEquals, - longerThan, - longerThanOrEquals, - lt: lessThan, - lte: lessThanOrEquals, - matches, - notEquals, - notInside, - notMatches, - numberEquals, - numberNotEquals, - shorterThan, - shorterThanOrEquals, - startsWith, - }; -} diff --git a/packages/n4s/src/runtime/runtimeRules.ts b/packages/n4s/src/runtime/runtimeRules.ts deleted file mode 100644 index e437fffab..000000000 --- a/packages/n4s/src/runtime/runtimeRules.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DropFirst } from 'vest-utils'; - -import type { RuleReturn } from 'ruleReturn'; -import rules from 'rules'; - -export type Args = any[]; - -export type RuleValue = any; - -export type RuleBase = (value: RuleValue, ...args: Args) => RuleReturn; - -export type Rule = Record; - -type BaseRules = typeof baseRules; -type KBaseRules = keyof BaseRules; - -const baseRules = rules(); - -function getRule(ruleName: string): RuleBase { - return baseRules[ruleName as KBaseRules]; -} - -export { baseRules, getRule }; - -type Rules> = n4s.EnforceCustomMatchers< - Rules & E -> & - Record Rules & E> & { - [P in KBaseRules]: ( - ...args: DropFirst> | Args - ) => Rules & E; - }; - -/* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-empty-interface */ -declare global { - namespace n4s { - interface IRules extends Rules {} - } -} diff --git a/packages/n4s/src/utils/RuleInstance.ts b/packages/n4s/src/utils/RuleInstance.ts new file mode 100644 index 000000000..986dc7000 --- /dev/null +++ b/packages/n4s/src/utils/RuleInstance.ts @@ -0,0 +1,57 @@ +import { RuleRunReturn } from 'RuleRunReturn'; + +/** + * Represents a lazy validation rule that can be executed with a value. + * RuleInstances support chaining and can be reused across multiple validations. + * + * @template T - The type of value this rule validates + * @template Args - The argument types for this rule (first arg is always the value) + * + * @example + * ```typescript + * const stringRule = enforce.isString(); + * + * // Test returns boolean + * stringRule.test('hello'); // true + * stringRule.test(123); // false + * + * // Run returns detailed result + * const result = stringRule.run('hello'); + * console.log(result.pass); // true + * console.log(result.type); // 'hello' + * ``` + */ +export class RuleInstance { + // The runtime object produced by create() supports dynamic chaining. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; + + // Type-only property for inference of rule return type + // (not used at runtime, assigned in create()) + infer!: T; + + // Type-only declaration for the run function shape + run!: (...args: Args) => RuleRunReturn; + + // Type-only declaration for the test function shape (returns boolean) + test!: (...args: Args) => boolean; + + private constructor() {} + + /** + * Creates a new RuleInstance from a validation function. + * The created instance provides both `run()` and `test()` methods. + * + * @param rule - Validation function that returns a RuleRunReturn + * @returns A new RuleInstance that can be executed with values + */ + static create, T, Args extends any[]>( + rule: (...args: Args) => RuleRunReturn, + ): R { + return { + run: (...args: Args) => rule(...args), + test: (...args: Args) => rule(...args).pass, + infer: {} as T, + } as R; + } +} diff --git a/packages/n4s/src/utils/RuleRunReturn.ts b/packages/n4s/src/utils/RuleRunReturn.ts new file mode 100644 index 000000000..1ab29f88c --- /dev/null +++ b/packages/n4s/src/utils/RuleRunReturn.ts @@ -0,0 +1,106 @@ +import { isBoolean, Stringable, dynamicValue } from 'vest-utils'; + +/** + * Represents the result of a validation rule execution. + * Contains the pass/fail status, the validated type, and an optional error message. + * + * @template T - The type of value that was validated + * + * @example + * ```typescript + * const result = RuleRunReturn.Passing('hello'); + * console.log(result.pass); // true + * console.log(result.type); // 'hello' + * + * const failed = RuleRunReturn.Failing(123, 'Must be positive'); + * console.log(failed.pass); // false + * console.log(failed.message); // 'Must be positive' + * ``` + */ +export class RuleRunReturn { + /** Whether the validation passed */ + pass: boolean; + /** The validated value's type */ + type: T; + /** Optional error message if validation failed */ + message?: string; + + constructor(pass: boolean, type: T, message?: string) { + this.pass = pass; + this.type = type; + this.message = message; + } + + /** + * Creates a RuleRunReturn from a boolean or existing RuleRunReturn. + * Handles message resolution and type coercion. + * + * @param pass - Boolean indicating success, or existing RuleRunReturn + * @param type - The type of the validated value + * @param message - Optional error message (can be string or function) + * @returns A new RuleRunReturn instance + */ + static create( + pass: boolean | RuleRunReturn, + type: T, + message?: Stringable, + ): RuleRunReturn { + if (isBoolean(pass)) { + return new RuleRunReturn(!!pass, type, dynamicValue(message, type)); + } + return RuleRunReturn.fromObject(pass, type, message); + } + + private static fromObject( + pass: any, + type: T, + message?: Stringable, + ): RuleRunReturn { + const hasValidObject = pass && isBoolean(pass.pass); + + if (!hasValidObject) { + return new RuleRunReturn(false, type, dynamicValue(message, type)); + } + + return new RuleRunReturn( + !!pass.pass, + type ?? pass.type, + dynamicValue(message ?? pass.message, type), + ); + } + + /** + * Creates a passing RuleRunReturn. + * + * @param type - The validated value's type + * @param message - Optional success message + * @returns A RuleRunReturn with pass=true + * + * @example + * ```typescript + * const result = RuleRunReturn.Passing('valid'); + * console.log(result.pass); // true + * ``` + */ + static Passing(type: T, message?: Stringable): RuleRunReturn { + return RuleRunReturn.create(true, type, message); + } + + /** + * Creates a failing RuleRunReturn. + * + * @param type - The validated value's type + * @param message - Optional error message + * @returns A RuleRunReturn with pass=false + * + * @example + * ```typescript + * const result = RuleRunReturn.Failing(123, 'Number must be positive'); + * console.log(result.pass); // false + * console.log(result.message); // 'Number must be positive' + * ``` + */ + static Failing(type: T, message?: Stringable): RuleRunReturn { + return RuleRunReturn.create(false, type, message); + } +} diff --git a/packages/n4s/src/utils/__tests__/RuleInstance.test.ts b/packages/n4s/src/utils/__tests__/RuleInstance.test.ts new file mode 100644 index 000000000..25677451d --- /dev/null +++ b/packages/n4s/src/utils/__tests__/RuleInstance.test.ts @@ -0,0 +1,32 @@ +import { RuleInstance } from 'RuleInstance'; +import { describe, it, expect } from 'vitest'; + +import { RuleRunReturn } from 'RuleRunReturn'; + +describe('RuleInstance.create', () => { + it('wraps a rule function and returns pass/fail correctly', () => { + type R = RuleInstance; + + const greaterThan = (a: number, b: number) => + RuleRunReturn.create(a > b, a); + + const rule = RuleInstance.create(greaterThan); + + expect(rule.run(5, 3).pass).toBe(true); + expect(rule.run(3, 5).pass).toBe(false); + }); + + it('preserves messages when provided by the rule', () => { + type R = RuleInstance; + + const nonEmpty = (s: string) => + s.trim().length > 0 + ? RuleRunReturn.Passing(s, 'ok') + : RuleRunReturn.Failing(s, 'empty'); + + const rule = RuleInstance.create(nonEmpty); + + expect(rule.run('hello').message).toBe('ok'); + expect(rule.run(' ').message).toBe('empty'); + }); +}); diff --git a/packages/n4s/src/utils/__tests__/RuleRunReturn.test.ts b/packages/n4s/src/utils/__tests__/RuleRunReturn.test.ts new file mode 100644 index 000000000..5a0cfaf8e --- /dev/null +++ b/packages/n4s/src/utils/__tests__/RuleRunReturn.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { RuleRunReturn } from 'RuleRunReturn'; + +describe('RuleRunReturn', () => { + describe('create with boolean', () => { + it('returns pass/type/message when message is string', () => { + const res = RuleRunReturn.create(true, 'TYPE', 'ok'); + expect(res.pass).toBe(true); + expect(res.type).toBe('TYPE'); + expect(res.message).toBe('ok'); + }); + + it('invokes message function with type and sets pass=false', () => { + const msgFn = vi.fn((t: number) => `msg:${t}`); + const res = RuleRunReturn.create(false, 123, msgFn); + expect(res.pass).toBe(false); + expect(res.type).toBe(123); + expect(res.message).toBe('msg:123'); + expect(msgFn).toHaveBeenCalledTimes(1); + expect(msgFn).toHaveBeenCalledWith(123); + }); + + it('keeps message undefined when not provided', () => { + const res = RuleRunReturn.create(true, 'TYP'); + expect(res.pass).toBe(true); + expect(res.type).toBe('TYP'); + expect(res.message).toBeUndefined(); + }); + }); + + describe('create with RuleRunReturn', () => { + it('clones values and invokes message function with the original type', () => { + const base = RuleRunReturn.Failing('T', (t: string) => `fail:${t}`); + const cloned = RuleRunReturn.create(base, 'T'); + + expect(cloned.pass).toBe(false); + expect(cloned.type).toBe('T'); + expect(cloned.message).toBe('fail:T'); + }); + + it('uses pass from inner; explicit type/message take precedence', () => { + const inner = RuleRunReturn.Failing('INNER', 'inner'); + const res = RuleRunReturn.create(inner, 'OUTER', 'outer'); + + expect(res.pass).toBe(false); + expect(res.type).toBe('OUTER'); + // explicit message is preferred over inner + expect(res.message).toBe('outer'); + }); + + it('falls back to provided type when inner type is undefined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inner = new (RuleRunReturn as any)( + false, + undefined, + 'm', + ) as RuleRunReturn; + const res = RuleRunReturn.create(inner, 'FALLBACK', 'outer'); + expect(res.pass).toBe(false); + expect(res.type).toBe('FALLBACK'); + // explicit message is used when provided + expect(res.message).toBe('outer'); + }); + + it('invokes provided message function with provided type argument', () => { + const inner = RuleRunReturn.Passing('INNER'); + const msgFn = vi.fn((t: string) => `outer:${t}`); + const res = RuleRunReturn.create(inner, 'OUTER', msgFn); + + // final type prefers explicit type + expect(res.type).toBe('OUTER'); + // message function receives the second arg to create (OUTER) + expect(res.message).toBe('outer:OUTER'); + expect(msgFn).toHaveBeenCalledTimes(1); + expect(msgFn).toHaveBeenCalledWith('OUTER'); + }); + }); + + describe('Passing/Failing helpers', () => { + it('Passing returns pass=true with string or function message', () => { + const r1 = RuleRunReturn.Passing('X', 'ok'); + expect(r1.pass).toBe(true); + expect(r1.type).toBe('X'); + expect(r1.message).toBe('ok'); + + const r2 = RuleRunReturn.Passing('Y', (t: string) => `yay:${t}`); + expect(r2.pass).toBe(true); + expect(r2.type).toBe('Y'); + expect(r2.message).toBe('yay:Y'); + }); + + it('Failing returns pass=false with string or function message', () => { + const r1 = RuleRunReturn.Failing('X', 'nope'); + expect(r1.pass).toBe(false); + expect(r1.type).toBe('X'); + expect(r1.message).toBe('nope'); + + const r2 = RuleRunReturn.Failing('Y', (t: string) => `nay:${t}`); + expect(r2.pass).toBe(false); + expect(r2.type).toBe('Y'); + expect(r2.message).toBe('nay:Y'); + }); + }); +}); diff --git a/packages/n4s/src/utils/__tests__/utils.test.ts b/packages/n4s/src/utils/__tests__/utils.test.ts new file mode 100644 index 000000000..96c31d5f4 --- /dev/null +++ b/packages/n4s/src/utils/__tests__/utils.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; + +import { toRegExp } from '../regex'; +import { toNumber } from '../toNumber'; + +describe('toRegExp', () => { + it('returns RegExp unchanged', () => { + const regex = /test/; + expect(toRegExp(regex)).toBe(regex); + }); + + it('converts string to RegExp', () => { + const result = toRegExp('test'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('test'); + }); + + it('handles regex patterns with flags as string', () => { + const result = toRegExp('[0-9]+'); + expect(result).toBeInstanceOf(RegExp); + expect('123'.match(result!)).toBeTruthy(); + }); + + it('returns null for invalid inputs', () => { + expect(toRegExp(123 as any)).toBe(null); + expect(toRegExp({} as any)).toBe(null); + expect(toRegExp(null as any)).toBe(null); + }); +}); + +describe('toNumber', () => { + it('returns number unchanged', () => { + expect(toNumber(42)).toBe(42); + expect(toNumber(0)).toBe(0); + expect(toNumber(-5)).toBe(-5); + expect(toNumber(3.14)).toBe(3.14); + }); + + it('converts numeric string to number', () => { + expect(toNumber('42')).toBe(42); + expect(toNumber('3.14')).toBe(3.14); + expect(toNumber('-5')).toBe(-5); + expect(toNumber('0')).toBe(0); + }); + + it('returns null for non-numeric values', () => { + expect(toNumber('not a number')).toBe(null); + expect(toNumber('abc')).toBe(null); + expect(toNumber({})).toBe(null); + expect(toNumber(undefined)).toBe(null); + }); + + it('handles NaN correctly', () => { + // NaN is technically a number type, so toNumber returns it as-is + const result = toNumber(NaN); + expect(Number.isNaN(result)).toBe(true); + }); + + it('handles edge cases', () => { + expect(toNumber('')).toBe(0); // Empty string converts to 0 + expect(toNumber(' ')).toBe(0); // Whitespace converts to 0 + expect(toNumber(true)).toBe(1); + expect(toNumber(false)).toBe(0); + expect(toNumber([])).toBe(0); // Empty array converts to 0 + expect(toNumber(null)).toBe(0); // null converts to 0 + }); +}); diff --git a/packages/n4s/src/utils/regex.ts b/packages/n4s/src/utils/regex.ts new file mode 100644 index 000000000..887d39154 --- /dev/null +++ b/packages/n4s/src/utils/regex.ts @@ -0,0 +1,5 @@ +export function toRegExp(regex: RegExp | string): RegExp | null { + if (regex instanceof RegExp) return regex; + if (typeof regex === 'string') return new RegExp(regex); + return null; +} diff --git a/packages/n4s/src/utils/toNumber.ts b/packages/n4s/src/utils/toNumber.ts new file mode 100644 index 000000000..2a57a03dc --- /dev/null +++ b/packages/n4s/src/utils/toNumber.ts @@ -0,0 +1,6 @@ +// Shared utility for converting values to numbers across rules +export function toNumber(value: unknown): number | null { + if (typeof value === 'number') return value; + const n = Number(value); + return Number.isNaN(n) ? null : n; +} diff --git a/packages/n4s/testUtils/TEnforceMock.ts b/packages/n4s/testUtils/TEnforceMock.ts deleted file mode 100644 index dca25165f..000000000 --- a/packages/n4s/testUtils/TEnforceMock.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { enforce } from 'n4s'; - -export type TEnforceMock = typeof enforce; diff --git a/packages/n4s/tsconfig.json b/packages/n4s/tsconfig.json index 06f650e9d..6f698640b 100644 --- a/packages/n4s/tsconfig.json +++ b/packages/n4s/tsconfig.json @@ -7,57 +7,109 @@ "declarationMap": true, "outDir": "./dist", "paths": { + "ruleResult": ["./src/ruleResult.ts"], + "n4sTypes": ["./src/n4sTypes.ts"], "n4s": ["./src/n4s.ts"], - "runtimeRules": ["./src/runtime/runtimeRules.ts"], - "rules": ["./src/runtime/rules.ts"], - "genEnforceLazy": ["./src/runtime/genEnforceLazy.ts"], - "enforceEager": ["./src/runtime/enforceEager.ts"], - "enforceContext": ["./src/runtime/enforceContext.ts"], - "enforce": ["./src/runtime/enforce.ts"], - "startsWith": ["./src/rules/startsWith.ts"], - "shorterThanOrEquals": ["./src/rules/shorterThanOrEquals.ts"], - "shorterThan": ["./src/rules/shorterThan.ts"], - "ruleCondition": ["./src/rules/ruleCondition.ts"], - "matches": ["./src/rules/matches.ts"], - "longerThanOrEquals": ["./src/rules/longerThanOrEquals.ts"], - "lessThanOrEquals": ["./src/rules/lessThanOrEquals.ts"], - "lessThan": ["./src/rules/lessThan.ts"], - "isValueOf": ["./src/rules/isValueOf.ts"], - "isTruthy": ["./src/rules/isTruthy.ts"], - "isString": ["./src/rules/isString.ts"], - "isOdd": ["./src/rules/isOdd.ts"], - "isNumber": ["./src/rules/isNumber.ts"], - "isNegative": ["./src/rules/isNegative.ts"], - "isNaN": ["./src/rules/isNaN.ts"], - "isKeyOf": ["./src/rules/isKeyOf.ts"], - "isEven": ["./src/rules/isEven.ts"], - "isBoolean": ["./src/rules/isBoolean.ts"], - "isBlank": ["./src/rules/isBlank.ts"], - "isBetween": ["./src/rules/isBetween.ts"], - "inside": ["./src/rules/inside.ts"], - "greaterThanOrEquals": ["./src/rules/greaterThanOrEquals.ts"], - "equals": ["./src/rules/equals.ts"], - "endsWith": ["./src/rules/endsWith.ts"], - "shape": ["./src/plugins/schema/shape.ts"], - "schemaTypes": ["./src/plugins/schema/schemaTypes.ts"], - "partial": ["./src/plugins/schema/partial.ts"], - "optional": ["./src/plugins/schema/optional.ts"], - "loose": ["./src/plugins/schema/loose.ts"], - "isArrayOf": ["./src/plugins/schema/isArrayOf.ts"], - "oneOf": ["./src/plugins/compounds/oneOf.ts"], - "noneOf": ["./src/plugins/compounds/noneOf.ts"], - "anyOf": ["./src/plugins/compounds/anyOf.ts"], - "allOf": ["./src/plugins/compounds/allOf.ts"], - "transformResult": ["./src/lib/transformResult.ts"], - "runLazyRule": ["./src/lib/runLazyRule.ts"], - "ruleReturn": ["./src/lib/ruleReturn.ts"], - "enforceUtilityTypes": ["./src/lib/enforceUtilityTypes.ts"], - "schema": ["./src/exports/schema.ts"], + "lazy": ["./src/lazy.ts"], + "extendLogic": ["./src/extendLogic.ts"], + "enforceContext": ["./src/enforceContext.ts"], + "eager": ["./src/eager.ts"], + "compose": ["./src/compose.ts"], + "toNumber": ["./src/utils/toNumber.ts"], + "regex": ["./src/utils/regex.ts"], + "RuleRunReturn": ["./src/utils/RuleRunReturn.ts"], + "RuleInstance": ["./src/utils/RuleInstance.ts"], + "stringRules": ["./src/rules/stringRules.ts"], + "objectRules": ["./src/rules/objectRules.ts"], + "numberRules": ["./src/rules/numberRules.ts"], + "nullishRules": ["./src/rules/nullishRules.ts"], + "generalRules": ["./src/rules/generalRules.ts"], + "genRuleChain": ["./src/rules/genRuleChain.ts"], + "commonLength": ["./src/rules/commonLength.ts"], + "commonContainer": ["./src/rules/commonContainer.ts"], + "commonComparison": ["./src/rules/commonComparison.ts"], + "booleanRules": ["./src/rules/booleanRules.ts"], + "arrayRules": ["./src/rules/arrayRules.ts"], + "RuleInstanceBuilder": ["./src/rules/RuleInstanceBuilder.ts"], + "startsWith": ["./src/rules/string/startsWith.ts"], + "notMatches": ["./src/rules/string/notMatches.ts"], + "matches": ["./src/rules/string/matches.ts"], + "isString": ["./src/rules/string/isString.ts"], + "isNotBlank": ["./src/rules/string/isNotBlank.ts"], + "isBlankString": ["./src/rules/string/isBlankString.ts"], + "endsWith": ["./src/rules/string/endsWith.ts"], + "doesNotStartWith": ["./src/rules/string/doesNotStartWith.ts"], + "doesNotEndWith": ["./src/rules/string/doesNotEndWith.ts"], + "shape": ["./src/rules/schemaRules/shape.ts"], + "schemaRulesTypes": ["./src/rules/schemaRules/schemaRulesTypes.ts"], + "schemaRulesLazyTypes": [ + "./src/rules/schemaRules/schemaRulesLazyTypes.ts" + ], + "schemaRules": ["./src/rules/schemaRules/schemaRules.ts"], + "partial": ["./src/rules/schemaRules/partial.ts"], + "optional": ["./src/rules/schemaRules/optional.ts"], + "loose": ["./src/rules/schemaRules/loose.ts"], + "isArrayOf": ["./src/rules/schemaRules/isArrayOf.ts"], + "isValueOf": ["./src/rules/object/isValueOf.ts"], + "isKeyOf": ["./src/rules/object/isKeyOf.ts"], + "isNumeric": ["./src/rules/numeric/isNumeric.ts"], + "numberNotEquals": ["./src/rules/number/numberNotEquals.ts"], + "lessThanOrEquals": ["./src/rules/number/lessThanOrEquals.ts"], + "lessThan": ["./src/rules/number/lessThan.ts"], + "isPositive": ["./src/rules/number/isPositive.ts"], + "isOdd": ["./src/rules/number/isOdd.ts"], + "isNumber": ["./src/rules/number/isNumber.ts"], + "isNotBetween": ["./src/rules/number/isNotBetween.ts"], + "isNegative": ["./src/rules/number/isNegative.ts"], + "isEven": ["./src/rules/number/isEven.ts"], + "isBetween": ["./src/rules/number/isBetween.ts"], + "greaterThanOrEquals": ["./src/rules/number/greaterThanOrEquals.ts"], + "isUndefined": ["./src/rules/nullish/isUndefined.ts"], + "isNullish": ["./src/rules/nullish/isNullish.ts"], + "isNull": ["./src/rules/nullish/isNull.ts"], + "notEquals": ["./src/rules/general/notEquals.ts"], + "isTruthy": ["./src/rules/general/isTruthy.ts"], + "isNotUndefined": ["./src/rules/general/isNotUndefined.ts"], + "isNotString": ["./src/rules/general/isNotString.ts"], + "isNotNumeric": ["./src/rules/general/isNotNumeric.ts"], + "isNotNumber": ["./src/rules/general/isNotNumber.ts"], + "isNotNullish": ["./src/rules/general/isNotNullish.ts"], + "isNotNull": ["./src/rules/general/isNotNull.ts"], + "isNotNaN": ["./src/rules/general/isNotNaN.ts"], + "isNotEmpty": ["./src/rules/general/isNotEmpty.ts"], + "isNotBoolean": ["./src/rules/general/isNotBoolean.ts"], + "isNotArray": ["./src/rules/general/isNotArray.ts"], + "isNaN": ["./src/rules/general/isNaN.ts"], + "isFalsy": ["./src/rules/general/isFalsy.ts"], + "isEmpty": ["./src/rules/general/isEmpty.ts"], + "isBlank": ["./src/rules/general/isBlank.ts"], + "equals": ["./src/rules/general/equals.ts"], + "condition": ["./src/rules/general/condition.ts"], + "oneOf": ["./src/rules/compoundRules/oneOf.ts"], + "noneOf": ["./src/rules/compoundRules/noneOf.ts"], + "compoundRulesTypes": ["./src/rules/compoundRules/compoundRulesTypes.ts"], + "compoundRules": ["./src/rules/compoundRules/compoundRules.ts"], + "anyOf": ["./src/rules/compoundRules/anyOf.ts"], + "allOf": ["./src/rules/compoundRules/allOf.ts"], + "proxyHandlers": ["./src/rules/chainBuilder/proxyHandlers.ts"], + "lazyRegistry": ["./src/rules/chainBuilder/lazyRegistry.ts"], + "chainExecutor": ["./src/rules/chainBuilder/chainExecutor.ts"], + "chainBuilder": ["./src/rules/chainBuilder/chainBuilder.ts"], + "isTrue": ["./src/rules/boolean/isTrue.ts"], + "isFalse": ["./src/rules/boolean/isFalse.ts"], + "isBoolean": ["./src/rules/boolean/isBoolean.ts"], + "isArrayRule": ["./src/rules/array/isArrayRule.ts"], + "includes": ["./src/rules/array/includes.ts"], + "typeRules": ["./src/lazy/typeRules.ts"], + "ruleAdapter": ["./src/lazy/ruleAdapter.ts"], "isURL": ["./src/exports/isURL.ts"], "email": ["./src/exports/email.ts"], "date": ["./src/exports/date.ts"], - "compounds": ["./src/exports/compounds.ts"], - "compose": ["./src/exports/compose.ts"] + "typeUtils": ["./src/eager/typeUtils.ts"], + "ruleRegistry": ["./src/eager/ruleRegistry.ts"], + "ruleCallGenerator": ["./src/eager/ruleCallGenerator.ts"], + "eagerTypes": ["./src/eager/eagerTypes.ts"], + "allRules": ["./src/eager/allRules.ts"] } } } diff --git a/packages/vast/src/vast.ts b/packages/vast/src/vast.ts index f776ca766..3cc566b06 100644 --- a/packages/vast/src/vast.ts +++ b/packages/vast/src/vast.ts @@ -1,10 +1,4 @@ -import { - CB, - DynamicValue, - Maybe, - isFunction, - optionalFunctionValue, -} from 'vest-utils'; +import { CB, DynamicValue, Maybe, isFunction, dynamicValue } from 'vest-utils'; // eslint-disable-next-line max-lines-per-function export function createState( @@ -62,13 +56,13 @@ export function createState( prevState?: Maybe, ) { current().push(); - set(key, optionalFunctionValue(initialState, prevState)); + set(key, dynamicValue(initialState, prevState)); return function useStateKey(): StateHandlerReturn { return [ current()[key], (nextState: SetStateInput) => - set(key, optionalFunctionValue(nextState, current()[key])), + set(key, dynamicValue(nextState, current()[key])), ]; }; } diff --git a/packages/vest-utils/src/Predicates.ts b/packages/vest-utils/src/Predicates.ts index 45b3a89a1..45e291cca 100644 --- a/packages/vest-utils/src/Predicates.ts +++ b/packages/vest-utils/src/Predicates.ts @@ -1,17 +1,13 @@ +import dynamicValue from 'dynamicValue'; import { isEmpty } from 'isEmpty'; -import optionalFunctionValue from 'optionalFunctionValue'; import { Predicate } from 'utilityTypes'; export function all(...p: Predicate[]): (value: T) => boolean { return (value: T) => - isEmpty(p) - ? false - : p.every(predicate => optionalFunctionValue(predicate, value)); + isEmpty(p) ? false : p.every(predicate => dynamicValue(predicate, value)); } export function any(...p: Predicate[]): (value: T) => boolean { return (value: T) => - isEmpty(p) - ? false - : p.some(predicate => optionalFunctionValue(predicate, value)); + isEmpty(p) ? false : p.some(predicate => dynamicValue(predicate, value)); } diff --git a/packages/vest-utils/src/SimpleStateMachine.ts b/packages/vest-utils/src/SimpleStateMachine.ts index 9d9ca8c61..18031c416 100644 --- a/packages/vest-utils/src/SimpleStateMachine.ts +++ b/packages/vest-utils/src/SimpleStateMachine.ts @@ -6,7 +6,7 @@ type TStateWildCard = typeof STATE_WILD_CARD; export type TStateMachine = { initial: S; states: Partial<{ - [key in S & TStateWildCard]: { + [key in S | TStateWildCard]: { [key in A]?: S | [S, CB]; }; }>; @@ -40,28 +40,31 @@ export function StateMachine( return (state = staticTransition(state, action, payload)); } - // eslint-disable-next-line complexity - function staticTransition(from: S, action: A, payload?: any): S { - const transitionTo = + function getTransitionTarget( + from: S, + action: A, + ): S | [S, CB] | undefined { + return ( machine.states[from]?.[action] ?? - // @ts-expect-error - This is a valid state - machine.states[STATE_WILD_CARD]?.[action]; - - let target = transitionTo; - - if (Array.isArray(target)) { - const [, conditional] = target; - if (!conditional(payload)) { - return from; - } + machine.states[STATE_WILD_CARD]?.[action] + ); + } - target = target[0]; - } + function evaluateConditionalTarget( + target: [S, CB], + from: S, + payload?: any, + ): S { + const [nextState, conditional] = target; + return conditional(payload) ? nextState : from; + } - if (!target || target === from) { - return from; - } + function staticTransition(from: S, action: A, payload?: any): S { + const transitionTo = getTransitionTarget(from, action); + const target = Array.isArray(transitionTo) + ? evaluateConditionalTarget(transitionTo, from, payload) + : transitionTo; - return target as S; + return !target || target === from ? from : (target as S); } } diff --git a/packages/vest-utils/src/StringObject.ts b/packages/vest-utils/src/StringObject.ts index 0970472bd..6848adfde 100644 --- a/packages/vest-utils/src/StringObject.ts +++ b/packages/vest-utils/src/StringObject.ts @@ -1,7 +1,7 @@ -import optionalFunctionValue from 'optionalFunctionValue'; +import dynamicValue from 'dynamicValue'; import type { Stringable } from 'utilityTypes'; // eslint-disable-next-line @typescript-eslint/ban-types export function StringObject(value?: Stringable): String { - return new String(optionalFunctionValue(value)); + return new String(dynamicValue(value)); } diff --git a/packages/vest-utils/src/__tests__/cache.test.ts b/packages/vest-utils/src/__tests__/cache.test.ts index 3a696bd36..92a9c9a6b 100644 --- a/packages/vest-utils/src/__tests__/cache.test.ts +++ b/packages/vest-utils/src/__tests__/cache.test.ts @@ -17,6 +17,13 @@ describe('lib: cache', () => { expect(cache()).not.toBe(cache()); }); + describe('when cacheaction is a value rather than a function', () => { + it('should store the value if no hit', () => { + const res = c([1, 2, 3], 123); + expect(res).toBe(123); + }); + }); + describe('on cache miss', () => { it('Should call passed cache action function and return its value', () => { const cacheAction = vi.fn(() => ({})); @@ -102,6 +109,24 @@ describe('lib: cache', () => { }); }); + describe('cache.set', () => { + it('Should set a value to the cache storage by its dependencies', () => { + const deps = [1, 2, 3]; + const res = Math.random(); + c.set(deps, res); + expect(c.get(deps)?.[1]).toBe(res); + }); + + it('Should update an existing value in the cache storage by its dependencies', () => { + const deps = [1, 2, 3]; + const res = Math.random(); + c.set(deps, res); + const updatedRes = Math.random(); + c.set(deps, updatedRes); + expect(c.get(deps)?.[1]).toBe(updatedRes); + }); + }); + describe('cache.invalidate', () => { it('Should remove cached item from cache storage by its dependencies', () => { const deps = [1, 2, 3]; diff --git a/packages/vest-utils/src/__tests__/optionalFunctionValue.test.ts b/packages/vest-utils/src/__tests__/optionalFunctionValue.test.ts index 60f4b3b91..d25d8a5c5 100644 --- a/packages/vest-utils/src/__tests__/optionalFunctionValue.test.ts +++ b/packages/vest-utils/src/__tests__/optionalFunctionValue.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; -import { optionalFunctionValue } from 'vest-utils'; +import { dynamicValue } from 'vest-utils'; -describe('optionalFunctionValue', () => { +describe('dynamicValue', () => { describe('When not a function', () => { it.each([0, undefined, false, true, 1, [], {}, null, NaN])( 'Should return the same value', value => { - expect(optionalFunctionValue(value)).toBe(value); + expect(dynamicValue(value)).toBe(value); }, ); }); @@ -16,13 +16,13 @@ describe('optionalFunctionValue', () => { it('Should call the function and return its return value', () => { const value = vi.fn(() => 'return value'); - expect(optionalFunctionValue(value)).toBe('return value'); + expect(dynamicValue(value)).toBe('return value'); expect(value).toHaveBeenCalled(); }); it('Should run with arguments array', () => { const value = vi.fn((...args) => args.join('|')); const args = [1, 2, 3, 4]; - expect(optionalFunctionValue(value, ...args)).toBe('1|2|3|4'); + expect(dynamicValue(value, ...args)).toBe('1|2|3|4'); expect(value).toHaveBeenCalledWith(...args); }); }); diff --git a/packages/vest-utils/src/cache.ts b/packages/vest-utils/src/cache.ts index 4ac2cc2c9..542fb3542 100644 --- a/packages/vest-utils/src/cache.ts +++ b/packages/vest-utils/src/cache.ts @@ -1,6 +1,8 @@ +import { dynamicValue } from 'vest-utils'; + import { lengthEquals } from 'lengthEquals'; import { longerThan } from 'longerThan'; -import { Nullable } from 'utilityTypes'; +import { DynamicValue, Nullable } from 'utilityTypes'; /** * Creates a cache function @@ -8,18 +10,15 @@ import { Nullable } from 'utilityTypes'; export default function createCache(maxSize = 1): CacheApi { const cacheStorage: Array<[unknown[], T]> = []; - const cache = ( - deps: unknown[], - cacheAction: (...args: unknown[]) => T, - ): T => { + const cache = (deps: unknown[], cacheAction: DynamicValue): T => { const cacheHit = cache.get(deps); // cache hit is not null if (cacheHit) return cacheHit[1]; - const result = cacheAction(); + const result = dynamicValue(cacheAction); cacheStorage.unshift([deps.concat(), result]); - if (longerThan(cacheStorage, maxSize)) cacheStorage.length = maxSize; + trimToSize(); return result; }; @@ -34,8 +33,25 @@ export default function createCache(maxSize = 1): CacheApi { cache.get = (deps: unknown[]): Nullable<[unknown[], T]> => cacheStorage[findIndex(deps)] || null; + // sets a value to the cache by its dependencies, updating if a hit is found. + cache.set = (deps: unknown[], value: T): void => { + const index = findIndex(deps); + if (index > -1) { + cacheStorage[index] = [deps, value]; + } else { + cacheStorage.unshift([deps, value]); + } + trimToSize(); + }; + return cache; + function trimToSize() { + if (longerThan(cacheStorage, maxSize)) { + cacheStorage.length = maxSize; + } + } + function findIndex(deps: unknown[]): number { return cacheStorage.findIndex( ([cachedDeps]) => @@ -46,7 +62,8 @@ export default function createCache(maxSize = 1): CacheApi { } export type CacheApi = { - (deps: unknown[], cacheAction: (...args: unknown[]) => T): T; + (deps: unknown[], cacheAction: DynamicValue): T; get(deps: unknown[]): Nullable<[unknown[], T]>; + set(deps: unknown[], value: T): void; invalidate(item: any): void; }; diff --git a/packages/vest-utils/src/defaultTo.ts b/packages/vest-utils/src/defaultTo.ts index f4a9a417f..f05e8a388 100644 --- a/packages/vest-utils/src/defaultTo.ts +++ b/packages/vest-utils/src/defaultTo.ts @@ -1,9 +1,9 @@ -import optionalFunctionValue from 'optionalFunctionValue'; +import dynamicValue from 'dynamicValue'; import { DynamicValue, Nullish } from 'utilityTypes'; export default function defaultTo( value: DynamicValue>, defaultValue: DynamicValue, ): T { - return optionalFunctionValue(value) ?? optionalFunctionValue(defaultValue); + return dynamicValue(value) ?? dynamicValue(defaultValue); } diff --git a/packages/vest-utils/src/optionalFunctionValue.ts b/packages/vest-utils/src/dynamicValue.ts similarity index 79% rename from packages/vest-utils/src/optionalFunctionValue.ts rename to packages/vest-utils/src/dynamicValue.ts index 71da983c5..d57123095 100644 --- a/packages/vest-utils/src/optionalFunctionValue.ts +++ b/packages/vest-utils/src/dynamicValue.ts @@ -1,7 +1,7 @@ import isFunction from 'isFunction'; import { DynamicValue } from 'utilityTypes'; -export default function optionalFunctionValue( +export default function dynamicValue( value: DynamicValue, ...args: unknown[] ): T { diff --git a/packages/vest-utils/src/exports/minifyObject.ts b/packages/vest-utils/src/exports/minifyObject.ts index dc76ba35c..a2bf09272 100644 --- a/packages/vest-utils/src/exports/minifyObject.ts +++ b/packages/vest-utils/src/exports/minifyObject.ts @@ -111,7 +111,6 @@ function isNonSerializable(value: any): boolean { return isNullish(value) || isFunction(value) || typeof value === 'symbol'; } -// eslint-disable-next-line complexity function shouldMinify(value: any): boolean { if (isObject(value) && isEmpty(value)) { return false; @@ -121,10 +120,6 @@ function shouldMinify(value: any): boolean { return false; } - if (isObject(value) && isEmpty(value)) { - return false; - } - return true; } diff --git a/packages/vest-utils/src/invariant.ts b/packages/vest-utils/src/invariant.ts index 556e3977d..94c790fe1 100644 --- a/packages/vest-utils/src/invariant.ts +++ b/packages/vest-utils/src/invariant.ts @@ -1,4 +1,4 @@ -import optionalFunctionValue from 'optionalFunctionValue'; +import dynamicValue from 'dynamicValue'; import type { Stringable } from 'utilityTypes'; export default function invariant( @@ -15,5 +15,5 @@ export default function invariant( // Alternatively, throw an error with the message throw message instanceof String ? message.valueOf() - : new Error(message ? optionalFunctionValue(message) : message); + : new Error(message ? dynamicValue(message) : message); } diff --git a/packages/vest-utils/src/noop.ts b/packages/vest-utils/src/noop.ts index 247aaa144..177804c7a 100644 --- a/packages/vest-utils/src/noop.ts +++ b/packages/vest-utils/src/noop.ts @@ -1,2 +1 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-function export function noop() {} diff --git a/packages/vest-utils/src/tinyState.ts b/packages/vest-utils/src/tinyState.ts index f1d898e95..09656d234 100644 --- a/packages/vest-utils/src/tinyState.ts +++ b/packages/vest-utils/src/tinyState.ts @@ -1,4 +1,4 @@ -import optionalFunctionValue from 'optionalFunctionValue'; +import dynamicValue from 'dynamicValue'; import { DynamicValue } from 'utilityTypes'; export function createTinyState( @@ -11,11 +11,11 @@ export function createTinyState( return () => [value, setValue, resetValue]; function setValue(nextValue: SetValueInput) { - value = optionalFunctionValue(nextValue, value); + value = dynamicValue(nextValue, value); } function resetValue() { - setValue(optionalFunctionValue(initialValue)); + setValue(dynamicValue(initialValue)); } } diff --git a/packages/vest-utils/src/vest-utils.ts b/packages/vest-utils/src/vest-utils.ts index 1037e62f0..870c4dde4 100644 --- a/packages/vest-utils/src/vest-utils.ts +++ b/packages/vest-utils/src/vest-utils.ts @@ -1,3 +1,4 @@ +export { withResolvers } from 'withResolvers'; export { default as cache, CacheApi } from 'cache'; export { BusType } from 'bus'; export { TinyState } from 'tinyState'; @@ -6,7 +7,7 @@ export { default as asArray } from 'asArray'; export { default as callEach } from 'callEach'; export { default as hasOwnProperty } from 'hasOwnProperty'; export { default as isPromise } from 'isPromise'; -export { default as optionalFunctionValue } from 'optionalFunctionValue'; +export { default as dynamicValue } from 'dynamicValue'; export { default as assign } from 'assign'; export { default as defaultTo } from 'defaultTo'; export { default as invariant } from 'invariant'; diff --git a/packages/vest-utils/src/withResolvers.ts b/packages/vest-utils/src/withResolvers.ts new file mode 100644 index 000000000..513b83cec --- /dev/null +++ b/packages/vest-utils/src/withResolvers.ts @@ -0,0 +1,22 @@ +import { noop } from 'noop'; + +export function withResolvers() { + if (Promise.hasOwnProperty('withResolvers')) { + // @ts-expect-error - rollup ts plugin does not support withResolvers + return Promise.withResolvers(); + } + + let resolve: (value: T | PromiseLike) => void = noop, + reject: (reason?: any) => void = noop; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { + promise, + resolve, + reject, + }; +} diff --git a/packages/vest-utils/tsconfig.json b/packages/vest-utils/tsconfig.json index dd8b8f85a..dd9e775c7 100644 --- a/packages/vest-utils/tsconfig.json +++ b/packages/vest-utils/tsconfig.json @@ -7,13 +7,13 @@ "declarationMap": true, "outDir": "./dist", "paths": { + "withResolvers": ["./src/withResolvers.ts"], "vest-utils": ["./src/vest-utils.ts"], "valueIsObject": ["./src/valueIsObject.ts"], "utilityTypes": ["./src/utilityTypes.ts"], "tinyState": ["./src/tinyState.ts"], "text": ["./src/text.ts"], "seq": ["./src/seq.ts"], - "optionalFunctionValue": ["./src/optionalFunctionValue.ts"], "numberEquals": ["./src/numberEquals.ts"], "noop": ["./src/noop.ts"], "nonnullish": ["./src/nonnullish.ts"], @@ -37,6 +37,7 @@ "globals.d": ["./src/globals.d.ts"], "freezeAssign": ["./src/freezeAssign.ts"], "either": ["./src/either.ts"], + "dynamicValue": ["./src/dynamicValue.ts"], "deferThrow": ["./src/deferThrow.ts"], "defaultTo": ["./src/defaultTo.ts"], "callEach": ["./src/callEach.ts"], diff --git a/packages/vest/.npmignore b/packages/vest/.npmignore index cdd44898d..a2119521f 100644 --- a/packages/vest/.npmignore +++ b/packages/vest/.npmignore @@ -4,14 +4,11 @@ src !types/ !dist/ tsconfig.json -!promisify/ !parser/ -!enforce@schema/ +!memo/ !enforce@isURL/ !enforce@email/ !enforce@date/ -!enforce@compounds/ -!enforce@compose/ !debounce/ !classnames/ !SuiteSerializer/ diff --git a/packages/vest/package.json b/packages/vest/package.json index 12d4137c7..a68d5cfaf 100644 --- a/packages/vest/package.json +++ b/packages/vest/package.json @@ -43,36 +43,6 @@ "vestjs-runtime": "^1.5.1" }, "exports": { - "./promisify": { - "production": { - "types": "./types/promisify.d.ts", - "browser": "./dist/es/promisify.production.js", - "umd": "./dist/umd/promisify.production.js", - "import": "./dist/es/promisify.production.js", - "require": "./dist/cjs/promisify.production.js", - "node": "./dist/cjs/promisify.production.js", - "module": "./dist/es/promisify.production.js", - "default": "./dist/cjs/promisify.production.js" - }, - "development": { - "types": "./types/promisify.d.ts", - "browser": "./dist/es/promisify.development.js", - "umd": "./dist/umd/promisify.development.js", - "import": "./dist/es/promisify.development.js", - "require": "./dist/cjs/promisify.development.js", - "node": "./dist/cjs/promisify.development.js", - "module": "./dist/es/promisify.development.js", - "default": "./dist/cjs/promisify.development.js" - }, - "types": "./types/promisify.d.ts", - "browser": "./dist/es/promisify.production.js", - "umd": "./dist/umd/promisify.production.js", - "import": "./dist/es/promisify.production.js", - "require": "./dist/cjs/promisify.production.js", - "node": "./dist/cjs/promisify.production.js", - "module": "./dist/es/promisify.production.js", - "default": "./dist/cjs/promisify.production.js" - }, "./parser": { "production": { "types": "./types/parser.d.ts", @@ -103,35 +73,35 @@ "module": "./dist/es/parser.production.js", "default": "./dist/cjs/parser.production.js" }, - "./enforce/schema": { + "./memo": { "production": { - "types": "./types/enforce/schema.d.ts", - "browser": "./dist/es/enforce/schema.production.js", - "umd": "./dist/umd/enforce/schema.production.js", - "import": "./dist/es/enforce/schema.production.js", - "require": "./dist/cjs/enforce/schema.production.js", - "node": "./dist/cjs/enforce/schema.production.js", - "module": "./dist/es/enforce/schema.production.js", - "default": "./dist/cjs/enforce/schema.production.js" + "types": "./types/memo.d.ts", + "browser": "./dist/es/memo.production.js", + "umd": "./dist/umd/memo.production.js", + "import": "./dist/es/memo.production.js", + "require": "./dist/cjs/memo.production.js", + "node": "./dist/cjs/memo.production.js", + "module": "./dist/es/memo.production.js", + "default": "./dist/cjs/memo.production.js" }, "development": { - "types": "./types/enforce/schema.d.ts", - "browser": "./dist/es/enforce/schema.development.js", - "umd": "./dist/umd/enforce/schema.development.js", - "import": "./dist/es/enforce/schema.development.js", - "require": "./dist/cjs/enforce/schema.development.js", - "node": "./dist/cjs/enforce/schema.development.js", - "module": "./dist/es/enforce/schema.development.js", - "default": "./dist/cjs/enforce/schema.development.js" + "types": "./types/memo.d.ts", + "browser": "./dist/es/memo.development.js", + "umd": "./dist/umd/memo.development.js", + "import": "./dist/es/memo.development.js", + "require": "./dist/cjs/memo.development.js", + "node": "./dist/cjs/memo.development.js", + "module": "./dist/es/memo.development.js", + "default": "./dist/cjs/memo.development.js" }, - "types": "./types/enforce/schema.d.ts", - "browser": "./dist/es/enforce/schema.production.js", - "umd": "./dist/umd/enforce/schema.production.js", - "import": "./dist/es/enforce/schema.production.js", - "require": "./dist/cjs/enforce/schema.production.js", - "node": "./dist/cjs/enforce/schema.production.js", - "module": "./dist/es/enforce/schema.production.js", - "default": "./dist/cjs/enforce/schema.production.js" + "types": "./types/memo.d.ts", + "browser": "./dist/es/memo.production.js", + "umd": "./dist/umd/memo.production.js", + "import": "./dist/es/memo.production.js", + "require": "./dist/cjs/memo.production.js", + "node": "./dist/cjs/memo.production.js", + "module": "./dist/es/memo.production.js", + "default": "./dist/cjs/memo.production.js" }, "./enforce/isURL": { "production": { @@ -223,66 +193,6 @@ "module": "./dist/es/enforce/date.production.js", "default": "./dist/cjs/enforce/date.production.js" }, - "./enforce/compounds": { - "production": { - "types": "./types/enforce/compounds.d.ts", - "browser": "./dist/es/enforce/compounds.production.js", - "umd": "./dist/umd/enforce/compounds.production.js", - "import": "./dist/es/enforce/compounds.production.js", - "require": "./dist/cjs/enforce/compounds.production.js", - "node": "./dist/cjs/enforce/compounds.production.js", - "module": "./dist/es/enforce/compounds.production.js", - "default": "./dist/cjs/enforce/compounds.production.js" - }, - "development": { - "types": "./types/enforce/compounds.d.ts", - "browser": "./dist/es/enforce/compounds.development.js", - "umd": "./dist/umd/enforce/compounds.development.js", - "import": "./dist/es/enforce/compounds.development.js", - "require": "./dist/cjs/enforce/compounds.development.js", - "node": "./dist/cjs/enforce/compounds.development.js", - "module": "./dist/es/enforce/compounds.development.js", - "default": "./dist/cjs/enforce/compounds.development.js" - }, - "types": "./types/enforce/compounds.d.ts", - "browser": "./dist/es/enforce/compounds.production.js", - "umd": "./dist/umd/enforce/compounds.production.js", - "import": "./dist/es/enforce/compounds.production.js", - "require": "./dist/cjs/enforce/compounds.production.js", - "node": "./dist/cjs/enforce/compounds.production.js", - "module": "./dist/es/enforce/compounds.production.js", - "default": "./dist/cjs/enforce/compounds.production.js" - }, - "./enforce/compose": { - "production": { - "types": "./types/enforce/compose.d.ts", - "browser": "./dist/es/enforce/compose.production.js", - "umd": "./dist/umd/enforce/compose.production.js", - "import": "./dist/es/enforce/compose.production.js", - "require": "./dist/cjs/enforce/compose.production.js", - "node": "./dist/cjs/enforce/compose.production.js", - "module": "./dist/es/enforce/compose.production.js", - "default": "./dist/cjs/enforce/compose.production.js" - }, - "development": { - "types": "./types/enforce/compose.d.ts", - "browser": "./dist/es/enforce/compose.development.js", - "umd": "./dist/umd/enforce/compose.development.js", - "import": "./dist/es/enforce/compose.development.js", - "require": "./dist/cjs/enforce/compose.development.js", - "node": "./dist/cjs/enforce/compose.development.js", - "module": "./dist/es/enforce/compose.development.js", - "default": "./dist/cjs/enforce/compose.development.js" - }, - "types": "./types/enforce/compose.d.ts", - "browser": "./dist/es/enforce/compose.production.js", - "umd": "./dist/umd/enforce/compose.production.js", - "import": "./dist/es/enforce/compose.production.js", - "require": "./dist/cjs/enforce/compose.production.js", - "node": "./dist/cjs/enforce/compose.production.js", - "module": "./dist/es/enforce/compose.production.js", - "default": "./dist/cjs/enforce/compose.production.js" - }, "./debounce": { "production": { "types": "./types/debounce.d.ts", diff --git a/packages/vest/src/__tests__/__snapshots__/integration.base.test.ts.snap b/packages/vest/src/__tests__/__snapshots__/integration.base.test.ts.snap index 97d0c36ac..fd25bd462 100644 --- a/packages/vest/src/__tests__/__snapshots__/integration.base.test.ts.snap +++ b/packages/vest/src/__tests__/__snapshots__/integration.base.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Base behavior > Should produce correct validation result 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -88,6 +88,7 @@ exports[`Base behavior > Should produce correct validation result 1`] = ` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 1, "warnings": [ diff --git a/packages/vest/src/__tests__/__snapshots__/integration.stateful-async.test.ts.snap b/packages/vest/src/__tests__/__snapshots__/integration.stateful-async.test.ts.snap index 038af42aa..7b4e4c59d 100644 --- a/packages/vest/src/__tests__/__snapshots__/integration.stateful-async.test.ts.snap +++ b/packages/vest/src/__tests__/__snapshots__/integration.stateful-async.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Stateful async tests > Merges skipped validations from previous suite 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -91,6 +91,7 @@ exports[`Stateful async tests > Merges skipped validations from previous suite 1 "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -99,6 +100,7 @@ exports[`Stateful async tests > Merges skipped validations from previous suite 1 exports[`Stateful async tests > Merges skipped validations from previous suite 2`] = ` SuiteSummary { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -194,6 +196,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -202,6 +205,7 @@ SuiteSummary { exports[`Stateful async tests > Merges skipped validations from previous suite 3`] = ` SuiteSummary { + "dump": [Function], "errorCount": 5, "errors": [ SummaryFailure { @@ -321,6 +325,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/__tests__/__snapshots__/integration.stateful-tests.test.ts.snap b/packages/vest/src/__tests__/__snapshots__/integration.stateful-tests.test.ts.snap index 75fa4d93a..954f3e4f3 100644 --- a/packages/vest/src/__tests__/__snapshots__/integration.stateful-tests.test.ts.snap +++ b/packages/vest/src/__tests__/__snapshots__/integration.stateful-tests.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Stateful behavior > Should merge skipped fields with previous values 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -79,6 +79,7 @@ exports[`Stateful behavior > Should merge skipped fields with previous values 1` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -86,8 +87,8 @@ exports[`Stateful behavior > Should merge skipped fields with previous values 1` `; exports[`Stateful behavior > Should merge skipped fields with previous values 2`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -177,6 +178,7 @@ exports[`Stateful behavior > Should merge skipped fields with previous values 2` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -184,8 +186,8 @@ exports[`Stateful behavior > Should merge skipped fields with previous values 2` `; exports[`Stateful behavior > Should merge skipped fields with previous values 3`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 4, "errors": [ SummaryFailure { @@ -284,6 +286,7 @@ exports[`Stateful behavior > Should merge skipped fields with previous values 3` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 1, "warnings": [ @@ -298,6 +301,7 @@ exports[`Stateful behavior > Should merge skipped fields with previous values 3` exports[`more complex > Should run correctly 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -320,6 +324,7 @@ SuiteSummary { "suiteName": undefined, "testCount": 0, "tests": {}, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -328,6 +333,7 @@ SuiteSummary { exports[`more complex > Should run correctly 2`] = ` SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -378,6 +384,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -386,6 +393,7 @@ SuiteSummary { exports[`more complex > Should run correctly 4`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -444,6 +452,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -452,6 +461,7 @@ SuiteSummary { exports[`more complex > Should run correctly 6`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -510,6 +520,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/__tests__/integration.async-tests.test.ts b/packages/vest/src/__tests__/integration.async-tests.test.ts index 3b3848fd7..4f23cd7bf 100644 --- a/packages/vest/src/__tests__/integration.async-tests.test.ts +++ b/packages/vest/src/__tests__/integration.async-tests.test.ts @@ -1,10 +1,10 @@ -import { Modes } from 'Modes'; -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach, beforeAll, test, vi } from 'vitest'; import wait from 'wait'; import { TestPromise } from '../testUtils/testPromise'; +import { Modes } from 'Modes'; +import { TTestSuite } from 'TVestMock'; import * as vest from 'vest'; function genSuite() { @@ -36,8 +36,7 @@ describe('Stateful behavior', () => { let result, callback_1 = vi.fn(), callback_2 = vi.fn(), - callback_3 = vi.fn(), - control = vi.fn(); + callback_3 = vi.fn(); beforeEach(() => { suite = genSuite(); @@ -47,14 +46,13 @@ describe('Stateful behavior', () => { callback_1 = vi.fn(); callback_2 = vi.fn(); callback_3 = vi.fn(); - control = vi.fn(); }); test('Should have all fields', () => TestPromise(done => { // ❗️Why is this test async? Because of the `resetState` beforeEach. // We must not clean up before the suite is actually done. - result = suite().done(done); + result = suite.after(done).run(); expect(result.tests).toHaveProperty('field_1'); expect(result.tests).toHaveProperty('field_2'); expect(result.tests).toHaveProperty('field_4'); @@ -65,29 +63,25 @@ describe('Stateful behavior', () => { expect(result.tests).toMatchSnapshot(); })); - it('Should invoke done callback specified with sync field immediately, and the others after finishing', () => - TestPromise(done => { - result = suite(); - result - .done('field_1', callback_1) - .done('field_6', callback_2) - .done(callback_3); - expect(callback_1).toHaveBeenCalled(); - expect(callback_2).not.toHaveBeenCalled(); - expect(callback_3).not.toHaveBeenCalled(); + it('Should invoke after callback specified with sync field immediately, and the others after finishing', async () => { + result = suite + .afterField('field_1', callback_1) + .after(callback_2) + .after(callback_3) + .run(); - setTimeout(() => { - expect(callback_2).not.toHaveBeenCalled(); - expect(callback_3).not.toHaveBeenCalled(); - expect(suite.get().hasErrors('field_7')).toBe(true); - control(); - }); + expect(callback_1).toHaveBeenCalled(); + expect(callback_2).toHaveBeenCalledOnce(); + expect(callback_3).toHaveBeenCalledOnce(); - setTimeout(() => { - expect(callback_2).toHaveBeenCalled(); - expect(callback_3).toHaveBeenCalled(); - expect(control).toHaveBeenCalled(); - done(); - }, 250); - })); + await wait(0); + expect(callback_2).toHaveBeenCalledTimes(2); + expect(callback_3).toHaveBeenCalledTimes(2); + expect(suite.get().hasErrors('field_7')).toBe(true); + + await result; + + expect(callback_2).toHaveBeenCalledTimes(3); + expect(callback_3).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/vest/src/__tests__/integration.base.test.ts b/packages/vest/src/__tests__/integration.base.test.ts index f5ab731c0..ac797d609 100644 --- a/packages/vest/src/__tests__/integration.base.test.ts +++ b/packages/vest/src/__tests__/integration.base.test.ts @@ -1,8 +1,8 @@ -import { describe, test, beforeEach, it, expect } from 'vitest'; +import { describe, test, beforeEach, it, expect, vi } from 'vitest'; import * as vest from 'vest'; -const suite = () => +const genSuite = () => vest.create(() => { vest.skip('field_5'); vest.test('field_1', 'field_statement_1', () => false); @@ -20,13 +20,13 @@ const suite = () => }); vest.test('field_5', 'field_statement_5', () => false); vest.test('field_5', 'field_statement_6', () => false); - })(); + }); describe('Base behavior', () => { - let res: vest.SuiteRunResult; + let res: vest.SuiteResult; beforeEach(() => { - res = suite(); + res = genSuite().run(); }); test('Should produce correct validation result', () => { @@ -35,12 +35,12 @@ describe('Base behavior', () => { expect(res.tests).toHaveProperty('field_3'); expect(res.tests).toHaveProperty('field_4'); expect(res.tests.field_5.testCount).toBe(0); - expect(suite()).toMatchSnapshot(); + expect(genSuite().run()).toMatchSnapshot(); }); it('Should run done callbacks immediately', () => { const callback = vi.fn(); - res.done(callback); + genSuite().after(callback).run(); expect(callback).toHaveBeenCalled(); }); diff --git a/packages/vest/src/__tests__/integration.byGroup.test.ts b/packages/vest/src/__tests__/integration.byGroup.test.ts new file mode 100644 index 000000000..5e9f0c67e --- /dev/null +++ b/packages/vest/src/__tests__/integration.byGroup.test.ts @@ -0,0 +1,591 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import wait from 'wait'; + +import { TTestSuite } from '../testUtils/TVestMock'; + +import { Modes } from 'Modes'; +import { create, group, test, warn, skip, optional, only } from 'vest'; +import * as vest from 'vest'; + +/** + * Integration tests for byGroup selectors + * These tests cover edge cases and integration scenarios across multiple selectors + */ +describe('Integration: byGroup selectors', () => { + describe('Multiple groups share field names', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('group_1', () => { + test('field_1', 'error in g1', () => false); + test('field_2', () => {}); + }); + group('group_2', () => { + test('field_1', () => {}); + test('field_2', 'error in g2', () => false); + }); + }); + }); + + it('should report errors only from the queried group in hasErrorsByGroup', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('group_1')).toBe(true); + expect(result.hasErrorsByGroup('group_1', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('group_1', 'field_2')).toBe(false); + expect(result.hasErrorsByGroup('group_2')).toBe(true); + expect(result.hasErrorsByGroup('group_2', 'field_1')).toBe(false); + expect(result.hasErrorsByGroup('group_2', 'field_2')).toBe(true); + }); + + it('should return only errors from the queried group in getErrorsByGroup', () => { + const result = suite.run(); + expect(result.getErrorsByGroup('group_1')).toEqual({ + field_1: ['error in g1'], + }); + expect(result.getErrorsByGroup('group_2')).toEqual({ + field_2: ['error in g2'], + }); + expect(result.getErrorsByGroup('group_1', 'field_1')).toEqual([ + 'error in g1', + ]); + expect(result.getErrorsByGroup('group_2', 'field_2')).toEqual([ + 'error in g2', + ]); + }); + + it('should evaluate validity separately per group in isValidByGroup', () => { + const result = suite.run(); + expect(result.isValidByGroup('group_1')).toBe(false); + expect(result.isValidByGroup('group_2')).toBe(false); + expect(result.isValidByGroup('group_1', 'field_1')).toBe(false); + expect(result.isValidByGroup('group_1', 'field_2')).toBe(true); + expect(result.isValidByGroup('group_2', 'field_1')).toBe(true); + expect(result.isValidByGroup('group_2', 'field_2')).toBe(false); + }); + }); + + describe('Nested groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + group('outer', () => { + test('field_1', 'outer error', () => false); + group('inner', () => { + test('field_2', 'inner error', () => false); + }); + }); + }); + }); + + it('should keep errors separate between nested groups', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('outer')).toBe(true); + expect(result.hasErrorsByGroup('inner')).toBe(true); + expect(result.getErrorsByGroup('outer', 'field_1')).toEqual([ + 'outer error', + ]); + expect(result.getErrorsByGroup('inner', 'field_2')).toEqual([ + 'inner error', + ]); + }); + }); + + describe('Mixed errors and warnings in same group', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('mixed_group', () => { + test('field_1', 'error_msg', () => false); + test('field_2', 'warning_msg', () => { + warn(); + return false; + }); + test('field_3', () => {}); + }); + }); + }); + + it('should separate errors from warnings', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('mixed_group')).toBe(true); + expect(result.hasWarningsByGroup('mixed_group')).toBe(true); + expect(result.hasErrorsByGroup('mixed_group', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('mixed_group', 'field_2')).toBe(false); + expect(result.hasWarningsByGroup('mixed_group', 'field_1')).toBe(false); + expect(result.hasWarningsByGroup('mixed_group', 'field_2')).toBe(true); + }); + + it('should return separate maps from getErrorsByGroup and getWarningsByGroup', () => { + const result = suite.run(); + expect(result.getErrorsByGroup('mixed_group')).toEqual({ + field_1: ['error_msg'], + }); + expect(result.getWarningsByGroup('mixed_group')).toEqual({ + field_2: ['warning_msg'], + }); + expect(result.getErrorsByGroup('mixed_group', 'field_1')).toEqual([ + 'error_msg', + ]); + expect(result.getWarningsByGroup('mixed_group', 'field_2')).toEqual([ + 'warning_msg', + ]); + }); + }); + + describe('Multiple tests for the same field in a group', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('group_1', () => { + test('field_1', 'error 1', () => false); + test('field_1', 'error 2', () => false); + test('field_1', 'error 3', () => false); + }); + }); + }); + + it('should collect all errors from repeated tests of the same field', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('group_1', 'field_1')).toBe(true); + expect(result.getErrorsByGroup('group_1', 'field_1')).toEqual([ + 'error 1', + 'error 2', + 'error 3', + ]); + }); + + describe('With mixed results', () => { + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('group_1', () => { + test('field_1', 'error 1', () => false); + test('field_1', () => {}); + test('field_1', 'error 2', () => false); + }); + }); + }); + + it('should ignore passing tests when collecting errors', () => { + const result = suite.run(); + expect(result.getErrorsByGroup('group_1', 'field_1')).toEqual([ + 'error 1', + 'error 2', + ]); + }); + }); + }); + + describe('Empty group', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + group('empty_group', () => { + // no tests + }); + group('another_group', () => { + test('field_1', () => false); + }); + }); + }); + + it('should treat an empty group as valid with no errors or warnings', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('empty_group')).toBe(false); + expect(result.hasWarningsByGroup('empty_group')).toBe(false); + expect(result.getErrorsByGroup('empty_group')).toEqual({}); + expect(result.getWarningsByGroup('empty_group')).toEqual({}); + expect(result.isValidByGroup('empty_group')).toBe(true); + }); + }); + + describe('Async tests in groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + group('async_group', () => { + test('field_1', 'field_1 error', async () => { + await wait(100); + throw new Error(); + }); + test('field_2', 'field_2 warning', async () => { + warn(); + await wait(100); + throw new Error(); + }); + test('field_3', async () => { + await wait(100); + }); + }); + }); + }); + + it('should mark group invalid while async tests are pending', () => { + const result = suite.run(); + expect(result.isValidByGroup('async_group')).toBe(false); + expect(result.isValidByGroup('async_group', 'field_1')).toBe(false); + expect(result.isValidByGroup('async_group', 'field_2')).toBe(false); + expect(result.isValidByGroup('async_group', 'field_3')).toBe(false); + }); + + it('should update errors, warnings and validity after async tests finish', async () => { + await suite.run(); + const result = suite.get(); + expect(result.hasErrorsByGroup('async_group')).toBe(true); + expect(result.hasWarningsByGroup('async_group')).toBe(true); + expect(result.hasErrorsByGroup('async_group', 'field_1')).toBe(true); + expect(result.hasWarningsByGroup('async_group', 'field_2')).toBe(true); + expect(result.getErrorsByGroup('async_group', 'field_1')).toHaveLength(1); + expect(result.getWarningsByGroup('async_group', 'field_2')).toHaveLength( + 1, + ); + expect(result.isValidByGroup('async_group')).toBe(false); + expect(result.isValidByGroup('async_group', 'field_3')).toBe(true); + }); + }); + + describe('Skipped tests in groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create((skipField: string) => { + skip(skipField); + group('skip_group', () => { + test('field_1', () => false); + test('field_2', () => false); + test('field_3', () => {}); + }); + }); + }); + + it('should not count skipped tests as errors', () => { + const result = suite.run('field_1'); + expect(result.hasErrorsByGroup('skip_group')).toBe(true); + expect(result.hasErrorsByGroup('skip_group', 'field_1')).toBe(false); + expect(result.hasErrorsByGroup('skip_group', 'field_2')).toBe(true); + }); + + it('should exclude skipped tests from getErrorsByGroup', () => { + const result = suite.run('field_1'); + const errors = result.getErrorsByGroup('skip_group'); + expect(errors).not.toHaveProperty('field_1'); + expect(errors).toHaveProperty('field_2'); + }); + + it('should mark group invalid when required tests are skipped', () => { + const result = suite.run('field_1'); + expect(result.isValidByGroup('skip_group')).toBe(false); + expect(result.isValidByGroup('skip_group', 'field_1')).toBe(false); + }); + }); + + describe('Optional tests in groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + optional({ field_1: false, field_2: false }); + group('optional_group', () => { + test('field_1', 'error 1', () => false); + test('field_2', 'error 2', () => false); + }); + }); + }); + + it('should report errors for non-optional fields', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('optional_group')).toBe(true); + expect(result.hasErrorsByGroup('optional_group', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('optional_group', 'field_2')).toBe(true); + expect(result.getErrorsByGroup('optional_group', 'field_1')).toEqual([ + 'error 1', + ]); + }); + + it('should mark group invalid when non-optional fields fail', () => { + const result = suite.run(); + expect(result.isValidByGroup('optional_group')).toBe(false); + expect(result.isValidByGroup('optional_group', 'field_1')).toBe(false); + expect(result.isValidByGroup('optional_group', 'field_2')).toBe(false); + }); + }); + + describe('Only mode with groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create((onlyField: string) => { + only(onlyField); + group('only_group', () => { + test('field_1', 'error 1', () => false); + test('field_2', 'error 2', () => false); + test('field_3', () => {}); + }); + }); + }); + + it('should run tests only for the field focused by only()', () => { + const result = suite.run('field_1'); + expect(result.hasErrorsByGroup('only_group')).toBe(true); + expect(result.hasErrorsByGroup('only_group', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('only_group', 'field_2')).toBe(false); + const errors = result.getErrorsByGroup('only_group'); + expect(errors).toHaveProperty('field_1'); + expect(errors).not.toHaveProperty('field_2'); + }); + }); + + describe('Tests without messages in groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('no_message_group', () => { + test('field_1', () => false); + test('field_2', () => false); + test('field_3', 'has message', () => false); + }); + }); + }); + + it('should detect errors even when tests have no messages', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('no_message_group')).toBe(true); + expect(result.hasErrorsByGroup('no_message_group', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('no_message_group', 'field_2')).toBe(true); + }); + + it('should return an empty array for errors without messages in getErrorsByGroup', () => { + const result = suite.run(); + expect(result.getErrorsByGroup('no_message_group', 'field_1')).toEqual( + [], + ); + expect(result.getErrorsByGroup('no_message_group', 'field_2')).toEqual( + [], + ); + expect(result.getErrorsByGroup('no_message_group', 'field_3')).toEqual([ + 'has message', + ]); + }); + }); + + describe('omitWhen in groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create((shouldOmit: boolean) => { + group('omit_group', () => { + vest.omitWhen(shouldOmit, () => { + test('field_1', 'omitted error', () => false); + }); + test('field_2', 'regular error', () => false); + }); + }); + }); + + it('should exclude the test from running and from errors when omitWhen(true)', () => { + const result = suite.run(true); + expect(result.hasErrorsByGroup('omit_group', 'field_1')).toBe(false); + expect(result.hasErrorsByGroup('omit_group', 'field_2')).toBe(true); + expect(result.isValidByGroup('omit_group', 'field_1')).toBe(true); + }); + + it('should run the test and report its errors when omitWhen(false)', () => { + const result = suite.run(false); + expect(result.hasErrorsByGroup('omit_group', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('omit_group', 'field_2')).toBe(true); + }); + }); + + describe('skipWhen in groups', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create((shouldSkip: boolean) => { + group('skip_group', () => { + vest.skipWhen(shouldSkip, () => { + test('field_1', 'skipped error', () => false); + }); + test('field_2', 'regular error', () => false); + }); + }); + }); + + it('should skip the test when skipWhen(true); skipped test still affects validity', () => { + const result = suite.run(true); + expect(result.hasErrorsByGroup('skip_group', 'field_1')).toBe(false); + expect(result.hasErrorsByGroup('skip_group', 'field_2')).toBe(true); + expect(result.isValidByGroup('skip_group', 'field_1')).toBe(false); + }); + + it('should run the test and collect errors when skipWhen(false)', () => { + const result = suite.run(false); + expect(result.hasErrorsByGroup('skip_group', 'field_1')).toBe(true); + expect(result.hasErrorsByGroup('skip_group', 'field_2')).toBe(true); + }); + }); + + describe('SuiteResult vs SuiteRunResult consistency', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('consistency_group', () => { + test('field_1', 'error 1', () => false); + test('field_2', 'warning 1', () => { + warn(); + return false; + }); + }); + }); + }); + + it('should return consistent group data from run() and get()', () => { + const runResult = suite.run(); + const getResult = suite.get(); + + expect(runResult.hasErrorsByGroup('consistency_group')).toBe( + getResult.hasErrorsByGroup('consistency_group'), + ); + expect(runResult.hasWarningsByGroup('consistency_group')).toBe( + getResult.hasWarningsByGroup('consistency_group'), + ); + expect(runResult.getErrorsByGroup('consistency_group')).toEqual( + getResult.getErrorsByGroup('consistency_group'), + ); + expect(runResult.getWarningsByGroup('consistency_group')).toEqual( + getResult.getWarningsByGroup('consistency_group'), + ); + expect(runResult.isValidByGroup('consistency_group')).toBe( + getResult.isValidByGroup('consistency_group'), + ); + }); + }); + + describe('Special characters in group and field names', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + group('group-with-dashes', () => { + test('field.with.dots', 'error 1', () => false); + test('field_with_underscores', () => {}); + }); + group('group with spaces', () => { + test('field-1', 'error 2', () => false); + }); + }); + }); + + it('should handle dashes, spaces, dots and underscores in names', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('group-with-dashes')).toBe(true); + expect( + result.hasErrorsByGroup('group-with-dashes', 'field.with.dots'), + ).toBe(true); + expect(result.hasErrorsByGroup('group with spaces')).toBe(true); + expect(result.hasErrorsByGroup('group with spaces', 'field-1')).toBe( + true, + ); + }); + }); + + describe('Large number of fields in a group', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + vest.mode(Modes.ALL); + group('large_group', () => { + for (let i = 0; i < 100; i++) { + test(`field_${i}`, `error ${i}`, () => i % 2 === 0); + } + }); + }); + }); + + it('should handle large groups and report errors only for failing fields', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('large_group')).toBe(true); + const errors = result.getErrorsByGroup('large_group'); + expect(Object.keys(errors).length).toBe(50); + expect(result.hasErrorsByGroup('large_group', 'field_0')).toBe(false); + expect(result.hasErrorsByGroup('large_group', 'field_1')).toBe(true); + expect(result.getErrorsByGroup('large_group', 'field_1')).toEqual([ + 'error 1', + ]); + }); + }); + + describe('Group with all passing tests', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + group('passing_group', () => { + test('field_1', () => {}); + test('field_2', () => true); + test('field_3', () => {}); + }); + }); + }); + + it('should be valid when all tests in the group pass', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('passing_group')).toBe(false); + expect(result.hasWarningsByGroup('passing_group')).toBe(false); + expect(result.getErrorsByGroup('passing_group')).toEqual({}); + expect(result.getWarningsByGroup('passing_group')).toEqual({}); + expect(result.isValidByGroup('passing_group')).toBe(true); + }); + }); + + describe('Mixed synchronous and asynchronous tests in group', () => { + let suite: TTestSuite; + + beforeEach(() => { + suite = create(() => { + group('mixed_async_group', () => { + test('sync_field', 'sync error', () => false); + test('async_field', async () => { + await wait(100); + throw new Error(); + }); + }); + }); + }); + + it('should show sync errors immediately; pending async tests are not errors yet', () => { + const result = suite.run(); + expect(result.hasErrorsByGroup('mixed_async_group', 'sync_field')).toBe( + true, + ); + expect(result.hasErrorsByGroup('mixed_async_group', 'async_field')).toBe( + false, + ); + }); + + it('should show async errors after the async test finishes', async () => { + suite.run(); + await wait(150); + const result = suite.get(); + expect(result.hasErrorsByGroup('mixed_async_group', 'sync_field')).toBe( + true, + ); + expect(result.hasErrorsByGroup('mixed_async_group', 'async_field')).toBe( + true, + ); + }); + }); +}); diff --git a/packages/vest/src/__tests__/integration.exclusive.test.ts b/packages/vest/src/__tests__/integration.exclusive.test.ts index 57ef340eb..770a9986e 100644 --- a/packages/vest/src/__tests__/integration.exclusive.test.ts +++ b/packages/vest/src/__tests__/integration.exclusive.test.ts @@ -1,6 +1,6 @@ -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach, test } from 'vitest'; +import { TTestSuite } from 'TVestMock'; import * as vest from 'vest'; let suite: TTestSuite; @@ -11,7 +11,7 @@ beforeEach(() => { describe('only', () => { it('Should only count included fields', () => { - const res = suite({ + const res = suite.run({ only: ['field_1', 'field_2'], }); @@ -22,7 +22,7 @@ describe('only', () => { expect(res.tests.field_5.testCount).toBe(0); }); it('Should only count included field', () => { - const res = suite({ + const res = suite.run({ only: 'field_1', }); @@ -35,7 +35,7 @@ describe('only', () => { }); describe('skip', () => { it('Should count all but excluded fields', () => { - const res = suite({ + const res = suite.run({ skip: ['field_1', 'field_2'], }); @@ -47,7 +47,7 @@ describe('skip', () => { }); it('Should count all but excluded field', () => { - const res = suite({ + const res = suite.run({ skip: 'field_1', }); @@ -61,7 +61,7 @@ describe('skip', () => { describe('Combined', () => { test('First declaration wins', () => { - const res = suite({ + const res = suite.run({ skip: ['field_1'], only: ['field_1', 'field_2', 'field_3'], skip_last: 'field_3', diff --git a/packages/vest/src/__tests__/integration.stateful-async.test.ts b/packages/vest/src/__tests__/integration.stateful-async.test.ts index e72cec1bb..70f17ac8e 100644 --- a/packages/vest/src/__tests__/integration.stateful-async.test.ts +++ b/packages/vest/src/__tests__/integration.stateful-async.test.ts @@ -1,18 +1,18 @@ -import { TIsolateTest } from 'IsolateTest'; -import { Modes } from 'Modes'; -import { TFieldName, TGroupName } from 'SuiteResultTypes'; -import { VestTest } from 'VestTest'; +import { CB } from 'vest-utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import wait from 'wait'; import { dummyTest } from '../testUtils/testDummy'; -import { TestPromise } from '../testUtils/testPromise'; +import { TIsolateTest } from 'IsolateTest'; +import { Modes } from 'Modes'; +import { TFieldName, TGroupName } from 'SuiteResultTypes'; +import { VestTest } from 'VestTest'; import * as vest from 'vest'; type SuiteParams = { skip?: string; skipGroup?: true }; -const suite = () => +const genSuite = () => vest.create(({ skip, skipGroup }: SuiteParams = {}) => { vest.mode(Modes.ALL); vest.skip(skip); @@ -37,11 +37,7 @@ const suite = () => dummyTest.failingAsync('field_3', { message: 'field_message_3' }); }); -let validate: vest.Suite< - TFieldName, - TGroupName, - ({ skip, skipGroup }: SuiteParams) => void ->; +let suite: vest.Suite; let callback_1 = vi.fn(), callback_2 = vi.fn(), callback_3 = vi.fn(), @@ -55,74 +51,62 @@ describe('Stateful async tests', () => { callback_3 = vi.fn(); callback_4 = vi.fn(); control = vi.fn(); - validate = suite(); + suite = genSuite(); + }); + + it('Should only run field callbacks for last suite run', async () => { + expect(callback_1).not.toHaveBeenCalled(); + expect(callback_2).not.toHaveBeenCalled(); + suite.afterField('field_3', callback_4).run({}); + expect(callback_4).not.toHaveBeenCalled(); + await wait(0); + expect(callback_1).not.toHaveBeenCalled(); + expect(callback_2).not.toHaveBeenCalled(); + expect(callback_4).toHaveBeenCalled(); + control(); + await wait(50); + expect(callback_1).not.toHaveBeenCalled(); + expect(callback_2).not.toHaveBeenCalled(); + expect(control).toHaveBeenCalled(); }); - it('Should only run callbacks for last suite run', () => - TestPromise(done => { - validate({}).done(callback_1).done('field_3', callback_2); - expect(callback_1).not.toHaveBeenCalled(); - expect(callback_2).not.toHaveBeenCalled(); - validate({}).done(callback_3).done('field_3', callback_4); - expect(callback_3).not.toHaveBeenCalled(); - expect(callback_4).not.toHaveBeenCalled(); - setTimeout(() => { - expect(callback_1).not.toHaveBeenCalled(); - expect(callback_2).not.toHaveBeenCalled(); - expect(callback_3).not.toHaveBeenCalled(); - expect(callback_4).toHaveBeenCalled(); - control(); - }); - setTimeout(() => { - expect(callback_1).not.toHaveBeenCalled(); - expect(callback_2).not.toHaveBeenCalled(); - expect(callback_3).toHaveBeenCalledTimes(1); - expect(control).toHaveBeenCalled(); - done(); - }, 50); - })); - - it('Merges skipped validations from previous suite', () => - TestPromise(done => { - const res = validate({ skipGroup: true, skip: 'field_3' }); - expect(res.testCount).toBe(3); - expect(res.errorCount).toBe(1); - expect(res.warnCount).toBe(0); - expect(res.hasErrors('field_1')).toBe(true); - expect(res.tests.field_1.errorCount).toBe(1); - expect(res.hasErrors('field_2')).toBe(false); - expect(res.hasErrors('field_3')).toBe(false); - expect(res.hasErrors('field_4')).toBe(false); - expect(res).toMatchSnapshot(); - setTimeout(() => { - const res = validate.get(); - - expect(res.testCount).toBe(3); - expect(res.errorCount).toBe(2); - expect(res.warnCount).toBe(0); - expect(res.tests.field_1.errorCount).toBe(1); - expect(res.hasErrors('field_2')).toBe(true); - expect(res.hasErrors('field_3')).toBe(false); - expect(res.hasErrors('field_4')).toBe(false); - expect(res).toMatchSnapshot(); - - validate({ skip: 'field_2' }).done(res => { - expect(res.testCount).toBe(7); - expect(res.errorCount).toBe(5); - expect(res.warnCount).toBe(0); - expect(res.tests.field_1.errorCount).toBe(2); - expect(res.hasErrors('field_2')).toBe(true); - expect(res.hasErrors('field_3')).toBe(true); - expect(res.hasErrors('field_4')).toBe(true); - expect(res).toMatchSnapshot(); - done(); - }); - }, 50); - })); + it('Merges skipped validations from previous suite', async () => { + let res = suite.run({ skipGroup: true, skip: 'field_3' }); + expect(res.testCount).toBe(3); + expect(res.errorCount).toBe(1); + expect(res.warnCount).toBe(0); + expect(res.hasErrors('field_1')).toBe(true); + expect(res.tests.field_1.errorCount).toBe(1); + expect(res.hasErrors('field_2')).toBe(false); + expect(res.hasErrors('field_3')).toBe(false); + expect(res.hasErrors('field_4')).toBe(false); + expect(res).toMatchSnapshot(); + await wait(50); + + res = suite.get(); + + expect(res.testCount).toBe(3); + expect(res.errorCount).toBe(2); + expect(res.warnCount).toBe(0); + expect(res.tests.field_1.errorCount).toBe(1); + expect(res.hasErrors('field_2')).toBe(true); + expect(res.hasErrors('field_3')).toBe(false); + expect(res.hasErrors('field_4')).toBe(false); + expect(res).toMatchSnapshot(); + + await suite.run({ skip: 'field_2' }); + expect(suite.get().testCount).toBe(7); + expect(suite.get().errorCount).toBe(5); + expect(suite.get().warnCount).toBe(0); + expect(suite.get().tests.field_1.errorCount).toBe(2); + expect(suite.hasErrors('field_2')).toBe(true); + expect(suite.hasErrors('field_3')).toBe(true); + expect(suite.hasErrors('field_4')).toBe(true); + expect(suite.get()).toMatchSnapshot(); + }); it('Should discard of re-tested async tests', async () => { const tests: Array = []; - const control = vi.fn(); const suite = vest.create(() => { tests.push( vest.test('field_1', tests.length.toString(), async () => { @@ -131,16 +115,8 @@ describe('Stateful async tests', () => { }), ); }); - suite().done(() => { - control(0); - }); - await wait(5); - suite().done(() => { - control(1); - }); - await wait(100); - expect(control).toHaveBeenCalledTimes(1); - expect(control).toHaveBeenCalledWith(1); + suite.run(); // not awaiting intentionally + await suite.run(); expect(VestTest.isCanceled(tests[0])).toBe(true); expect(VestTest.isFailing(tests[1])).toBe(true); diff --git a/packages/vest/src/__tests__/integration.stateful-tests.test.ts b/packages/vest/src/__tests__/integration.stateful-tests.test.ts index 488b2f45f..9f3e63e8b 100644 --- a/packages/vest/src/__tests__/integration.stateful-tests.test.ts +++ b/packages/vest/src/__tests__/integration.stateful-tests.test.ts @@ -1,22 +1,22 @@ -import { Modes } from 'Modes'; import { enforce } from 'n4s'; import { describe, it, expect, test } from 'vitest'; +import { Modes } from 'Modes'; import * as vest from 'vest'; describe('Stateful behavior', () => { let result; - const validate = genSuite(); + const suite = genSuite(); test('Should merge skipped fields with previous values', () => { - result = validate({ only: 'field_1' }); + result = suite.run({ only: 'field_1' }); expect(result.tests.field_1.errorCount).toBe(1); expect(result.errorCount).toBe(1); expect(Object.keys(result.tests)).toHaveLength(5); // including 4 skipped tests expect(result.tests).toHaveProperty('field_1'); expect(result).toMatchSnapshot(); - result = validate({ only: 'field_5' }); + result = suite.run({ only: 'field_5' }); expect(result.errorCount).toBe(3); expect(result.tests.field_1.errorCount).toBe(1); expect(result.tests.field_5.errorCount).toBe(2); @@ -25,7 +25,7 @@ describe('Stateful behavior', () => { expect(result.tests).toHaveProperty('field_5'); expect(result).toMatchSnapshot(); - result = validate(); + result = suite.run(); expect(result.errorCount).toBe(4); expect(result.tests.field_1.errorCount).toBe(1); expect(result.tests.field_2.errorCount).toBe(1); @@ -44,12 +44,12 @@ describe('more complex', () => { expect(suite.get()).toMatchSnapshot(); data.username = 'user_1'; - suite(data, 'username'); + suite.run(data, 'username'); expect(suite.get().hasErrors()).toBe(false); expect(suite.get()).toMatchSnapshot(); - suite(data, 'password'); + suite.run(data, 'password'); expect(suite.get().hasErrors()).toBe(true); expect(suite.get().tests.password).toMatchInlineSnapshot(` SummaryBase { @@ -66,7 +66,7 @@ describe('more complex', () => { `); expect(suite.get()).toMatchSnapshot(); - suite(data, 'confirm'); + suite.run(data, 'confirm'); expect(suite.get().tests.confirm).toMatchInlineSnapshot(` SummaryBase { "errorCount": 0, @@ -83,7 +83,7 @@ describe('more complex', () => { expect(suite.get().hasErrors('confirm')).toBe(false); data.password = '123456'; - suite(data, 'password'); + suite.run(data, 'password'); expect(suite.get().tests.confirm).toMatchInlineSnapshot(` SummaryBase { "errorCount": 0, @@ -96,7 +96,7 @@ describe('more complex', () => { } `); data.confirm = '123456'; - suite(data, 'confirm'); + suite.run(data, 'confirm'); expect(suite.get().hasErrors('password')).toBe(false); expect(suite.get().hasErrors('confirm')).toBe(false); expect(suite.get().tests.confirm).toMatchInlineSnapshot(` diff --git a/packages/vest/src/__tests__/isolate.test.ts b/packages/vest/src/__tests__/isolate.test.ts index 6d8ce5c53..a053df663 100644 --- a/packages/vest/src/__tests__/isolate.test.ts +++ b/packages/vest/src/__tests__/isolate.test.ts @@ -36,12 +36,12 @@ describe('isolate', () => { }); }); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(true); expect(f1).toHaveBeenCalledTimes(1); expect(f2).toHaveBeenCalledTimes(1); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(true); expect(f1).toHaveBeenCalledTimes(1); @@ -67,7 +67,7 @@ describe('isolate', () => { }); }); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(true); expect(suite.get().hasErrors('f3')).toBe(false); @@ -79,7 +79,7 @@ describe('isolate', () => { expect(suite.get().tests.f4).toBeUndefined(); expect(suite.get().tests.f5).toBeDefined(); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(true); expect(suite.get().hasErrors('f3')).toBe(true); @@ -110,7 +110,7 @@ describe('isolate', () => { }); }); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(false); expect(suite.get().hasErrors('f3')).toBe(true); @@ -118,7 +118,7 @@ describe('isolate', () => { expect(suite.get().tests.f2).toBeUndefined(); expect(suite.get().tests.f3).toBeDefined(); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(true); expect(suite.get().hasErrors('f3')).toBe(false); @@ -151,7 +151,7 @@ describe('isolate', () => { }); }); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(true); expect(suite.get().hasErrors('f3')).toBe(true); @@ -165,7 +165,7 @@ describe('isolate', () => { expect(suite.get().tests.f5).toBeDefined(); expect(suite.get().tests.f6).toBeUndefined(); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(false); expect(suite.get().hasErrors('f3')).toBe(false); @@ -195,12 +195,12 @@ describe('isolate', () => { } }); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(false); expect(suite.get().tests.f1).toBeDefined(); expect(suite.get().tests.f2).toBeUndefined(); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(false); expect(suite.get().hasErrors('f2')).toBe(true); expect(suite.get().tests.f1).toBeUndefined(); @@ -220,12 +220,12 @@ describe('isolate', () => { } }); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(true); expect(suite.get().hasErrors('f2')).toBe(false); expect(suite.get().tests.f1).toBeDefined(); expect(suite.get().tests.f2).toBeUndefined(); - suite(); + suite.run(); expect(suite.get().hasErrors('f1')).toBe(false); expect(suite.get().hasErrors('f2')).toBe(true); expect(suite.get().tests.f1).toBeUndefined(); @@ -240,8 +240,8 @@ describe('isolate', () => { }); }); - suite(); - suite(); + suite.run(); + suite.run(); expect(deferThrow).toHaveBeenCalledWith( expect.stringContaining( 'Vest Critical Error: Tests called in different order than previous run', diff --git a/packages/vest/src/__tests__/state_refill.test.ts b/packages/vest/src/__tests__/state_refill.test.ts index 1cf53194c..0dd3a3d25 100644 --- a/packages/vest/src/__tests__/state_refill.test.ts +++ b/packages/vest/src/__tests__/state_refill.test.ts @@ -1,6 +1,6 @@ -import { Modes } from 'Modes'; import { describe, it, expect } from 'vitest'; +import { Modes } from 'Modes'; import * as vest from 'vest'; describe('state refill', () => { @@ -37,7 +37,7 @@ describe('state refill', () => { suiteStates.push(currentRun); }); - expect(suite()).isDeepCopyOf(suite()); + expect(suite.run()).isDeepCopyOf(suite.run()); suiteStates[0].forEach((suiteState, i) => { expect(suiteState).isDeepCopyOf(suiteStates[1][i]); }); diff --git a/packages/vest/src/core/Runtime.ts b/packages/vest/src/core/Runtime.ts index 4d389cbaf..dc5fc8cda 100644 --- a/packages/vest/src/core/Runtime.ts +++ b/packages/vest/src/core/Runtime.ts @@ -1,3 +1,4 @@ +import type { RuleInstance } from 'n4s'; import { CB, CacheApi, @@ -7,39 +8,30 @@ import { seq, tinyState, } from 'vest-utils'; -import { IRecociler, TIsolate, VestRuntime } from 'vestjs-runtime'; +import { IRecociler, VestRuntime } from 'vestjs-runtime'; import { TIsolateSuite } from 'IsolateSuite'; -import { Severity } from 'Severity'; import { SuiteName, SuiteResult, TFieldName, TGroupName, } from 'SuiteResultTypes'; +import { reprocessTree } from 'registerTests'; export type DoneCallback = (res: SuiteResult) => void; type FieldCallbacks = Record; + type DoneCallbacks = Array; -type FailuresCache = { - [Severity.ERRORS]: Record; - [Severity.WARNINGS]: Record; -}; -export type PreAggCache = { - pending: TIsolate[]; - failures: FailuresCache; -}; type StateExtra = { doneCallbacks: TinyState; fieldCallbacks: TinyState; suiteName: Maybe; suiteId: string; - suiteResultCache: CacheApi>; - preAggCache: CacheApi; + suiteResultCache: CacheApi>; }; -const suiteResultCache = cache>(); -const preAggCache = cache(); +const suiteResultCache = cache>(); export function useCreateVestState({ suiteName, @@ -51,7 +43,6 @@ export function useCreateVestState({ const stateRef: StateExtra = { doneCallbacks: tinyState.createTinyState(() => []), fieldCallbacks: tinyState.createTinyState(() => ({})), - preAggCache, suiteId: seq(), suiteName, suiteResultCache, @@ -76,32 +67,23 @@ export function useSuiteName() { return useX().suiteName; } -export function useSuiteId() { +function useSuiteId() { return useX().suiteId; } -export function useSuiteResultCache( - action: CB>, -): SuiteResult { +export function useSuiteResultCache< + F extends TFieldName, + G extends TGroupName, + S extends RuleInstance | undefined = undefined, +>(action: CB>): SuiteResult { const suiteResultCache = useX().suiteResultCache; - return suiteResultCache([useSuiteId()], action) as SuiteResult; -} - -export function usePreAggCache(action: CB) { - const preAggCache = useX().preAggCache; - - return preAggCache([useSuiteId()], action); + return suiteResultCache([useSuiteId()], action) as SuiteResult; } export function useExpireSuiteResultCache() { const suiteResultCache = useX().suiteResultCache; suiteResultCache.invalidate([useSuiteId()]); - - // whenever we invalidate the entire result, we also want to invalidate the preagg cache - // so that we do not get stale results there. - // there may be a better place to do this, but for now, this should work. - preAggCache.invalidate([useSuiteId()]); } export function useResetCallbacks() { @@ -119,5 +101,8 @@ export function useResetSuite() { export function useLoadSuite(rootNode: TIsolateSuite): void { VestRuntime.useSetHistoryRoot(rootNode); + + reprocessTree(rootNode); + useExpireSuiteResultCache(); } diff --git a/packages/vest/src/core/StateMachines/CommonStateMachine.ts b/packages/vest/src/core/StateMachines/CommonStateMachine.ts index 4d811270c..62bb06ef7 100644 --- a/packages/vest/src/core/StateMachines/CommonStateMachine.ts +++ b/packages/vest/src/core/StateMachines/CommonStateMachine.ts @@ -12,7 +12,7 @@ const State = { [CommonStates.DONE]: CommonStates.DONE, }; -export type State = ValueOf; +type State = ValueOf; const machine: TStateMachine = { initial: State.INITIAL, diff --git a/packages/vest/src/core/StateMachines/IsolateTestStateMachine.ts b/packages/vest/src/core/StateMachines/IsolateTestStateMachine.ts index 53f5aaa74..70b048a32 100644 --- a/packages/vest/src/core/StateMachines/IsolateTestStateMachine.ts +++ b/packages/vest/src/core/StateMachines/IsolateTestStateMachine.ts @@ -20,7 +20,7 @@ export const TestAction = { export type TestStatus = ValueOf; export type TestAction = ValueOf; -export type TestStateMachineAction = TestAction | TestStatus; +type TestStateMachineAction = TestAction | TestStatus; const machine: TStateMachine = { initial: TestStatus.UNTESTED, diff --git a/packages/vest/src/core/VestBus/VestBus.ts b/packages/vest/src/core/VestBus/VestBus.ts index 41be227bd..80eeadd68 100644 --- a/packages/vest/src/core/VestBus/VestBus.ts +++ b/packages/vest/src/core/VestBus/VestBus.ts @@ -1,8 +1,13 @@ +import { + registerTestsTraverseUp, + registerTestNodes, + onTestStart, + reprocessTree, +} from 'registerTests'; import { CB, ValueOf } from 'vest-utils'; -import { Bus, RuntimeEvents, TIsolate } from 'vestjs-runtime'; +import { Bus, RuntimeEvents, TIsolate, VestRuntime } from 'vestjs-runtime'; import { Events } from 'BusEvents'; -// import { TIsolateTest } from 'IsolateTest'; import { useExpireSuiteResultCache, useResetCallbacks, @@ -14,13 +19,25 @@ import { TestWalker } from 'TestWalker'; import { VestIsolate } from 'VestIsolate'; import { VestTest } from 'VestTest'; import { useOmitOptionalFields } from 'omitOptionalFields'; -import { useRunDoneCallbacks, useRunFieldCallbacks } from 'runCallbacks'; +import { + useRunDoneCallbacks, + useRunFieldCallbacks, + useRunSyncFieldCallbacks, +} from 'runCallbacks'; // eslint-disable-next-line max-statements, max-lines-per-function export function useInitVestBus() { const VestBus = Bus.useBus(); - on('TEST_COMPLETED', () => {}); + on('TEST_COMPLETED', (isolate: TIsolate) => { + // #FIXME: This is not ideal as it traverses up all the way on every test + // but for now it should be ok. O(n) + // The reason we're doing this is that whenever tests are declared anywhere that's not the top level + // we still need them to be accessible from the root level tests[] array. + // This is becaue all of the runtime checks related to execution mode and early exit (eager, one, lazy) + // occur and are based on the top level registry instead of needing to always traverse all the way down. + registerTestsTraverseUp(isolate); + }); on('TEST_RUN_STARTED', () => { // Bringin this back due to https://github.com/ealush/vest/issues/1157 @@ -36,6 +53,16 @@ export function useInitVestBus() { // any performance issues. }); + VestBus.on(RuntimeEvents.ISOLATE_ENTER, (isolate: TIsolate) => { + if (VestTest.is(isolate)) { + onTestStart(isolate); + } + }); + + VestBus.on(RuntimeEvents.ISOLATE_RECONCILED, (isolate: TIsolate) => { + registerTestNodes(isolate); + }); + VestBus.on(RuntimeEvents.ISOLATE_PENDING, (isolate: TIsolate) => { if (VestTest.is(isolate)) { VestTest.setPending(isolate); @@ -47,6 +74,8 @@ export function useInitVestBus() { VestBus.on(RuntimeEvents.ISOLATE_DONE, (isolate: TIsolate) => { if (VestTest.is(isolate)) { VestBus.emit('TEST_COMPLETED', isolate); + } else { + registerTestNodes(isolate); } VestIsolate.setDone(isolate); @@ -58,12 +87,13 @@ export function useInitVestBus() { const { fieldName } = VestTest.getData(isolate); useRunFieldCallbacks(fieldName); + useRunDoneCallbacks(); } } if (!SuiteWalker.useHasPending()) { // When no more async tests are running, emit the done event - VestBus.emit('ALL_RUNNING_TESTS_FINISHED'); + VestBus.emit('ALL_RUNNING_TESTS_FINISHED', isolate); } }); @@ -80,28 +110,35 @@ export function useInitVestBus() { if (TestWalker.someTests(VestTest.isAsyncTest)) { useOmitOptionalFields(); } - useRunDoneCallbacks(); + + // resolve the suite's promise + SuiteWalker.useResolve(); }); on('RESET_FIELD', (fieldName: TFieldName) => { TestWalker.resetField(fieldName); }); - on('SUITE_RUN_STARTED', () => { + on('SUITE_RUN_STARTED', () => {}); + + on('INITIALIZING_CALLBACKS', () => { useResetCallbacks(); }); - on('SUITE_CALLBACK_RUN_FINISHED', () => { + on('SUITE_CALLBACK_RUN_FINISHED', (isolate: TIsolate) => { if (!SuiteWalker.useHasPending()) { // When no more async tests are running, emit the done event - VestBus.emit('ALL_RUNNING_TESTS_FINISHED'); + VestBus.emit('ALL_RUNNING_TESTS_FINISHED', isolate); } useOmitOptionalFields(); + useRunSyncFieldCallbacks(); + useRunDoneCallbacks(); }); on('REMOVE_FIELD', (fieldName: TFieldName) => { TestWalker.removeTestByFieldName(fieldName); + reprocessTree(VestRuntime.useAvailableRoot()); }); on('RESET_SUITE', () => { diff --git a/packages/vest/src/core/__tests__/__snapshots__/runtime.test.ts.snap b/packages/vest/src/core/__tests__/__snapshots__/runtime.test.ts.snap index 07e283703..2b2b16551 100644 --- a/packages/vest/src/core/__tests__/__snapshots__/runtime.test.ts.snap +++ b/packages/vest/src/core/__tests__/__snapshots__/runtime.test.ts.snap @@ -6,6 +6,22 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "abortController": AbortController {}, "allowReorder": undefined, "children": [ + { + "$type": "Focused", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "focusMode": 0, + "match": [], + "matchAll": false, + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, { "$type": "Focused", "abortController": AbortController {}, @@ -52,7 +68,6 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "children": null, "data": { "fieldName": "t2", - "groupName": "g1", "message": "t2 message", "severity": "error", "testFn": [Function], @@ -70,7 +85,6 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "children": null, "data": { "fieldName": "t3", - "groupName": "g1", "severity": "error", "testFn": [Function], }, @@ -87,7 +101,6 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "children": null, "data": { "fieldName": "t4", - "groupName": "g1", "severity": "warning", "testFn": [Function], }, @@ -98,7 +111,60 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "status": "WARNING", }, ], - "data": {}, + "data": { + "groupName": "g1", + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t2", + "message": "t2 message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t3", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "PASSING", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t4", + "severity": "warning", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "WARNING", + }, + ], + }, "key": null, "keys": null, "output": undefined, @@ -159,7 +225,42 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "status": "FAILED", }, ], - "data": {}, + "data": { + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "a", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "b", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + ], + }, "key": null, "keys": { "a": { @@ -202,11 +303,511 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] ], "data": { "optional": {}, + "resolver": [Function], + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t2", + "message": "t2 message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t3", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "PASSING", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t4", + "severity": "warning", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "WARNING", + }, + ], + "data": { + "groupName": "g1", + "tests": [ + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t3", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "PASSING", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t4", + "severity": "warning", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "WARNING", + }, + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t3", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t2", + "message": "t2 message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t4", + "severity": "warning", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "WARNING", + }, + ], + "data": { + "groupName": "g1", + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t2", + "message": "t2 message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t4", + "severity": "warning", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "WARNING", + }, + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "PASSING", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t4", + "severity": "warning", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t2", + "message": "t2 message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t3", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "PASSING", + }, + [Circular], + ], + "data": { + "groupName": "g1", + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t2", + "message": "t2 message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t3", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "PASSING", + }, + [Circular], + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "WARNING", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t5", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "a", + "keys": null, + "output": undefined, + "parent": { + "$type": "Each", + "abortController": AbortController {}, + "allowReorder": true, + "children": [ + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "b", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + ], + "data": { + "tests": [ + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "b", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + ], + }, + "key": null, + "keys": { + "a": [Circular], + "b": { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "b", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + }, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "b", + "keys": null, + "output": undefined, + "parent": { + "$type": "Each", + "abortController": AbortController {}, + "allowReorder": true, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "a", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + [Circular], + ], + "data": { + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "a", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + [Circular], + ], + }, + "key": null, + "keys": { + "a": { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "t6", + "severity": "error", + "testFn": [Function], + }, + "key": "a", + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + "b": [Circular], + }, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "FAILED", + }, + ], }, "key": null, "keys": null, - "output": { - "done": [Function], + "output": SuiteSummary { + "dump": [Function], "errorCount": 4, "errors": [ SummaryFailure { @@ -339,6 +940,7 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 1, "warnings": [ @@ -356,6 +958,7 @@ exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 1`] exports[`useLoadSuite > Calling useLoadSuite should resume from loaded state 2`] = ` SuiteSummary { + "dump": [Function], "errorCount": 4, "errors": [ SummaryFailure { @@ -488,6 +1091,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 1, "warnings": [ diff --git a/packages/vest/src/core/__tests__/runtime.test.ts b/packages/vest/src/core/__tests__/runtime.test.ts index 0fea460d1..47328f21c 100644 --- a/packages/vest/src/core/__tests__/runtime.test.ts +++ b/packages/vest/src/core/__tests__/runtime.test.ts @@ -7,7 +7,7 @@ describe('useLoadSuite', () => { const suite = vest.create(() => { vest.test('some_test', () => {}); }); - const res = suite(); + const res = suite.run(); expect(suite.isValid()).toBe(true); expect(res.testCount).toBe(1); expect(res.errorCount).toBe(0); @@ -85,7 +85,7 @@ function genDump() { }); }); - suite(); + suite.run(); return suite.dump(); } diff --git a/packages/vest/src/core/context/SuiteContext.ts b/packages/vest/src/core/context/SuiteContext.ts index 1fd43c707..d8225a117 100644 --- a/packages/vest/src/core/context/SuiteContext.ts +++ b/packages/vest/src/core/context/SuiteContext.ts @@ -1,12 +1,6 @@ +import type { RuleInstance } from 'n4s'; import { createCascade } from 'context'; -import { - assign, - TinyState, - tinyState, - cache, - CacheApi, - DynamicValue, -} from 'vest-utils'; +import { assign, TinyState, tinyState, DynamicValue } from 'vest-utils'; import { TIsolateTest } from 'IsolateTest'; import { Modes } from 'Modes'; @@ -21,7 +15,7 @@ export const SuiteContext = createCascade((ctxRef, parentContext) => { inclusion: {}, mode: tinyState.createTinyState(Modes.EAGER), suiteParams: [], - testMemoCache, + schema: undefined, }, ctxRef, ); @@ -31,9 +25,8 @@ type CTXType = { inclusion: Record>; mode: TinyState; suiteParams: any[]; - testMemoCache: CacheApi; + schema?: RuleInstance; currentTest?: TIsolateTest; - groupName?: string; skipped?: boolean; omitted?: boolean; }; @@ -42,10 +35,6 @@ export function useCurrentTest(msg?: string) { return SuiteContext.useX(msg).currentTest; } -export function useGroupName() { - return SuiteContext.useX().groupName; -} - export function useInclusion() { return SuiteContext.useX().inclusion; } @@ -62,12 +51,10 @@ export function useOmitted() { return SuiteContext.useX().omitted ?? false; } -const testMemoCache = cache(10); - -export function useTestMemoCache() { - return SuiteContext.useX().testMemoCache; -} - export function useSuiteParams() { return SuiteContext.useX().suiteParams; } + +export function useSuiteSchema() { + return SuiteContext.useX().schema; +} diff --git a/packages/vest/src/core/isolate/IsolateEach/IsolateEach.ts b/packages/vest/src/core/isolate/IsolateEach/IsolateEach.ts index 8d089051d..2f446e2b9 100644 --- a/packages/vest/src/core/isolate/IsolateEach/IsolateEach.ts +++ b/packages/vest/src/core/isolate/IsolateEach/IsolateEach.ts @@ -1,14 +1,17 @@ import { CB } from 'vest-utils'; -import { TIsolate, Isolate } from 'vestjs-runtime'; -import { VestIsolateType } from 'VestIsolateType'; +import { + createVestIsolate, + TVestIsolate, + VestIsolateType, +} from 'VestIsolateType'; -type TIsolateEach = TIsolate; +type TIsolateEach = TVestIsolate; export function IsolateEach( callback: Callback, ): TIsolateEach { - return Isolate.create(VestIsolateType.Each, callback, { + return createVestIsolate(VestIsolateType.Each, callback, { allowReorder: true, }); } diff --git a/packages/vest/src/core/isolate/IsolateSuite/IsolateSuite.ts b/packages/vest/src/core/isolate/IsolateSuite/IsolateSuite.ts index 573ae0981..20ab358c1 100644 --- a/packages/vest/src/core/isolate/IsolateSuite/IsolateSuite.ts +++ b/packages/vest/src/core/isolate/IsolateSuite/IsolateSuite.ts @@ -1,19 +1,25 @@ import { CB, assign } from 'vest-utils'; -import { Isolate, TIsolate } from 'vestjs-runtime'; import { OptionalFieldDeclaration, OptionalFields } from 'OptionalTypes'; -import { TFieldName } from 'SuiteResultTypes'; -import { VestIsolateType } from 'VestIsolateType'; +import { SuiteResult, TFieldName, TGroupName } from 'SuiteResultTypes'; +import { + createVestIsolate, + TVestIsolate, + VestIsolateType, +} from 'VestIsolateType'; -export type TIsolateSuite = TIsolate<{ +export type TIsolateSuite = TVestIsolate<{ optional: OptionalFields; + resolver: CB>; }>; export function IsolateSuite( callback: Callback, + resolver: CB>, ): TIsolateSuite { - return Isolate.create(VestIsolateType.Suite, callback, { + return createVestIsolate(VestIsolateType.Suite, callback, { optional: {}, + resolver, }); } diff --git a/packages/vest/src/core/isolate/IsolateTest/IsolateTest.ts b/packages/vest/src/core/isolate/IsolateTest/IsolateTest.ts index 988d95cac..fa028a960 100644 --- a/packages/vest/src/core/isolate/IsolateTest/IsolateTest.ts +++ b/packages/vest/src/core/isolate/IsolateTest/IsolateTest.ts @@ -3,33 +3,25 @@ import { TIsolate, Isolate, IsolateKey } from 'vestjs-runtime'; import { IsolateTestStateMachine, TestStatus } from 'IsolateTestStateMachine'; import { TestSeverity } from 'Severity'; -import { TFieldName, TGroupName } from 'SuiteResultTypes'; +import { TFieldName } from 'SuiteResultTypes'; import { AsyncTest, TestFn } from 'TestTypes'; import { VestIsolateType } from 'VestIsolateType'; -export type TIsolateTest< - F extends TFieldName = TFieldName, - G extends TGroupName = TGroupName, -> = TIsolate & IsolateTestPayload>; +export type TIsolateTest = TIsolate< + CommonTestFields & IsolateTestPayload +>; -export function IsolateTest< - F extends TFieldName = TFieldName, - G extends TGroupName = TGroupName, ->( +export function IsolateTest( callback: CB, - input: CommonTestFields, + input: CommonTestFields, key?: IsolateKey, -): TIsolateTest { +): TIsolateTest { const payload: IsolateTestPayload = { ...IsolateTestBase(), fieldName: input.fieldName, testFn: input.testFn, }; - if (input.groupName) { - payload.groupName = input.groupName; - } - if (input.message) { payload.message = input.message; } @@ -40,7 +32,7 @@ export function IsolateTest< key ?? null, ); - return isolate as TIsolateTest; + return isolate as TIsolateTest; } export function IsolateTestBase() { @@ -50,21 +42,15 @@ export function IsolateTestBase() { }; } -export type IsolateTestPayload< - F extends TFieldName = TFieldName, - G extends TGroupName = TGroupName, -> = CommonTestFields & { - severity: TestSeverity; - status: TestStatus; - asyncTest?: AsyncTest; -}; +export type IsolateTestPayload = + CommonTestFields & { + severity: TestSeverity; + status: TestStatus; + asyncTest?: AsyncTest; + }; -type CommonTestFields< - F extends TFieldName = TFieldName, - G extends TGroupName = TGroupName, -> = { +type CommonTestFields = { message?: Maybe; - groupName?: G; fieldName: F; testFn: TestFn; }; diff --git a/packages/vest/src/core/isolate/IsolateTest/TestWalker.ts b/packages/vest/src/core/isolate/IsolateTest/TestWalker.ts index 75b77c809..ab7f5b258 100644 --- a/packages/vest/src/core/isolate/IsolateTest/TestWalker.ts +++ b/packages/vest/src/core/isolate/IsolateTest/TestWalker.ts @@ -2,7 +2,7 @@ import { Nullable } from 'vest-utils'; import { Walker, VestRuntime, TIsolate } from 'vestjs-runtime'; import { TIsolateTest } from 'IsolateTest'; -import { TFieldName, TGroupName } from 'SuiteResultTypes'; +import { TFieldName } from 'SuiteResultTypes'; import { VestTest } from 'VestTest'; import matchingFieldName from 'matchingFieldName'; @@ -48,15 +48,15 @@ export class TestWalker { ); } - static walkTests( - callback: (test: TIsolateTest, breakout: () => void) => void, + static walkTests( + callback: (test: TIsolateTest, breakout: () => void) => void, root: MaybeRoot = TestWalker.defaultRoot(), ): void { if (!root) return; Walker.walk( root, (isolate, breakout) => { - callback(VestTest.cast(isolate), breakout); + callback(VestTest.cast(isolate), breakout); }, VestTest.is, ); diff --git a/packages/vest/src/core/isolate/IsolateTest/VestTest.ts b/packages/vest/src/core/isolate/IsolateTest/VestTest.ts index 2a01093f5..a81123783 100644 --- a/packages/vest/src/core/isolate/IsolateTest/VestTest.ts +++ b/packages/vest/src/core/isolate/IsolateTest/VestTest.ts @@ -1,5 +1,10 @@ -import { Maybe, invariant, isPromise, optionalFunctionValue } from 'vest-utils'; -import { IsolateMutator, IsolateSelectors, TIsolate } from 'vestjs-runtime'; +import { Maybe, invariant, isPromise, dynamicValue } from 'vest-utils'; +import { + IsolateMutator, + IsolateSelectors, + TIsolate, + Walker, +} from 'vestjs-runtime'; import { ErrorStrings } from 'ErrorStrings'; import type { TIsolateTest } from 'IsolateTest'; @@ -12,20 +17,25 @@ import { TestSeverity } from 'Severity'; import { TFieldName, TGroupName } from 'SuiteResultTypes'; import { VestIsolate } from 'VestIsolate'; import { VestIsolateType } from 'VestIsolateType'; +import { TIsolateGroup } from 'group'; export class VestTest extends VestIsolate { static stateMachine = IsolateTestStateMachine; // Read - static getData< - F extends TFieldName = TFieldName, - G extends TGroupName = TGroupName, - >(test: TIsolateTest) { + static getData(test: TIsolateTest) { invariant(test.data); return test.data; } + static getGroupName(test: TIsolateTest): Maybe { + const group = Walker.closest>(test, i => + IsolateSelectors.isIsolateType(i, VestIsolateType.Group), + ); + return group?.data.groupName; + } + static is(isolate?: Maybe): isolate is TIsolateTest { return IsolateSelectors.isIsolateType( isolate, @@ -37,11 +47,11 @@ export class VestTest extends VestIsolate { invariant(VestTest.is(isolate), ErrorStrings.EXPECTED_VEST_TEST); } - static cast( + static cast( isolate?: Maybe, - ): TIsolateTest { + ): TIsolateTest { VestTest.isX(isolate); - return isolate as TIsolateTest; + return isolate as TIsolateTest; } static warns(test: TIsolateTest): boolean { @@ -136,7 +146,7 @@ export class VestTest extends VestIsolate { | ((current: TIsolateTest['data']) => TIsolateTest['data']) | TIsolateTest['data'], ): void { - test.data = optionalFunctionValue(setter, VestTest.getData(test)); + test.data = dynamicValue(setter, VestTest.getData(test)); } static skip(test: TIsolateTest, force?: boolean): void { diff --git a/packages/vest/src/core/isolate/IsolateTest/__tests__/hasRemainingTests.test.ts b/packages/vest/src/core/isolate/IsolateTest/__tests__/hasRemainingTests.test.ts index b2c6672ed..7e2d77458 100644 --- a/packages/vest/src/core/isolate/IsolateTest/__tests__/hasRemainingTests.test.ts +++ b/packages/vest/src/core/isolate/IsolateTest/__tests__/hasRemainingTests.test.ts @@ -12,18 +12,20 @@ describe('SuiteWalker.useHasRemainingWithTestNameMatching', () => { describe('When no field specified', () => { describe('When no remaining tests', () => { it('should return false', () => { - const suite = vest.create(() => {})(); + const suite = vest.create(() => {}).run(); expect(suite.isPending()).toBe(false); }); }); describe('When there are remaining tests', () => { it('pending tests return true', () => { - const suite = vest.create(() => { - vest.test('f1', async () => { - await wait(100); - }); - })(); + const suite = vest + .create(() => { + vest.test('f1', async () => { + await wait(100); + }); + }) + .run(); expect(suite.isPending()).toBe(true); }); @@ -36,8 +38,8 @@ describe('SuiteWalker.useHasRemainingWithTestNameMatching', () => { }); count++; }); - suite(); - suite(); + suite.run(); + suite.run(); expect(suite.isPending()).toBe(true); }); @@ -54,8 +56,8 @@ describe('SuiteWalker.useHasRemainingWithTestNameMatching', () => { count++; }); - suite(); - suite(); + suite.run(); + suite.run(); expect(suite.isPending()).toBe(true); }); @@ -65,18 +67,20 @@ describe('SuiteWalker.useHasRemainingWithTestNameMatching', () => { describe('When field specified', () => { describe('When no remaining tests', () => { it('Should return false', () => { - const suite = vest.create(() => {})(); + const suite = vest.create(() => {}).run(); expect(suite.isPending('f1')).toBe(false); }); }); describe('When remaining tests', () => { it('pending tests return true', () => { - const suite = vest.create(() => { - vest.test('f1', async () => { - await wait(100); - }); - })(); + const suite = vest + .create(() => { + vest.test('f1', async () => { + await wait(100); + }); + }) + .run(); expect(suite.isPending('f1')).toBe(true); }); @@ -88,8 +92,8 @@ describe('SuiteWalker.useHasRemainingWithTestNameMatching', () => { }); count++; }); - suite(); - suite(); + suite.run(); + suite.run(); expect(suite.isPending('f1')).toBe(true); }); @@ -106,8 +110,8 @@ describe('SuiteWalker.useHasRemainingWithTestNameMatching', () => { count++; }); - suite(); - suite(); + suite.run(); + suite.run(); expect(suite.isPending('f1')).toBe(true); expect(suite.isPending('f2')).toBe(true); diff --git a/packages/vest/src/core/isolate/IsolateTest/isSameProfileTest.ts b/packages/vest/src/core/isolate/IsolateTest/isSameProfileTest.ts index 0889ea8fd..7b41a8a4e 100644 --- a/packages/vest/src/core/isolate/IsolateTest/isSameProfileTest.ts +++ b/packages/vest/src/core/isolate/IsolateTest/isSameProfileTest.ts @@ -6,8 +6,9 @@ export function isSameProfileTest( testObject1: TIsolateTest, testObject2: TIsolateTest, ): boolean { - const { groupName: gn1 } = VestTest.getData(testObject1); - const { groupName: gn2, fieldName: fn2 } = VestTest.getData(testObject2); + const gn1 = VestTest.getGroupName(testObject1); + const { fieldName: fn2 } = VestTest.getData(testObject2); + const gn2 = VestTest.getGroupName(testObject2); return ( matchingFieldName(VestTest.getData(testObject1), fn2) && gn1 === gn2 && diff --git a/packages/vest/src/core/isolate/VestIsolate.ts b/packages/vest/src/core/isolate/VestIsolate.ts index fb82a991b..e11d1c172 100644 --- a/packages/vest/src/core/isolate/VestIsolate.ts +++ b/packages/vest/src/core/isolate/VestIsolate.ts @@ -1,4 +1,4 @@ -import { TStateMachineApi } from 'vest-utils'; +import { Nullable, TStateMachineApi } from 'vest-utils'; import { TIsolate } from 'vestjs-runtime'; import { CommonStateMachine, CommonStates } from 'CommonStateMachine'; @@ -33,4 +33,8 @@ export class VestIsolate { static isPending(isolate: TIsolate): boolean { return VestIsolate.statusEquals(isolate, CommonStates.PENDING); } + + static getParent(isolate: TIsolate): Nullable { + return isolate.parent; + } } diff --git a/packages/vest/src/core/isolate/VestIsolateType.ts b/packages/vest/src/core/isolate/VestIsolateType.ts index fd7bd0d67..e445510b4 100644 --- a/packages/vest/src/core/isolate/VestIsolateType.ts +++ b/packages/vest/src/core/isolate/VestIsolateType.ts @@ -1,3 +1,8 @@ +import { CB } from 'vest-utils'; +import { Isolate, IsolateKey, TIsolate } from 'vestjs-runtime'; + +import { TIsolateTest } from 'IsolateTest'; + export const VestIsolateType = { Each: 'Each', Focused: 'Focused', @@ -7,3 +12,32 @@ export const VestIsolateType = { Suite: 'Suite', Test: 'Test', }; + +export type TVestIsolate

= TIsolate< + P & { + tests: TIsolateTest[]; + } +>; + +export function createVestIsolate>( + type: string, + cb: CB, + payload: Payload, + key?: IsolateKey, +): TVestIsolate { + return Isolate.create( + type, + cb, + { + ...payload, + tests: [], + }, + key, + ); +} + +export function isVestIsolate( + isolate: TIsolate | null, +): isolate is TVestIsolate { + return Array.isArray(isolate?.data?.tests); +} diff --git a/packages/vest/src/core/isolate/registerTests.ts b/packages/vest/src/core/isolate/registerTests.ts new file mode 100644 index 000000000..898936698 --- /dev/null +++ b/packages/vest/src/core/isolate/registerTests.ts @@ -0,0 +1,62 @@ +import { invariant } from 'vest-utils'; +import { Bus, RuntimeEvents, TIsolate } from 'vestjs-runtime'; + +import { TIsolateTest } from 'IsolateTest'; +import { VestIsolate } from 'VestIsolate'; +import { isVestIsolate } from 'VestIsolateType'; +import { VestTest } from 'VestTest'; + +export function registerTestNodes(isolate: TIsolate) { + const tests: TIsolateTest[] = []; + + const parent = VestIsolate.getParent(isolate); + + if (!parent) { + return; + } + + invariant(parent.children, 'Expected parent to have children'); + + parent.data.tests = parent.children.reduce((tests, current) => { + if (VestTest.is(current)) { + tests.push(current); + } + + if (isVestIsolate(current)) { + tests.push(...current.data.tests); + } + + return tests; + }, tests); +} + +// Traverses all the way up and registers all test nodes all the way up to the suite level +export function registerTestsTraverseUp(isolate: TIsolate) { + let next = isolate.parent; + + while (next) { + if (!isVestIsolate(next)) { + return; + } + + registerTestNodes(next); + next = next.parent; + } +} + +export function onTestStart(testObject: TIsolateTest) { + registerTestNodes(testObject); +} + +export function reprocessTree(rootNode: TIsolate): void { + if (!rootNode.children) { + return; + } + + const emit = Bus.useEmit(); + + for (const child of rootNode.children) { + reprocessTree(child); + emit(RuntimeEvents.ISOLATE_RECONCILED, child); + } +} diff --git a/packages/vest/src/core/test/__tests__/IsolateTest.test.ts b/packages/vest/src/core/test/__tests__/IsolateTest.test.ts index 2ab6ea20e..a5e43f690 100644 --- a/packages/vest/src/core/test/__tests__/IsolateTest.test.ts +++ b/packages/vest/src/core/test/__tests__/IsolateTest.test.ts @@ -1,12 +1,12 @@ -import { TIsolateTest } from 'IsolateTest'; -import { VestTest } from 'VestTest'; -import { mockIsolateTest } from 'vestMocks'; import { describe, it, expect, beforeEach, test, vi } from 'vitest'; import wait from 'wait'; import { TestPromise } from '../../../testUtils/testPromise'; +import { TIsolateTest } from 'IsolateTest'; +import { VestTest } from 'VestTest'; import * as vest from 'vest'; +import { mockIsolateTest } from 'vestMocks'; const fieldName = 'unicycle'; const message = 'I am Root.'; @@ -59,7 +59,7 @@ describe('IsolateTest', () => { }); VestTest.cancel(testObject); }); - suite(); + suite.run(); expect(VestTest.isCanceled(testObject)).toBe(true); done(); @@ -72,46 +72,50 @@ describe('IsolateTest', () => { vi.resetAllMocks(); }); it('keep status unchanged when `failed`', () => { - vest.create(() => { - // async so it is not a final status - const testObject = vest.test('f1', async () => { - await wait(100); - }); - VestTest.fail(testObject); - expect(VestTest.isFailing(testObject)).toBe(true); - VestTest.skip(testObject); - expect(VestTest.isSkipped(testObject)).toBe(false); - expect(VestTest.isFailing(testObject)).toBe(true); - VestTest.cancel(testObject); - expect(VestTest.isCanceled(testObject)).toBe(false); - expect(VestTest.isFailing(testObject)).toBe(true); - VestTest.setPending(testObject); - expect(VestTest.isPending(testObject)).toBe(false); - expect(VestTest.isFailing(testObject)).toBe(true); - control(); - })(); + vest + .create(() => { + // async so it is not a final status + const testObject = vest.test('f1', async () => { + await wait(100); + }); + VestTest.fail(testObject); + expect(VestTest.isFailing(testObject)).toBe(true); + VestTest.skip(testObject); + expect(VestTest.isSkipped(testObject)).toBe(false); + expect(VestTest.isFailing(testObject)).toBe(true); + VestTest.cancel(testObject); + expect(VestTest.isCanceled(testObject)).toBe(false); + expect(VestTest.isFailing(testObject)).toBe(true); + VestTest.setPending(testObject); + expect(VestTest.isPending(testObject)).toBe(false); + expect(VestTest.isFailing(testObject)).toBe(true); + control(); + }) + .run(); expect(control).toHaveBeenCalledTimes(1); }); it('keep status unchanged when `canceled`', () => { - vest.create(() => { - // async so it is not a final status - const testObject = vest.test('f1', async () => { - await wait(100); - }); - VestTest.cancel(testObject); - expect(VestTest.isCanceled(testObject)).toBe(true); - VestTest.fail(testObject); - expect(VestTest.isCanceled(testObject)).toBe(true); - expect(VestTest.isFailing(testObject)).toBe(false); - VestTest.skip(testObject); - expect(VestTest.isSkipped(testObject)).toBe(false); - expect(VestTest.isCanceled(testObject)).toBe(true); - VestTest.setPending(testObject); - expect(VestTest.isPending(testObject)).toBe(false); - expect(VestTest.isCanceled(testObject)).toBe(true); - control(); - })(); + vest + .create(() => { + // async so it is not a final status + const testObject = vest.test('f1', async () => { + await wait(100); + }); + VestTest.cancel(testObject); + expect(VestTest.isCanceled(testObject)).toBe(true); + VestTest.fail(testObject); + expect(VestTest.isCanceled(testObject)).toBe(true); + expect(VestTest.isFailing(testObject)).toBe(false); + VestTest.skip(testObject); + expect(VestTest.isSkipped(testObject)).toBe(false); + expect(VestTest.isCanceled(testObject)).toBe(true); + VestTest.setPending(testObject); + expect(VestTest.isPending(testObject)).toBe(false); + expect(VestTest.isCanceled(testObject)).toBe(true); + control(); + }) + .run(); expect(control).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/vest/src/core/test/__tests__/__snapshots__/test.test.ts.snap b/packages/vest/src/core/test/__tests__/__snapshots__/test.test.ts.snap index 3fa46eb27..e2a7326cd 100644 --- a/packages/vest/src/core/test/__tests__/__snapshots__/test.test.ts.snap +++ b/packages/vest/src/core/test/__tests__/__snapshots__/test.test.ts.snap @@ -22,15 +22,35 @@ exports[`Test Vest's \`test\` function > test params > creates a test without a "abortController": AbortController {}, "allowReorder": undefined, "children": [ + { + "$type": "Focused", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "focusMode": 0, + "match": [], + "matchAll": false, + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, [Circular], ], "data": { "optional": {}, + "resolver": [Function], + "tests": [ + [Circular], + ], }, "key": null, "keys": null, - "output": { - "done": [Function], + "output": SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -63,6 +83,7 @@ exports[`Test Vest's \`test\` function > test params > creates a test without a "warnings": [], }, }, + "types": undefined, "valid": true, "warnCount": 0, "warnings": [], @@ -93,17 +114,37 @@ exports[`Test Vest's \`test\` function > test params > creates a test without a "abortController": AbortController {}, "allowReorder": undefined, "children": [ + { + "$type": "Focused", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "focusMode": 0, + "match": [], + "matchAll": false, + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, [Circular], ], "data": { "optional": {}, + "resolver": [Function], + "tests": [ + [Circular], + ], }, "key": null, "keys": { "keyboardcat": [Circular], }, - "output": { - "done": [Function], + "output": SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -136,6 +177,7 @@ exports[`Test Vest's \`test\` function > test params > creates a test without a "warnings": [], }, }, + "types": undefined, "valid": true, "warnCount": 0, "warnings": [], @@ -166,15 +208,35 @@ exports[`Test Vest's \`test\` function > test params > creates a test without a "abortController": AbortController {}, "allowReorder": undefined, "children": [ + { + "$type": "Focused", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "focusMode": 0, + "match": [], + "matchAll": false, + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, [Circular], ], "data": { "optional": {}, + "resolver": [Function], + "tests": [ + [Circular], + ], }, "key": null, "keys": null, - "output": { - "done": [Function], + "output": SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -207,6 +269,7 @@ exports[`Test Vest's \`test\` function > test params > creates a test without a "warnings": [], }, }, + "types": undefined, "valid": true, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/core/test/__tests__/key.test.ts b/packages/vest/src/core/test/__tests__/key.test.ts index d694f2463..f37fc7484 100644 --- a/packages/vest/src/core/test/__tests__/key.test.ts +++ b/packages/vest/src/core/test/__tests__/key.test.ts @@ -45,8 +45,8 @@ describe('key', () => { count++; }); - const res1 = suite(); - const res2 = suite(); + const res1 = suite.run(); + const res2 = suite.run(); expect(res1.tests).toEqual(res2.tests); }); @@ -71,8 +71,8 @@ describe('key', () => { calls.push(currentCall); }); - const res1 = suite(); - const res2 = suite(); + const res1 = suite.run(); + const res2 = suite.run(); expect(calls[0][0]).toBe(calls[1][0]); expect(calls[0][1]).toBe(calls[1][1]); @@ -103,8 +103,8 @@ describe('key', () => { calls.push(currentCall); }); - const res1 = suite(); - const res2 = suite(); + const res1 = suite.run(); + const res2 = suite.run(); expect(deferThrow).toHaveBeenCalled(); @@ -205,7 +205,7 @@ describe('key', () => { vest.test('field2', () => false, 'key_1'); }); - suite(); + suite.run(); expect(deferThrow).toHaveBeenCalledWith( `Encountered the same key "key_1" twice. This may lead to inconsistent or overriding of results.`, ); @@ -236,18 +236,24 @@ describe('key', () => { ); }); - suite({ a: '', b: '' }).done(() => { - expect(suite.hasErrors('test_a')).toBe(true); - expect(suite.hasErrors('test_b')).toBe(true); - }); - suite({ a: 's', b: '' }, ['test_a']).done(() => { - expect(suite.hasErrors('test_a')).toBe(false); - expect(suite.hasErrors('test_b')).toBe(true); - }); - suite({ a: 's', b: 's' }, ['test_b']).done(() => { - expect(suite.hasErrors('test_a')).toBe(false); - expect(suite.hasErrors('test_b')).toBe(false); - }); + suite + .after(() => { + expect(suite.hasErrors('test_a')).toBe(true); + expect(suite.hasErrors('test_b')).toBe(true); + }) + .run({ a: '', b: '' }); + suite + .after(() => { + expect(suite.hasErrors('test_a')).toBe(false); + expect(suite.hasErrors('test_b')).toBe(true); + }) + .run({ a: 's', b: '' }, ['test_a']); + suite + .after(() => { + expect(suite.hasErrors('test_a')).toBe(false); + expect(suite.hasErrors('test_b')).toBe(false); + }) + .run({ a: 's', b: 's' }, ['test_b']); }); }); describe('Key with omitWhen', () => { @@ -265,15 +271,21 @@ describe('key', () => { }); }); - suite({ a: '' }, false).done(() => { - expect(suite.hasErrors('test_a')).toBe(true); - }); - suite({ a: '' }, false).done(() => { - expect(suite.hasErrors('test_a')).toBe(true); - }); - suite({ a: 's' }, true).done(() => { - expect(suite.hasErrors('test_a')).toBe(false); - }); + suite + .after(() => { + expect(suite.hasErrors('test_a')).toBe(true); + }) + .run({ a: '' }, false); + suite + .after(() => { + expect(suite.hasErrors('test_a')).toBe(true); + }) + .run({ a: '' }, false); + suite + .after(() => { + expect(suite.hasErrors('test_a')).toBe(false); + }) + .run({ a: 's' }, true); }); }); }); diff --git a/packages/vest/src/core/test/__tests__/memo.test.ts b/packages/vest/src/core/test/__tests__/memo.test.ts deleted file mode 100644 index f0afeeb52..000000000 --- a/packages/vest/src/core/test/__tests__/memo.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { TIsolateTest } from 'IsolateTest'; -import { Modes } from 'Modes'; -import { VestTest } from 'VestTest'; -import promisify from 'promisify'; -import { describe, it, expect, vi } from 'vitest'; -import wait from 'wait'; - -import { TestPromise } from '../../../testUtils/testPromise'; - -import * as vest from 'vest'; -import { test as vestTest, enforce } from 'vest'; - -describe('test.memo', () => { - describe('cache hit', () => { - it('Should return without calling callback', () => { - const cb1 = vi.fn(); - const cb2 = vi.fn(() => TestPromise(() => undefined)); - const suite = vest.create(() => { - vestTest.memo('f1', cb1, [1]); - vestTest.memo('f1', cb2, [2]); - }); - - suite(); - expect(cb1).toHaveBeenCalledTimes(1); - expect(cb2).toHaveBeenCalledTimes(1); - suite(); - expect(cb1).toHaveBeenCalledTimes(1); - expect(cb2).toHaveBeenCalledTimes(1); - }); - - it('Should produce correct initial result', () => { - const res = vest.create(() => { - vest.mode(Modes.ALL); - vestTest.memo('field1', 'msg1', () => false, [{}]); - vestTest.memo('field1', 'msg2', () => undefined, [{}]); - vestTest.memo('field2', () => undefined, [{}]); - vestTest.memo( - 'field3', - () => { - vest.warn(); - return false; - }, - [{}], - ); - })(); - - expect(res.hasErrors('field1')).toBe(true); - expect(res.hasErrors('field2')).toBe(false); - expect(res.hasWarnings('field3')).toBe(true); - expect(res).toMatchSnapshot(); - }); - describe('sync', () => { - it('Should restore previous result on re-run', () => { - const suite = vest.create(() => { - vest.mode(Modes.ALL); - vestTest.memo('field1', 'msg1', () => false, [1]); - vestTest.memo('field1', 'msg2', () => undefined, [2]); - vestTest.memo('field2', () => undefined, [3]); - vestTest.memo( - 'field3', - () => { - vest.warn(); - return false; - }, - [4], - ); - }); - - const res = suite(); - - expect(res.hasErrors('field1')).toBe(true); - expect(res.hasErrors('field2')).toBe(false); - expect(res.hasWarnings('field3')).toBe(true); - expect(res).toMatchSnapshot(); - - const res2 = suite(); - expect(res2.hasErrors('field1')).toBe(true); - expect(res2.hasErrors('field2')).toBe(false); - expect(res2.hasWarnings('field3')).toBe(true); - expect(res).isDeepCopyOf(res2); - }); - }); - - describe('async', () => { - it('Should immediately return previous result on re-run', async () => { - { - const suite = vest.create(() => { - vestTest.memo( - 'field1', - async () => { - await wait(500); - enforce(1).equals(2); - }, - [1], - ); - vestTest.memo( - 'field2', - async () => { - await wait(500); - enforce(1).equals(2); - }, - [2], - ); - }); - - const asyncSuite = promisify(suite); - - let start = Date.now(); - const res1 = await asyncSuite(); - enforce(Date.now() - start).gte(500); - - start = Date.now(); - const res2 = suite(); - - expect(res1).isDeepCopyOf(res2); - } - }); - }); - - describe('Test is canceled', () => { - it('Should refresh', async () => { - let count = 0; - const tests: TIsolateTest[] = []; - const suite = vest.create(() => { - count++; - - tests.push( - vestTest.memo( - 'f1', - async () => { - await wait(10); - }, - [true], - ), - ); - - if (count === 1) { - VestTest.cancel(tests[0]); - } - }); - - suite(); - suite(); - suite(); - - expect(tests[0]).not.toBe(tests[1]); - expect(tests[1]).toBe(tests[2]); - }); - }); - }); - - describe('cache miss', () => { - it('Should run test normally', () => { - const cb1 = vi.fn(res => res); - const cb2 = vi.fn( - res => new Promise((resolve, rej) => (res ? resolve() : rej())), - ); - const suite = vest.create((key, res) => { - vestTest.memo('f1', () => cb1(res), [1, key]); - vestTest.memo('f2', () => cb2(res), [2, key]); - }); - - expect(cb1).toHaveBeenCalledTimes(0); - expect(cb2).toHaveBeenCalledTimes(0); - suite('a', false); - expect(cb1).toHaveBeenCalledTimes(1); - expect(cb2).toHaveBeenCalledTimes(1); - expect(suite.get().hasErrors()).toBe(true); - suite('b', true); - expect(cb1).toHaveBeenCalledTimes(2); - expect(cb2).toHaveBeenCalledTimes(2); - expect(suite.get().hasErrors()).toBe(false); - }); - }); - - describe('Collision detection', () => { - describe('cross-field collision', () => { - it('Should factor in field name', () => { - const suite = vest.create(() => { - vestTest.memo('f1', () => false, [1]); - vestTest.memo('f2', () => true, [1]); - }); - - suite(); - suite(); - expect(suite.get().hasErrors('f1')).toBe(true); - expect(suite.get().hasErrors('f2')).toBe(false); - }); - }); - - describe('same-field-same-suite collision', () => { - it('Should factor in execution order', () => { - const suite = vest.create(() => { - vestTest.memo('f1', () => false, [1]); - vestTest.memo('f1', () => true, [1]); - }); - - suite(); - suite(); - expect(suite.get().hasErrors('f1')).toBe(true); - expect(suite.get().errorCount).toBe(1); - }); - }); - describe('cross-suite collision', () => { - it('Should factor in field name', () => { - const suite1 = vest.create(() => { - vestTest.memo('f1', () => false, [1]); - vestTest.memo('f2', () => true, [1]); - }); - const suite2 = vest.create(() => { - vestTest.memo('f1', () => true, [1]); - vestTest.memo('f2', () => false, [1]); - }); - - suite1(); - suite2(); - expect(suite1.get().hasErrors('f1')).toBe(true); - expect(suite1.get().hasErrors('f2')).toBe(false); - expect(suite2.get().hasErrors('f1')).toBe(false); - expect(suite2.get().hasErrors('f2')).toBe(true); - }); - }); - }); -}); diff --git a/packages/vest/src/core/test/__tests__/merging_of_previous_test_runs.test.ts b/packages/vest/src/core/test/__tests__/merging_of_previous_test_runs.test.ts index ae3851dc6..9f0071989 100644 --- a/packages/vest/src/core/test/__tests__/merging_of_previous_test_runs.test.ts +++ b/packages/vest/src/core/test/__tests__/merging_of_previous_test_runs.test.ts @@ -42,8 +42,8 @@ describe('Merging of previous test runs', () => { counter++; }); - const resA = suite(); - const resB = suite(); + const resA = suite.run(); + const resB = suite.run(); const [testsA, testsB] = testContainer; @@ -66,12 +66,12 @@ describe('Merging of previous test runs', () => { counter++; }); - const resA = suite(); + const resA = suite.run(); // Checking that the result is correct expect(resA.isValid('f1')).toBe(true); expect(resA.isValid('f2')).toBe(false); - const resB = suite(); + const resB = suite.run(); // Checking that the result is correct expect(resB.isValid('f1')).toBe(false); expect(resA.isValid('f2')).toBe(false); @@ -97,10 +97,10 @@ describe('Merging of previous test runs', () => { counter++; }); - suite(); + suite.run(); expect(deferThrow).not.toHaveBeenCalled(); - suite(); + suite.run(); expect(deferThrow).toHaveBeenCalledWith( expect.stringContaining( 'Vest Critical Error: Tests called in different order than previous run.', @@ -119,7 +119,7 @@ describe('Merging of previous test runs', () => { counter++; }); - const resA = suite(); + const resA = suite.run(); expect(resA.tests.f2).toBeDefined(); expect(resA.hasErrors('f1')).toBe(true); expect(resA.hasErrors('f2')).toBe(true); @@ -156,7 +156,7 @@ describe('Merging of previous test runs', () => { } `); - const resB = suite(); + const resB = suite.run(); expect(resB.tests.f2).toBeUndefined(); expect(resB.hasErrors('f1')).toBe(true); expect(resB.hasErrors('f2')).toBe(false); @@ -204,7 +204,7 @@ describe('Merging of previous test runs', () => { counter++; }); - const resA = suite(); + const resA = suite.run(); expect(resA.tests.f2).toBeDefined(); expect(resA.tests.f3).toBeDefined(); expect(resA.tests.f5).toBeDefined(); @@ -284,7 +284,7 @@ describe('Merging of previous test runs', () => { }, } `); - const resB = suite(); + const resB = suite.run(); expect(resB.tests.f2).toBeUndefined(); expect(resB.tests.f3).toBeUndefined(); expect(resB.tests.f5).toBeUndefined(); @@ -341,14 +341,14 @@ describe('Merging of previous test runs', () => { counter++; }); - const resA = suite(); + const resA = suite.run(); expect(resA.hasErrors('f4')).toBe(true); expect(resA.hasErrors('f5')).toBe(true); // This is testing that fact that the next test in line after f2 and f3 // got removed. We can see it because in normal situation, the test result is // merged into the next test result. - const resB = suite(); + const resB = suite.run(); expect(resB.hasErrors('f4')).toBe(false); expect(resB.hasErrors('f5')).toBe(false); }); diff --git a/packages/vest/src/core/test/__tests__/runAsyncTest.test.ts b/packages/vest/src/core/test/__tests__/runAsyncTest.test.ts index f7e062a6e..7e38b2460 100644 --- a/packages/vest/src/core/test/__tests__/runAsyncTest.test.ts +++ b/packages/vest/src/core/test/__tests__/runAsyncTest.test.ts @@ -1,8 +1,8 @@ -import { TIsolateTest } from 'IsolateTest'; -import { VestTest } from 'VestTest'; import { describe, it, expect, vi } from 'vitest'; import wait from 'wait'; +import { TIsolateTest } from 'IsolateTest'; +import { VestTest } from 'VestTest'; import * as vest from 'vest'; describe('runAsyncTest', () => { @@ -14,7 +14,7 @@ describe('runAsyncTest', () => { await wait(100); }); }); - suite(); + suite.run(); testObject = VestTest.cast(testObject); @@ -33,22 +33,26 @@ describe('runAsyncTest', () => { const suite = vest.create(() => { vest.test('field_1', async () => { - await wait(100); + await wait(50); }); vest.test('field_2', () => {}); vest.test('field_3', async () => { - await wait(50); + await wait(100); }); }); - suite().done(cb1).done(cb2).done('field_1', cb3); + suite + .after(cb1) + .afterField('field_1', cb2) + .afterField('field_3', cb3) + .run(); - expect(cb1).not.toHaveBeenCalled(); + expect(cb1).toHaveBeenCalled(); expect(cb2).not.toHaveBeenCalled(); expect(cb3).not.toHaveBeenCalled(); await wait(50); - expect(cb1).not.toHaveBeenCalled(); - expect(cb2).not.toHaveBeenCalled(); + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); expect(cb3).not.toHaveBeenCalled(); await wait(50); expect(cb1).toHaveBeenCalled(); @@ -58,7 +62,7 @@ describe('runAsyncTest', () => { }); describe('When there are remaining pending tests', () => { - it('Should only run callbacks for completed tests', async () => { + it('Should only run field callbacks for completed tests', async () => { const cb1 = vi.fn(); const cb2 = vi.fn(); const cb3 = vi.fn(); @@ -73,13 +77,17 @@ describe('runAsyncTest', () => { }); }); - suite().done(cb1).done('field_2', cb2).done('field_3', cb3); + suite + .after(cb1) + .afterField('field_2', cb2) + .afterField('field_3', cb3) + .run(); - expect(cb1).not.toHaveBeenCalled(); + expect(cb1).toHaveBeenCalled(); expect(cb2).toHaveBeenCalled(); expect(cb3).not.toHaveBeenCalled(); await wait(50); - expect(cb1).not.toHaveBeenCalled(); + expect(cb1).toHaveBeenCalled(); expect(cb3).toHaveBeenCalled(); await wait(50); expect(cb1).toHaveBeenCalled(); @@ -89,7 +97,7 @@ describe('runAsyncTest', () => { }); describe('When the test run was canceled', () => { - it('Should not run the callbacks', async () => { + it('Should not run the field callbacks', async () => { const cb1 = vi.fn(); const cb2 = vi.fn(); const cb3 = vi.fn(); @@ -105,15 +113,18 @@ describe('runAsyncTest', () => { vest.test('field_2', () => {}); }); - suite().done(cb1).done(cb2).done('field_1', cb3); + suite + .afterField('field_1', cb1) + .afterField('field_1', cb2) + .afterField('field_1', cb3) + .run(); expect(cb1).not.toHaveBeenCalled(); expect(cb2).not.toHaveBeenCalled(); expect(cb3).not.toHaveBeenCalled(); - suite(); + suite.run(); - await wait(10); expect(cb1).not.toHaveBeenCalled(); expect(cb2).not.toHaveBeenCalled(); expect(cb3).not.toHaveBeenCalled(); @@ -129,7 +140,7 @@ describe('runAsyncTest', () => { await wait(100); }); }); - suite(); + suite.run(); testObject = VestTest.cast(testObject); @@ -146,7 +157,7 @@ describe('runAsyncTest', () => { throw new Error(''); }); }); - suite(); + suite.run(); testObject = VestTest.cast(testObject); @@ -164,7 +175,7 @@ describe('runAsyncTest', () => { throw new Error(''); }); }); - suite(); + suite.run(); testObject = VestTest.cast(testObject); diff --git a/packages/vest/src/core/test/__tests__/test.test.ts b/packages/vest/src/core/test/__tests__/test.test.ts index a96258033..d50515b08 100644 --- a/packages/vest/src/core/test/__tests__/test.test.ts +++ b/packages/vest/src/core/test/__tests__/test.test.ts @@ -17,56 +17,64 @@ describe("Test Vest's `test` function", () => { describe('test callbacks', () => { describe('Warn hook', () => { it('Should be marked as warning when the warn hook gets called', () => { - vest.create(() => { - testObject = vest.test( - faker.lorem.word(), - faker.lorem.sentence(), - () => { - vest.warn(); - }, - ); - })(); + vest + .create(() => { + testObject = vest.test( + faker.lorem.word(), + faker.lorem.sentence(), + () => { + vest.warn(); + }, + ); + }) + .run(); expect(VestTest.warns(testObject)).toBe(true); }); }); describe('Sync', () => { it('Should be marked as failed after a thrown error', () => { - vest.create(() => { - testObject = vest.test( - faker.lorem.word(), - faker.lorem.sentence(), - () => { - throw new Error(); - }, - ); - })(); + vest + .create(() => { + testObject = vest.test( + faker.lorem.word(), + faker.lorem.sentence(), + () => { + throw new Error(); + }, + ); + }) + .run(); expect(VestTest.isFailing(testObject)).toBe(true); }); it('Should be marked as failed for an explicit false return', () => { - vest.create(() => { - vest.test(faker.lorem.word(), faker.lorem.sentence(), () => false); - })(); + vest + .create(() => { + vest.test(faker.lorem.word(), faker.lorem.sentence(), () => false); + }) + .run(); expect(VestTest.isFailing(testObject)).toBe(true); }); describe('Thrown with a message', () => { describe('When field has a message', () => { it("Should use field's own message", () => { - const res = vest.create(() => { - vest.test('field_with_message', 'some_field_message', () => { - failWithString(); - }); - vest.test( - 'warning_field_with_message', - 'some_field_message', - () => { - vest.warn(); + const res = vest + .create(() => { + vest.test('field_with_message', 'some_field_message', () => { failWithString(); - }, - ); - })(); + }); + vest.test( + 'warning_field_with_message', + 'some_field_message', + () => { + vest.warn(); + failWithString(); + }, + ); + }) + .run(); expect(res.getErrors('field_with_message')).toEqual([ 'some_field_message', @@ -84,26 +92,30 @@ describe("Test Vest's `test` function", () => { }); describe('When field does not have a message', () => { it('Should use message from enforce().message()', () => { - const res = vest.create(() => { - vest.test('field_without_message', () => { - enforce(100).message('some_field_message').equals(0); - }); - })(); + const res = vest + .create(() => { + vest.test('field_without_message', () => { + enforce(100).message('some_field_message').equals(0); + }); + }) + .run(); expect(res.getErrors('field_without_message')).toEqual([ 'some_field_message', ]); }); it('Should use message from thrown error', () => { - const res = vest.create(() => { - vest.test('field_without_message', () => { - failWithString(); - }); - vest.test('warning_field_without_message', () => { - vest.warn(); - failWithString(); - }); - })(); + const res = vest + .create(() => { + vest.test('field_without_message', () => { + failWithString(); + }); + vest.test('warning_field_without_message', () => { + vest.warn(); + failWithString(); + }); + }) + .run(); expect(res.getErrors('field_without_message')).toEqual([ 'I fail with a message', @@ -125,22 +137,24 @@ describe("Test Vest's `test` function", () => { describe('async', () => { it('Should be marked as failed when a returned promise rejects', () => TestPromise(done => { - vest.create(() => { - testObject = vest.test( - faker.lorem.word(), - faker.lorem.sentence(), - () => - new Promise((_, reject) => { - expect(VestTest.isFailing(testObject)).toBe(false); - setTimeout(reject, 300); - }), - ); - expect(VestTest.isFailing(testObject)).toBe(false); - setTimeout(() => { - expect(VestTest.isFailing(testObject)).toBe(true); - done(); - }, 310); - })(); + vest + .create(() => { + testObject = vest.test( + faker.lorem.word(), + faker.lorem.sentence(), + () => + new Promise((_, reject) => { + expect(VestTest.isFailing(testObject)).toBe(false); + setTimeout(reject, 300); + }), + ); + expect(VestTest.isFailing(testObject)).toBe(false); + setTimeout(() => { + expect(VestTest.isFailing(testObject)).toBe(true); + done(); + }, 310); + }) + .run(); })); }); }); @@ -148,9 +162,11 @@ describe("Test Vest's `test` function", () => { describe('test params', () => { let testObject: TIsolateTest; it('creates a test without a message and without a key', () => { - vest.create(() => { - testObject = vest.test('field_name', () => undefined); - })(); + vest + .create(() => { + testObject = vest.test('field_name', () => undefined); + }) + .run(); expect(testObject.data.fieldName).toBe('field_name'); expect(testObject.key).toBeNull(); expect(testObject.data.message).toBeUndefined(); @@ -158,13 +174,15 @@ describe("Test Vest's `test` function", () => { }); it('creates a test without a key', () => { - vest.create(() => { - testObject = vest.test( - 'field_name', - 'failure message', - () => undefined, - ); - })(); + vest + .create(() => { + testObject = vest.test( + 'field_name', + 'failure message', + () => undefined, + ); + }) + .run(); expect(testObject.data.fieldName).toBe('field_name'); expect(testObject.key).toBeNull(); expect(testObject.data.message).toBe('failure message'); @@ -172,9 +190,11 @@ describe("Test Vest's `test` function", () => { }); it('creates a test without a message and with a key', () => { - vest.create(() => { - testObject = vest.test('field_name', () => undefined, 'keyboardcat'); - })(); + vest + .create(() => { + testObject = vest.test('field_name', () => undefined, 'keyboardcat'); + }) + .run(); expect(testObject.data.fieldName).toBe('field_name'); expect(testObject.key).toBe('keyboardcat'); expect(testObject.data.message).toBeUndefined(); @@ -182,14 +202,16 @@ describe("Test Vest's `test` function", () => { }); it('creates a test with a message and with a key', () => { - vest.create(() => { - testObject = vest.test( - 'field_name', - 'failure message', - () => undefined, - 'keyboardcat', - ); - })(); + vest + .create(() => { + testObject = vest.test( + 'field_name', + 'failure message', + () => undefined, + 'keyboardcat', + ); + }) + .run(); expect(testObject.data.fieldName).toBe('field_name'); expect(testObject.key).toBe('keyboardcat'); expect(testObject.data.message).toBe('failure message'); @@ -198,67 +220,73 @@ describe("Test Vest's `test` function", () => { it('throws when field name is not a string', () => { const control = vi.fn(); - vest.create(() => { - // @ts-ignore - expect(() => vest.test(undefined, () => undefined)).toThrow( - text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { - fn_name: 'test', - param: 'fieldName', - expected: 'string', - }), - ); - // @ts-expect-error - expect(() => vest.test(null, 'error message', () => undefined)).toThrow( - text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { - fn_name: 'test', - param: 'fieldName', - expected: 'string', - }), - ); - expect(() => + vest + .create(() => { + // @ts-ignore + expect(() => vest.test(undefined, () => undefined)).toThrow( + text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { + fn_name: 'test', + param: 'fieldName', + expected: 'string', + }), + ); // @ts-expect-error - vest.test(null, 'error message', () => undefined, 'key'), - ).toThrow( - text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { - fn_name: 'test', - param: 'fieldName', - expected: 'string', - }), - ); - control(); - })(); + expect(() => + vest.test(null, 'error message', () => undefined), + ).toThrow( + text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { + fn_name: 'test', + param: 'fieldName', + expected: 'string', + }), + ); + expect(() => + // @ts-expect-error + vest.test(null, 'error message', () => undefined, 'key'), + ).toThrow( + text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { + fn_name: 'test', + param: 'fieldName', + expected: 'string', + }), + ); + control(); + }) + .run(); expect(control).toHaveBeenCalled(); }); it('throws when callback is not a function', () => { const control = vi.fn(); - vest.create(() => { - // @ts-expect-error - expect(() => vest.test('x')).toThrow( - text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { - fn_name: 'test', - param: 'callback', - expected: 'function', - }), - ); - // @ts-expect-error - expect(() => vest.test('x', 'msg', undefined)).toThrow( - text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { - fn_name: 'test', - param: 'callback', - expected: 'function', - }), - ); - // @ts-expect-error - expect(() => vest.test('x', 'msg', undefined, 'key')).toThrow( - text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { - fn_name: 'test', - param: 'callback', - expected: 'function', - }), - ); - control(); - })(); + vest + .create(() => { + // @ts-expect-error + expect(() => vest.test('x')).toThrow( + text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { + fn_name: 'test', + param: 'callback', + expected: 'function', + }), + ); + // @ts-expect-error + expect(() => vest.test('x', 'msg', undefined)).toThrow( + text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { + fn_name: 'test', + param: 'callback', + expected: 'function', + }), + ); + // @ts-expect-error + expect(() => vest.test('x', 'msg', undefined, 'key')).toThrow( + text(ErrorStrings.INVALID_PARAM_PASSED_TO_FUNCTION, { + fn_name: 'test', + param: 'callback', + expected: 'function', + }), + ); + control(); + }) + .run(); expect(control).toHaveBeenCalled(); }); }); diff --git a/packages/vest/src/core/test/__tests__/testFunctionPayload.test.ts b/packages/vest/src/core/test/__tests__/testFunctionPayload.test.ts index 396eca5e1..5f0062e5a 100644 --- a/packages/vest/src/core/test/__tests__/testFunctionPayload.test.ts +++ b/packages/vest/src/core/test/__tests__/testFunctionPayload.test.ts @@ -11,7 +11,7 @@ describe('Test Function Payload', () => { vest.test('field_1', testFnSync); vest.test('field_2', testFnAsync); }); - suite(); + suite.run(); expect(callPayload(testFnSync).signal).toBeInstanceOf(AbortSignal); expect(callPayload(testFnAsync).signal).toBeInstanceOf(AbortSignal); @@ -23,7 +23,7 @@ describe('Test Function Payload', () => { const suite = vest.create(() => { vest.test('field_1', testFn); }); - suite(); + suite.run(); await expect(callPayload(testFn).signal.aborted).toBe(false); }); @@ -35,8 +35,8 @@ describe('Test Function Payload', () => { const suite = vest.create(() => { vest.test('field_1', testFn); }); - suite(); - suite(); + suite.run(); + suite.run(); await expect(callPayload(testFn).signal.aborted).toBe(true); await expect(callPayload(testFn, 1, 0).signal.aborted).toBe(false); @@ -47,8 +47,8 @@ describe('Test Function Payload', () => { const suite = vest.create(() => { vest.test('field_1', testFn); }); - suite(); - suite(); + suite.run(); + suite.run(); await expect(callPayload(testFn).signal.reason).toBe('CANCELED'); }); @@ -66,8 +66,8 @@ describe('Test Function Payload', () => { vest.test('field_2', testFn2); }); - suite(); - suite('field_1'); + suite.run(); + suite.run('field_1'); await expect(callPayload(testFn1).signal.aborted).toBe(true); expect(callPayload(testFn2).signal.aborted).toBe(false); diff --git a/packages/vest/src/core/test/helpers/matchingGroupName.ts b/packages/vest/src/core/test/helpers/matchingGroupName.ts deleted file mode 100644 index 75ba66d72..000000000 --- a/packages/vest/src/core/test/helpers/matchingGroupName.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Maybe, bindNot } from 'vest-utils'; - -import { TIsolateTest } from 'IsolateTest'; -import { TGroupName } from 'SuiteResultTypes'; -import { VestTest } from 'VestTest'; - -export const nonMatchingGroupName = bindNot(matchingGroupName); - -export function matchingGroupName( - testObject: TIsolateTest, - groupName: Maybe, -): boolean { - return VestTest.getData(testObject).groupName === groupName; -} diff --git a/packages/vest/src/core/test/test.memo.ts b/packages/vest/src/core/test/test.memo.ts deleted file mode 100644 index 65a07bebf..000000000 --- a/packages/vest/src/core/test/test.memo.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { CB, isNull } from 'vest-utils'; -import { VestRuntime } from 'vestjs-runtime'; - -import { TIsolateTest } from 'IsolateTest'; -import * as Runtime from 'Runtime'; -import { useTestMemoCache } from 'SuiteContext'; -import { TFieldName } from 'SuiteResultTypes'; -import { TestFn } from 'TestTypes'; -import { VestTest } from 'VestTest'; -import { VTest } from 'test'; - -// @vx-allow use-use -export function wrapTestMemo(test: VTest): TestMemo { - /** - * Caches a test result based on the test's dependencies. - */ - function memo( - fieldName: F, - ...args: ParametersWithoutMessage - ): TIsolateTest; - function memo( - fieldName: F, - ...args: ParametersWithMessage - ): TIsolateTest; - function memo( - fieldName: F, - ...args: ParamsOverload - ): TIsolateTest { - const [deps, testFn, msg] = args.reverse() as [any[], TestFn, string]; - - // Implicit dependency for better specificity - const dependencies = [ - Runtime.useSuiteId(), - fieldName, - VestRuntime.useCurrentCursor(), - ].concat(deps); - - return useGetTestFromCache(dependencies, cacheAction); - - function cacheAction() { - return test(fieldName, msg, testFn); - } - } - - return memo; -} - -function useGetTestFromCache( - dependencies: any[], - cacheAction: CB, -): TIsolateTest { - const cache = useTestMemoCache(); - - const cached = cache.get(dependencies); - - if (isNull(cached)) { - // cache miss - return cache(dependencies, cacheAction); - } - - const [, cachedValue] = cached; - - if (VestTest.isCanceled(cachedValue)) { - // cache hit, but test is canceled - cache.invalidate(dependencies); - return cache(dependencies, cacheAction); - } - - // FIXME:(@ealush 2024-08-12): This is some kind of a hack. Instead organically letting Vest set the next - // child of the isolate, we're forcing it from the outside. - // Instead, an ideal solution would probably be to have test.memo be its own isolate - // that just injects a historic output from a previous test run. - VestRuntime.useSetNextIsolateChild(cachedValue); - - return cachedValue; -} - -export type TestMemo = { - (fieldName: F, ...args: ParametersWithoutMessage): TIsolateTest; - (fieldName: F, ...args: ParametersWithMessage): TIsolateTest; -}; - -type ParametersWithoutMessage = [test: TestFn, dependencies: unknown[]]; -type ParametersWithMessage = [ - message: string, - test: TestFn, - dependencies: unknown[], -]; - -type ParamsOverload = ParametersWithoutMessage | ParametersWithMessage; diff --git a/packages/vest/src/core/test/test.ts b/packages/vest/src/core/test/test.ts index e8a242cfe..d526abf43 100644 --- a/packages/vest/src/core/test/test.ts +++ b/packages/vest/src/core/test/test.ts @@ -1,13 +1,11 @@ -import { assign, invariant, isFunction, isStringValue, text } from 'vest-utils'; +import { invariant, isFunction, isStringValue, text } from 'vest-utils'; import { Bus, IsolateKey } from 'vestjs-runtime'; import { ErrorStrings } from 'ErrorStrings'; import { IsolateTest, TIsolateTest } from 'IsolateTest'; -import { useGroupName } from 'SuiteContext'; import { TFieldName } from 'SuiteResultTypes'; import { TestFn } from 'TestTypes'; import { useAttemptRunTest } from 'runTest'; -import { wrapTestMemo } from 'test.memo'; function vestTest( fieldName: F, @@ -26,7 +24,6 @@ function vestTest( cb: TestFn, key: IsolateKey, ): TIsolateTest; -// @vx-allow use-use function vestTest( fieldName: F, ...args: @@ -41,9 +38,7 @@ function vestTest( validateTestParams(fieldName, testFn); - const groupName = useGroupName(); - - const testObjectInput = { fieldName, groupName, message, testFn }; + const testObjectInput = { fieldName, message, testFn }; // This invalidates the suite cache. Bus.useEmit('TEST_RUN_STARTED'); @@ -51,11 +46,7 @@ function vestTest( return IsolateTest(useAttemptRunTest, testObjectInput, key); } -export const test = assign(vestTest, { - memo: wrapTestMemo(vestTest), -}); - -export type VTest = typeof vestTest; +export const test = vestTest; function validateTestParams(fieldName: string, testFn: TestFn): void { const fnName = 'test'; diff --git a/packages/vest/src/core/test/testLevelFlowControl/runTest.ts b/packages/vest/src/core/test/testLevelFlowControl/runTest.ts index ffff5ac8b..8f72dff0a 100644 --- a/packages/vest/src/core/test/testLevelFlowControl/runTest.ts +++ b/packages/vest/src/core/test/testLevelFlowControl/runTest.ts @@ -9,7 +9,6 @@ import { VestTest } from 'VestTest'; import { shouldUseErrorAsMessage } from 'shouldUseErrorMessage'; import { useVerifyTestRun } from 'verifyTestRun'; -// eslint-disable-next-line max-statements export function useAttemptRunTest(testObject: TIsolateTest) { useVerifyTestRun(testObject); diff --git a/packages/vest/src/errors/ErrorStrings.ts b/packages/vest/src/errors/ErrorStrings.ts index 3e5988a65..65e48b970 100644 --- a/packages/vest/src/errors/ErrorStrings.ts +++ b/packages/vest/src/errors/ErrorStrings.ts @@ -3,7 +3,6 @@ export enum ErrorStrings { EXPECTED_VEST_TEST = 'Expected value to be an instance of IsolateTest', FIELD_NAME_REQUIRED = 'Field name must be passed', SUITE_MUST_BE_INITIALIZED_WITH_FUNCTION = 'Suite must be initialized with a function', - PROMISIFY_REQUIRE_FUNCTION = 'Vest.Promisify must be called with a function', PARSER_EXPECT_RESULT_OBJECT = "Vest parser: expected argument at position 0 to be Vest's result object.", WARN_MUST_BE_CALLED_FROM_TEST = 'Warn must be called from within the body of a test function', EACH_CALLBACK_MUST_BE_A_FUNCTION = 'Each must be called with a function', diff --git a/packages/vest/src/examples/schemaTypeEnforcement.examples.ts b/packages/vest/src/examples/schemaTypeEnforcement.examples.ts new file mode 100644 index 000000000..fe50f1d17 --- /dev/null +++ b/packages/vest/src/examples/schemaTypeEnforcement.examples.ts @@ -0,0 +1,172 @@ +/** + * TypeScript Type Enforcement Examples + * + * This file demonstrates that TypeScript properly enforces types + * when using schemas with createSuite. Uncomment the error examples + * to see TypeScript compilation errors. + */ + +import { enforce } from 'n4s'; +import { create, test } from 'vest'; + +// ✅ CORRECT: Data matches schema +const userSchema = enforce.shape({ + username: enforce.isString(), + age: enforce.isNumber(), + email: enforce.isString(), +}); + +const validSuite = create(data => { + // TypeScript knows the exact shape of data: + // data.username: string + // data.age: number + // data.email: string + + test('username', () => { + enforce(data.username).isNotEmpty(); + }); + + test('age', () => { + enforce(data.age).greaterThan(0); + }); +}, userSchema); + +// ✅ This works - data matches schema +validSuite.run({ + username: 'john', + age: 30, + email: 'john@example.com', +}); + +// ❌ UNCOMMENT TO SEE ERROR: Missing required field +// validSuite.run({ +// username: 'john', +// age: 30, +// // Missing 'email' - TypeScript error! +// }); + +// ❌ UNCOMMENT TO SEE ERROR: Wrong type +// validSuite.run({ +// username: 'john', +// age: '30', // Should be number, not string - TypeScript error! +// email: 'john@example.com', +// }); + +// ❌ UNCOMMENT TO SEE ERROR: Extra field not in schema +// validSuite.run({ +// username: 'john', +// age: 30, +// email: 'john@example.com', +// extra: 'not allowed', // TypeScript error for strict shape! +// }); + +// ✅ LOOSE SCHEMA: Allows extra properties +const looseSchema = enforce.loose({ + id: enforce.isNumber(), + name: enforce.isString(), +}); + +const looseSuite = create(data => { + // TypeScript knows about id and name + test('id', () => { + enforce(data.id).isNumber(); + }); +}, looseSchema); + +// ✅ This works - extra properties allowed with loose +looseSuite.run({ + id: 1, + name: 'Test', + extra: 'This is fine with loose schema', + another: 42, +}); + +// ✅ NESTED SCHEMAS: Full type inference +const addressSchema = enforce.shape({ + street: enforce.isString(), + city: enforce.isString(), + zipCode: enforce.isString(), +}); + +const personSchema = enforce.shape({ + name: enforce.isString(), + address: addressSchema, +}); + +const nestedSuite = create(data => { + // TypeScript knows the full nested structure + // data.name: string + // data.address.street: string + // data.address.city: string + // data.address.zipCode: string + + test('city', () => { + enforce(data.address.city).isNotEmpty(); + }); +}, personSchema); + +// ✅ This works +nestedSuite.run({ + name: 'John', + address: { + street: '123 Main St', + city: 'Springfield', + zipCode: '12345', + }, +}); + +// ❌ UNCOMMENT TO SEE ERROR: Wrong nested type +// nestedSuite.run({ +// name: 'John', +// address: { +// street: '123 Main St', +// city: 12345, // Should be string, not number - TypeScript error! +// zipCode: '12345', +// }, +// }); + +// ❌ Should fail: callback parameter not typed from schema +const schema = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), +}); + +const suite = create(data => { + // @ts-expect-error: data should be strictly typed + data.nonexistent; + test('name', () => { + enforce(data.name).isNotEmpty(); + }); +}, schema); + +// ❌ Should fail: run accepts wrong shape +// @ts-expect-error: missing required argument +suite.run(); +// @ts-expect-error: empty object does not satisfy schema +suite.run({}); +// @ts-expect-error: missing age +suite.run({ name: 'John' }); +// @ts-expect-error: age wrong type +suite.run({ name: 'John', age: 'not a number' }); +// @ts-expect-error: unexpected key +suite.run({ number: 'john' }); + +// ✅ Should pass: correct shape +suite.run({ name: 'John', age: 42 }); + +// ✅ Should allow generic typing when no schema +const suite2 = create<{ foo: string; bar: number }>(data => { + test('foo', () => { + enforce(data.foo).isNotEmpty(); + }); + test('bar', () => { + enforce(data.bar).greaterThan(0); + }); +}); + +// @ts-expect-error: missing bar +suite2.run({ foo: 'baz' }); +// ✅ Should pass +suite2.run({ foo: 'baz', bar: 1 }); + +export { validSuite, looseSuite, nestedSuite }; diff --git a/packages/vest/src/exports/SuiteSerializer.ts b/packages/vest/src/exports/SuiteSerializer.ts index 810e905ca..6bceee572 100644 --- a/packages/vest/src/exports/SuiteSerializer.ts +++ b/packages/vest/src/exports/SuiteSerializer.ts @@ -1,13 +1,12 @@ import { CB } from 'vest-utils'; -import { IsolateSerializer } from 'vestjs-runtime'; -import { IsolateKeys } from 'vestjs-runtime/src/Isolate/IsolateKeys'; +import { IsolateSerializer, IsolateKeys } from 'vestjs-runtime'; import { TIsolateSuite } from 'IsolateSuite'; import { TestStatus } from 'IsolateTestStateMachine'; import { TFieldName, TGroupName } from 'SuiteResultTypes'; import { Suite } from 'SuiteTypes'; -export type Dumpable = { +type Dumpable = { dump: CB; }; @@ -60,4 +59,10 @@ const AllowedStatuses = new Set([ TestStatus.WARNING, ]); -const DisallowedKeys = new Set(['focusMode', 'match', 'matchAll', 'severity']); +const DisallowedKeys = new Set([ + 'focusMode', + 'match', + 'matchAll', + 'severity', + 'tests', +]); diff --git a/packages/vest/src/exports/__tests__/SuiteSerializer.test.ts b/packages/vest/src/exports/__tests__/SuiteSerializer.test.ts index aad2216ec..cd582f7af 100644 --- a/packages/vest/src/exports/__tests__/SuiteSerializer.test.ts +++ b/packages/vest/src/exports/__tests__/SuiteSerializer.test.ts @@ -4,8 +4,8 @@ import { SuiteSerializer } from 'SuiteSerializer'; import * as vest from 'vest'; describe('SuiteSerializer', () => { - it('Should produce a valid serialized dump', () => { - const suite = vest.create('suite_serialize_test', () => { + it('should produce a valid serialized dump', () => { + const suite = vest.create(() => { vest.only('field_1'); vest.test('field_1', 'field_1_message', () => false); @@ -21,7 +21,7 @@ describe('SuiteSerializer', () => { vest.test('field_5', 'field_5_message', () => false); }); }); - suite(); + suite.run(); const serialized = SuiteSerializer.serialize(suite); expect(serialized).toMatchSnapshot(); @@ -29,7 +29,7 @@ describe('SuiteSerializer', () => { }); describe('suite.resume', () => { - it('Should resume a suite from a serialized dump', () => { + it('should resume a suite from a serialized dump', () => { const suite = vest.create(() => { vest.only('field_1'); @@ -47,13 +47,13 @@ describe('suite.resume', () => { }); }); - suite(); + suite.run(); const serialized = SuiteSerializer.serialize(suite); const suite2 = vest.create(() => {}); - suite2(); + suite2.run(); expect(suite.get()).not.toEqual(suite2.get()); @@ -64,7 +64,7 @@ describe('suite.resume', () => { expect(suite2.hasWarnings()).toBe(false); expect(suite2.get().tests.field_1).toBeDefined(); - suite2(); + suite2.run(); expect(suite.get()).not.toEqual(suite2.get()); expect(suite2.hasErrors()).toBe(false); expect(suite2.hasWarnings()).toBe(false); @@ -83,25 +83,25 @@ describe('suite.resume', () => { }); } - it('Should continue with resumed state if matching', () => { - const suite = vest.create('suite_resume_test', cb); + it('should continue with resumed state if the data matches', () => { + const suite = vest.create(cb); - suite({}); + suite.run({}); const serialized = SuiteSerializer.serialize(suite); const suite2 = vest.create(cb); SuiteSerializer.resume(suite2, serialized); - suite2({}, 'field_1'); + suite2.run({}, 'field_1'); expect(suite2.getError('field_1')).toBe('field_1_message'); expect(suite2.getError('field_2')).toBe('field_2_message'); }); - describe('Sanity - suite should run as expected', () => { - it('Should have correct state after resuming', () => { - const suite = vest.create('suite_resume_test', cb); + describe('sanity - suite should run as expected', () => { + it('should have the correct state after resuming', () => { + const suite = vest.create(cb); - suite({}); + suite.run({}); const serialized = SuiteSerializer.serialize(suite); diff --git a/packages/vest/src/exports/__tests__/__snapshots__/SuiteSerializer.test.ts.snap b/packages/vest/src/exports/__tests__/__snapshots__/SuiteSerializer.test.ts.snap index a3d1c5545..cafcc6dca 100644 --- a/packages/vest/src/exports/__tests__/__snapshots__/SuiteSerializer.test.ts.snap +++ b/packages/vest/src/exports/__tests__/__snapshots__/SuiteSerializer.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`SuiteSerializer > Should produce a valid serialized dump 1`] = `"[{"0":"Suite","1":{},"5":[{"0":"Focused","1":{}},{"0":"2","1":{"3":"field_1","4":"field_1_message"},"status":"FAILED"},{"0":"2","1":{"3":"field_2","4":"field_2_message"}},{"0":"Group","5":[{"0":"2","1":{"3":"8","4":"field_3_message_1","6":"7"}},{"0":"2","1":{"3":"8","4":"field_3_message_2","6":"7"}},{"0":"2","1":{"3":"field_4","4":"field_4_message","6":"7"}}]},{"0":"SkipWhen","5":[{"0":"2","1":{"3":"field_5","4":"field_5_message"}}]}]},{"0":"$type","1":"data","2":"Test","3":"fieldName","4":"message","5":"children","6":"groupName","7":"group_1","8":"field_3"}]"`; +exports[`SuiteSerializer > should produce a valid serialized dump 1`] = `"[{"0":"Suite","1":{},"5":[{"0":"6","1":{}},{"0":"6","1":{}},{"0":"2","1":{"3":"field_1","4":"field_1_message"},"status":"FAILED"},{"0":"2","1":{"3":"field_2","4":"field_2_message"}},{"0":"Group","1":{"groupName":"group_1"},"5":[{"0":"2","1":{"3":"7","4":"field_3_message_1"}},{"0":"2","1":{"3":"7","4":"field_3_message_2"}},{"0":"2","1":{"3":"field_4","4":"field_4_message"}}]},{"0":"SkipWhen","1":{},"5":[{"0":"2","1":{"3":"field_5","4":"field_5_message"}}]}]},{"0":"$type","1":"data","2":"Test","3":"fieldName","4":"message","5":"children","6":"Focused","7":"field_3"}]"`; -exports[`suite.resume > Running the suite after resuming > Sanity - suite should run as expected > Should have correct state after resuming 1`] = ` +exports[`suite.resume > Running the suite after resuming > sanity - suite should run as expected > should have the correct state after resuming 1`] = ` { "field_1": [ "field_1_message", diff --git a/packages/vest/src/core/test/__tests__/__snapshots__/memo.test.ts.snap b/packages/vest/src/exports/__tests__/__snapshots__/memo.test.ts.snap similarity index 91% rename from packages/vest/src/core/test/__tests__/__snapshots__/memo.test.ts.snap rename to packages/vest/src/exports/__tests__/__snapshots__/memo.test.ts.snap index ac111b588..329939247 100644 --- a/packages/vest/src/core/test/__tests__/__snapshots__/memo.test.ts.snap +++ b/packages/vest/src/exports/__tests__/__snapshots__/memo.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`test.memo > cache hit > Should produce correct initial result 1`] = ` -{ - "done": [Function], +exports[`memo > cache hit > Should produce correct initial result 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -61,6 +61,7 @@ exports[`test.memo > cache hit > Should produce correct initial result 1`] = ` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 1, "warnings": [ @@ -73,9 +74,9 @@ exports[`test.memo > cache hit > Should produce correct initial result 1`] = ` } `; -exports[`test.memo > cache hit > sync > Should restore previous result on re-run 1`] = ` -{ - "done": [Function], +exports[`memo > cache hit > sync > Should restore previous result on re-run 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -134,6 +135,7 @@ exports[`test.memo > cache hit > sync > Should restore previous result on re-run "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 1, "warnings": [ diff --git a/packages/vest/src/exports/__tests__/classnames.test.ts b/packages/vest/src/exports/__tests__/classnames.test.ts index fe998d1aa..c8cb8f25c 100644 --- a/packages/vest/src/exports/__tests__/classnames.test.ts +++ b/packages/vest/src/exports/__tests__/classnames.test.ts @@ -1,15 +1,14 @@ -import { Modes } from 'Modes'; import { describe, it, expect, vi } from 'vitest'; import { dummyTest } from '../../testUtils/testDummy'; import classnames from '../classnames'; -import promisify from '../promisify'; +import { Modes } from 'Modes'; import * as vest from 'vest'; describe('Utility: classnames', () => { describe('When called without a vest result object', () => { - it('Should throw an error', () => { + it('should throw an error', () => { expect(classnames).toThrow(); // @ts-expect-error - testing invalid input expect(() => classnames({})).toThrow(); @@ -21,25 +20,25 @@ describe('Utility: classnames', () => { }); describe('When called with a vest result object', () => { - it('Should return a function', async () => { - const validate = vest.create( + it('should return a function', async () => { + const suite = vest.create( vi.fn(() => { dummyTest.failing('field_0'); }), ); - expect(typeof classnames(validate())).toBe('function'); - const promisifed = await promisify( - vest.create( + expect(typeof classnames(suite.run())).toBe('function'); + const promisifed = await vest + .create( vi.fn(() => { dummyTest.failing('field_0'); }), - ), - )(); + ) + .run(); expect(typeof classnames(promisifed)).toBe('function'); }); }); - const validate = vest.create(() => { + const suite = vest.create(() => { vest.mode(Modes.ALL); vest.skip('field_1'); @@ -51,7 +50,7 @@ describe('Utility: classnames', () => { dummyTest.failing('field_5'); }); - const res = validate(); + const res = suite.run(); describe('when all keys are provided', () => { const genClass = classnames(res, { @@ -63,7 +62,7 @@ describe('Utility: classnames', () => { warning: 'warning_string', }); - it('Should produce a string matching the classnames object for each field', () => { + it('should produce a string matching the classnames object for each field', () => { expect(genClass('field_1')).toBe('untested_string'); // splitting and sorting to not rely on object order which is unspecified in the language @@ -92,7 +91,7 @@ describe('Utility: classnames', () => { invalid: 'invalid_string', }); - it('Should produce a string matching the classnames object for each field', () => { + it('should produce a string matching the classnames object for each field', () => { expect(genClass('field_1')).toBe(''); // splitting and sorting to not rely on object order which is unspecified in the language @@ -106,14 +105,14 @@ describe('Utility: classnames', () => { }); describe('pending', () => { - it('Should add pending classname when a test is pending', () => { + it('should add the pending classname when a test is pending', () => { const suite = vest.create(() => { vest.test('field_1', 'msg', async () => {}); vest.test('field_2', 'msg', () => {}); vest.test('field_3', 'msg', () => {}); }); - const res = suite(); + const res = suite.run(); const genClass = classnames(res, { pending: 'pending_string', diff --git a/packages/vest/src/exports/__tests__/debounce.test.ts b/packages/vest/src/exports/__tests__/debounce.test.ts index 7e0fbac00..e533b4b8c 100644 --- a/packages/vest/src/exports/__tests__/debounce.test.ts +++ b/packages/vest/src/exports/__tests__/debounce.test.ts @@ -8,70 +8,62 @@ import * as vest from 'vest'; describe('debounce', () => { describe('Sync test', () => { describe('Returning false', () => { - it('Should debounce test function calls when used', () => { + it('should debounce test function calls when returning false', async () => { const test = vi.fn(() => { return false; }); - return new Promise(done => { - const suite = vest.create('suite', () => { - vest.test('test', 'message', debounce(test, 1500)); - }); - - suite(); - suite(); - suite(); - suite(); - suite(); - suite(); - suite().done(() => { - expect(test).toHaveBeenCalledTimes(1); - expect(suite.isValid()).toBe(false); - done(); - }); + const suite = vest.create(() => { + vest.test('test', 'message', debounce(test, 1500)); }); + + suite.run(); + suite.run(); + suite.run(); + suite.run(); + suite.run(); + suite.run(); + await suite.run(); + expect(test).toHaveBeenCalledTimes(1); + expect(suite.isValid()).toBe(false); }); }); describe('Throwing an error', () => { - it('Should debounce test function calls when used', () => { + it('should debounce test function calls when throwing an error', async () => { const test = vi.fn(() => { throw new Error(); }); - return new Promise(done => { - const suite = vest.create('suite', () => { - vest.test('test', 'message', debounce(test, 1500)); - }); - - suite(); - suite(); - suite(); - suite(); - suite(); - suite(); - suite().done(() => { - expect(test).toHaveBeenCalledTimes(1); - expect(suite.isValid()).toBe(false); - done(); - }); + const suite = vest.create(() => { + vest.test('test', 'message', debounce(test, 1500)); }); + + suite.run(); + suite.run(); + suite.run(); + suite.run(); + suite.run(); + suite.run(); + await suite.run(); + expect(test).toHaveBeenCalledTimes(1); + expect(suite.isValid()).toBe(false); }); }); }); describe('Async test', () => { - it('Should complete the async test after the delay', async () => { + it('should start the async test only after the debounce delay', async () => { const t = vi.fn(async () => { await wait(1000); vest.enforce(1).equals(2); }); - const suite = vest.create('suite', () => { + const suite = vest.create(() => { vest.test('test', 'message', debounce(t, 1500)); }); - suite(); + suite.run(); expect(t).toHaveBeenCalledTimes(0); expect(suite.isPending()).toBe(true); await wait(2000); @@ -85,118 +77,87 @@ describe('debounce', () => { }); describe('When delay met multiple times', () => { - it('Should call once per completed delay', async () => { + it('should call the test once per completed delay window', async () => { const test = vi.fn(() => { return false; }); - const suite = vest.create('suite', () => { + const suite = vest.create(() => { vest.test('test', 'message', debounce(test, 1000)); }); - suite(); + suite.run(); await wait(1000); expect(suite.get().hasErrors('test')).toBe(true); expect(test).toHaveBeenCalledTimes(1); - suite(); - suite(); - suite(); + suite.run(); + suite.run(); + suite.run(); expect(test).toHaveBeenCalledTimes(1); await wait(1000); expect(suite.get().hasErrors('test')).toBe(true); expect(test).toHaveBeenCalledTimes(2); - suite(); - suite(); - suite(); + suite.run(); + suite.run(); + suite.run(); expect(test).toHaveBeenCalledTimes(2); await wait(1000); }); }); describe('Debounced tests with non-debounced tests', () => { - it('Should complete non-debounced tests immediately', () => { + it('should complete non-debounced tests immediately', async () => { const test = vi.fn(() => { return false; }); - const suite = vest.create('suite', () => { - vest.test('test', 'message', debounce(test, 1000)); + const suite = vest.create(() => { + vest.test('test', 'message', debounce(test, 100)); vest.test('test2', 'message', test); }); - return new Promise(done => { - suite().done(() => { - expect(test).toHaveBeenCalledTimes(2); - expect(suite.get().hasErrors('test')).toBe(true); - expect(suite.get().hasErrors('test2')).toBe(true); - done(); - }); - expect(test).toHaveBeenCalledTimes(1); - expect(suite.get().hasErrors('test')).toBe(false); - expect(suite.get().hasErrors('test2')).toBe(true); - }); + const suitePromise = suite.run(); + expect(test).toHaveBeenCalledTimes(1); + expect(suite.get().hasErrors('test')).toBe(false); + expect(suite.get().hasErrors('test2')).toBe(true); + await suitePromise; + expect(test).toHaveBeenCalledTimes(2); + expect(suite.get().hasErrors('test')).toBe(true); + expect(suite.get().hasErrors('test2')).toBe(true); }); }); describe('Multiple debounced fields', () => { - it('Should conclude them on their own time', () => { + it('should conclude each debounced field on its own schedule', async () => { + const calls: number[] = []; const t = vi.fn(() => { + calls.push(Date.now()); return false; }); - const suite = vest.create('suite', () => { - vest.test('test', 'message', debounce(t, 1000)); - vest.test('test2', 'message', debounce(t, 1500)); - vest.test('test3', 'message', debounce(t, 2000)); + const suite = vest.create(() => { + vest.test('test', 'message', debounce(t, 100)); + vest.test('test2', 'message', debounce(t, 200)); + vest.test('test3', 'message', debounce(t, 300)); }); - const control = vi.fn(); - - return new Promise(done => { - suite(); - suite(); - suite() - .done('test', () => { - expect(control).toHaveBeenCalledTimes(0); - expect(t).toHaveBeenCalledTimes(1); - expect(suite.get().hasErrors('test')).toBe(true); - expect(suite.get().hasErrors('test2')).toBe(false); - expect(suite.get().hasErrors('test3')).toBe(false); - control(); - }) - .done('test2', () => { - expect(control).toHaveBeenCalledTimes(1); - expect(t).toHaveBeenCalledTimes(2); - expect(suite.get().hasErrors('test')).toBe(true); - expect(suite.get().hasErrors('test2')).toBe(true); - expect(suite.get().hasErrors('test3')).toBe(false); - control(); - }) - .done('test3', () => { - expect(control).toHaveBeenCalledTimes(2); - expect(t).toHaveBeenCalledTimes(3); - expect(suite.get().hasErrors('test')).toBe(true); - expect(suite.get().hasErrors('test2')).toBe(true); - expect(suite.get().hasErrors('test3')).toBe(true); - control(); - }) - .done(() => { - expect(control).toHaveBeenCalledTimes(3); - expect(t).toHaveBeenCalledTimes(3); - expect(suite.get().hasErrors('test')).toBe(true); - expect(suite.get().hasErrors('test2')).toBe(true); - expect(suite.get().hasErrors('test3')).toBe(true); - done(); - }); - }); + await suite.run(); + expect(t).toHaveBeenCalledTimes(3); + expect(calls[0]).toBeLessThan(calls[1]); + expect(calls[1]).toBeLessThan(calls[2]); + expect(calls[1] - calls[0]).toBeGreaterThanOrEqual(90); + expect(calls[2] - calls[1]).toBeGreaterThanOrEqual(90); + expect(suite.get().hasErrors('test')).toBe(true); + expect(suite.get().hasErrors('test2')).toBe(true); + expect(suite.get().hasErrors('test3')).toBe(true); }); }); describe('Test payload', () => { describe('AbortSignal', () => { - it('Should abort the test when signal is aborted', async () => { + it('should abort the test when the abort signal is triggered', async () => { const control = vi.fn(); let run = 0; @@ -211,23 +172,18 @@ describe('debounce', () => { return true; }); - const suite = vest.create('suite', () => { + const suite = vest.create(() => { vest.test('test', 'message', debounce(test, 200)); run++; }); - // eslint-disable-next-line no-async-promise-executor - return new Promise(async done => { - suite(); - await wait(200); - // This cancels the first run - suite().done(() => { - expect(suite.hasErrors('test')).toBe(false); - expect(test).toHaveBeenCalledTimes(2); - expect(control).toHaveBeenCalledTimes(1); - done(); - }); - }); + suite.run(); + await wait(200); + // This cancels the first run + await suite.run(); + expect(suite.hasErrors('test')).toBe(false); + expect(test).toHaveBeenCalledTimes(2); + expect(control).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/vest/src/exports/__tests__/memo.test.ts b/packages/vest/src/exports/__tests__/memo.test.ts new file mode 100644 index 000000000..f524bcba2 --- /dev/null +++ b/packages/vest/src/exports/__tests__/memo.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi } from 'vitest'; +import wait from 'wait'; + +import { TestPromise } from '../../testUtils/testPromise'; + +import { CommonStates } from 'CommonStateMachine'; +import { TIsolateTest } from 'IsolateTest'; +import { TestStatus } from 'IsolateTestStateMachine'; +import { Modes } from 'Modes'; +import { VestTest } from 'VestTest'; +import { memo } from 'memo'; +import * as vest from 'vest'; +import { test as vestTest, enforce } from 'vest'; + +describe('memo', () => { + describe('cache hit', () => { + it('Should return without calling callback', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(() => TestPromise(() => undefined)); + const suite = vest.create(() => { + memo(() => { + vestTest('f1', cb1); + }, [1]); + memo(() => { + vestTest('f1', cb2); + }, [2]); + }); + + suite.run(); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + suite.run(); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('Should produce correct initial result', () => { + const res = vest + .create(() => { + vest.mode(Modes.ALL); + memo(() => { + vestTest('field1', 'msg1', () => false); + vestTest('field1', 'msg2', () => undefined); + vestTest('field2', () => undefined); + vestTest('field3', () => { + vest.warn(); + return false; + }); + }, [{}]); + }) + .run(); + + expect(res.hasErrors('field1')).toBe(true); + expect(res.hasErrors('field2')).toBe(false); + expect(res.hasWarnings('field3')).toBe(true); + expect(res).toMatchSnapshot(); + }); + describe('sync', () => { + it('Should restore previous result on re-run', () => { + const suite = vest.create(() => { + vest.mode(Modes.ALL); + memo(() => { + vestTest('field1', 'msg1', () => false); + }, [1]); + memo(() => { + vestTest('field1', 'msg2', () => undefined); + }, [2]); + memo(() => { + vestTest('field2', () => undefined); + }, [3]); + memo(() => { + vestTest('field3', () => { + vest.warn(); + return false; + }); + }, [4]); + }); + + const res = suite.run(); + + expect(res.hasErrors('field1')).toBe(true); + expect(res.hasErrors('field2')).toBe(false); + expect(res.hasWarnings('field3')).toBe(true); + expect(res).toMatchSnapshot(); + + const res2 = suite.run(); + expect(res2.hasErrors('field1')).toBe(true); + expect(res2.hasErrors('field2')).toBe(false); + expect(res2.hasWarnings('field3')).toBe(true); + expect(res).isDeepCopyOf(res2); + }); + }); + + describe('async', () => { + it('Should immediately return previous result on re-run', async () => { + { + const suite = vest.create(() => { + memo(() => { + vestTest('field1', async () => { + await wait(500); + enforce(1).equals(2); + }); + }, [1]); + + memo(() => { + vestTest('field2', async () => { + await wait(500); + enforce(1).equals(2); + }); + }, [2]); + }); + + let start = Date.now(); + const res1 = await suite.run(); + enforce(Date.now() - start).gte(500); + + start = Date.now(); + const res2 = suite.run(); + + expect(res1).isDeepCopyOf(res2); + } + }); + }); + + describe('Test is canceled', () => { + it('Should refresh', async () => { + let count = 0; + const tests: TIsolateTest[] = []; + const suite = vest.create(() => { + count++; + + memo(() => { + tests.push( + vestTest('f1', async () => { + await wait(10); + }), + ); + }, [true]); + + if (count === 1) { + VestTest.cancel(tests[0]); + } + }); + + suite.run(); + suite.run(); + suite.run(); + + expect(tests[0]).not.toBe(tests[1]); + expect(tests[0].status).toBe(TestStatus.CANCELED); + expect(tests[1].status).toBe(CommonStates.PENDING); + expect(tests).toHaveLength(2); + }); + }); + }); + + describe('cache miss', () => { + it('Should run test normally', () => { + const cb1 = vi.fn(res => res); + const cb2 = vi.fn( + res => new Promise((resolve, rej) => (res ? resolve() : rej())), + ); + const suite = vest.create((key, res) => { + memo(() => { + vestTest('f1', () => cb1(res)); + }, [1, key]); + memo(() => { + vestTest('f2', () => cb2(res)); + }, [2, key]); + }); + + expect(cb1).toHaveBeenCalledTimes(0); + expect(cb2).toHaveBeenCalledTimes(0); + suite.run('a', false); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + expect(suite.get().hasErrors()).toBe(true); + suite.run('b', true); + expect(cb1).toHaveBeenCalledTimes(2); + expect(cb2).toHaveBeenCalledTimes(2); + expect(suite.get().hasErrors()).toBe(false); + }); + }); + + describe('Collision detection', () => { + describe('cross-field collision', () => { + it('Should factor in field name', () => { + const suite = vest.create(() => { + memo(() => { + vestTest('f1', () => false); + vestTest('f2', () => true); + }, [1]); + }); + + suite.run(); + suite.run(); + expect(suite.get().hasErrors('f1')).toBe(true); + expect(suite.get().hasErrors('f2')).toBe(false); + }); + }); + + describe('same-field-same-suite collision', () => { + it('Should factor in execution order', () => { + const suite = vest.create(() => { + memo(() => { + vestTest('f1', () => false); + vestTest('f1', () => true); + }, [1]); + }); + + suite.run(); + suite.run(); + expect(suite.get().hasErrors('f1')).toBe(true); + expect(suite.get().errorCount).toBe(1); + }); + }); + describe('cross-suite collision', () => { + it('Should factor in field name', () => { + const suite1 = vest.create(() => { + memo(() => { + vestTest('f1', () => false); + vestTest('f2', () => true); + }, [1]); + }); + const suite2 = vest.create(() => { + memo(() => { + vestTest('f1', () => true); + vestTest('f2', () => false); + }, [1]); + }); + + suite1.run(); + suite2.run(); + expect(suite1.get().hasErrors('f1')).toBe(true); + expect(suite1.get().hasErrors('f2')).toBe(false); + expect(suite2.get().hasErrors('f1')).toBe(false); + expect(suite2.get().hasErrors('f2')).toBe(true); + }); + }); + }); + + describe('hit is older than most recent run', () => { + it('should restore from cache only within last five', () => { + const suite = vest.create( + ({ cacheKey, index }: { cacheKey: string; index: number }) => { + memo(() => { + vestTest( + 'f1', + `CacheKey is ${cacheKey}, index is: ${index}`, + () => false, + ); + }, [cacheKey]); + }, + ); + + suite.run({ cacheKey: 'a', index: 0 }); + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 0'); + suite.run({ cacheKey: 'a', index: 100 }); + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 0'); + suite.run({ cacheKey: 'b', index: 1 }); + expect(suite.getError('f1')).toBe('CacheKey is b, index is: 1'); + suite.run({ cacheKey: 'a', index: 200 }); + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 0'); + suite.run({ cacheKey: 'c', index: 2 }); + expect(suite.getError('f1')).toBe('CacheKey is c, index is: 2'); + suite.run({ cacheKey: 'a', index: 300 }); + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 0'); + suite.run({ cacheKey: 'd', index: 3 }); + expect(suite.getError('f1')).toBe('CacheKey is d, index is: 3'); + suite.run({ cacheKey: 'a', index: 400 }); + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 0'); + suite.run({ cacheKey: 'e', index: 4 }); + expect(suite.getError('f1')).toBe('CacheKey is e, index is: 4'); + suite.run({ cacheKey: 'a', index: 500 }); + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 0'); + suite.run({ cacheKey: 'f', index: 5 }); + expect(suite.getError('f1')).toBe('CacheKey is f, index is: 5'); + suite.run({ cacheKey: 'a', index: 600 }); + // this is the 6th run, so the cache should finally be updated + expect(suite.getError('f1')).toBe('CacheKey is a, index is: 600'); + }); + + it('Should correctly restore async tests', async () => { + const tests = []; + const suite = vest.create(({ key, index }) => { + memo(() => { + tests.push( + vestTest('f1', `key: ${key}, index: ${index}`, async () => { + await wait(10); + throw new Error(`key: ${key}, index: ${index}`); + }), + ); + }, [key]); + }); + + let res = await suite.run({ key: 'a', index: 0 }); + expect(res.getError('f1')).toBe('key: a, index: 0'); + res = suite.run({ key: 'b', index: 1 }); + expect(res.getError('f1')).toBe(undefined); + res = await suite.run({ key: 'b', index: 2 }); + expect(res.getError('f1')).toBe('key: b, index: 1'); + }); + }); + + it('should be restored into the new tree', () => { + let runCount = 1; + const cb = vi.fn(() => false); + const suite = vest.create(() => { + vest.group(`g${runCount}`, () => { + memo(() => { + vestTest('f1', cb); + }, [1]); + }); + + runCount++; + }); + + let res = suite.run(); + expect(cb).toHaveBeenCalledTimes(1); + expect(res.groups.g1.f1.errorCount).toBe(1); + res = suite.run(); + expect(cb).toHaveBeenCalledTimes(1); + expect(res.groups.g2.f1.errorCount).toBe(1); + expect(res.groups.g1).toBeUndefined(); + }); +}); diff --git a/packages/vest/src/exports/__tests__/parser.test.ts b/packages/vest/src/exports/__tests__/parser.test.ts index f288e2c79..f254cb64f 100644 --- a/packages/vest/src/exports/__tests__/parser.test.ts +++ b/packages/vest/src/exports/__tests__/parser.test.ts @@ -1,9 +1,9 @@ -import { parse } from 'parser'; import { describe, it, expect } from 'vitest'; import * as suiteDummy from '../../testUtils/suiteDummy'; import { ser } from '../../testUtils/suiteDummy'; +import { parse } from 'parser'; import * as vest from 'vest'; describe('parser.parse', () => { @@ -96,12 +96,14 @@ describe('parser.parse', () => { it('Should return true if provided field is untested while others are', () => { expect( parse( - vest.create(() => { - vest.test('x', () => {}); - vest.skipWhen(true, () => { - vest.test('untested', () => {}); - }); - })(), + vest + .create(() => { + vest.test('x', () => {}); + vest.skipWhen(true, () => { + vest.test('untested', () => {}); + }); + }) + .run(), ).untested('untested'), ).toBe(true); }); @@ -125,12 +127,14 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.test('x', () => {}); - vest.skipWhen(true, () => { - vest.test('untested', () => {}); - }); - })(), + vest + .create(() => { + vest.test('x', () => {}); + vest.skipWhen(true, () => { + vest.test('untested', () => {}); + }); + }) + .run(), ), ).untested('untested'), ).toBe(true); @@ -189,13 +193,15 @@ describe('parser.parse', () => { it('should return false if not all required fields ran', () => { expect( parse( - vest.create(() => { - vest.test('x', () => {}); - vest.test('untested', () => {}); - vest.skipWhen(true, () => { + vest + .create(() => { + vest.test('x', () => {}); vest.test('untested', () => {}); - }); - })(), + vest.skipWhen(true, () => { + vest.test('untested', () => {}); + }); + }) + .run(), ).valid(), ).toBe(false); }); @@ -204,11 +210,13 @@ describe('parser.parse', () => { it('Should return false when field is untested', () => { expect( parse( - vest.create(() => { - vest.skipWhen(true, () => { - vest.test('f1', () => {}); - }); - })(), + vest + .create(() => { + vest.skipWhen(true, () => { + vest.test('f1', () => {}); + }); + }) + .run(), ).valid('f1'), ).toBe(false); }); @@ -216,12 +224,14 @@ describe('parser.parse', () => { it('Should return true if optional field is untested', () => { expect( parse( - vest.create(() => { - vest.optional('f1'); - vest.skipWhen(true, () => { - vest.test('f1', () => {}); - }); - })(), + vest + .create(() => { + vest.optional('f1'); + vest.skipWhen(true, () => { + vest.test('f1', () => {}); + }); + }) + .run(), ).valid('f1'), ).toBe(true); }); @@ -229,10 +239,12 @@ describe('parser.parse', () => { it('Should return true if field is passing', () => { expect( parse( - vest.create(() => { - vest.test('f1', () => {}); - vest.test('f2', () => false); - })(), + vest + .create(() => { + vest.test('f1', () => {}); + vest.test('f2', () => false); + }) + .run(), ).valid('f1'), ).toBe(true); }); @@ -240,19 +252,23 @@ describe('parser.parse', () => { it('Should return false if field is failing', () => { expect( parse( - vest.create(() => { - vest.test('f1', () => {}); - vest.test('f2', () => false); - })(), + vest + .create(() => { + vest.test('f1', () => {}); + vest.test('f2', () => false); + }) + .run(), ).valid('f2'), ).toBe(false); expect( parse( - vest.create(() => { - vest.test('f1', () => {}); - vest.test('f2', () => {}); - vest.test('f2', () => false); - })(), + vest + .create(() => { + vest.test('f1', () => {}); + vest.test('f2', () => {}); + vest.test('f2', () => false); + }) + .run(), ).valid('f2'), ).toBe(false); }); @@ -260,12 +276,14 @@ describe('parser.parse', () => { it('Should return true if field is warning', () => { expect( parse( - vest.create(() => { - vest.test('f1', () => { - vest.warn(); - return false; - }); - })(), + vest + .create(() => { + vest.test('f1', () => { + vest.warn(); + return false; + }); + }) + .run(), ).valid('f1'), ).toBe(true); }); @@ -317,13 +335,15 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.test('x', () => {}); - vest.test('untested', () => {}); - vest.skipWhen(true, () => { + vest + .create(() => { + vest.test('x', () => {}); vest.test('untested', () => {}); - }); - })(), + vest.skipWhen(true, () => { + vest.test('untested', () => {}); + }); + }) + .run(), ), ).valid(), ).toBe(false); @@ -334,11 +354,13 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.skipWhen(true, () => { - vest.test('f1', () => {}); - }); - })(), + vest + .create(() => { + vest.skipWhen(true, () => { + vest.test('f1', () => {}); + }); + }) + .run(), ), ).valid('f1'), ).toBe(false); @@ -348,12 +370,14 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.optional('f1'); - vest.skipWhen(true, () => { - vest.test('f1', () => {}); - }); - })(), + vest + .create(() => { + vest.optional('f1'); + vest.skipWhen(true, () => { + vest.test('f1', () => {}); + }); + }) + .run(), ), ).valid('f1'), ).toBe(true); @@ -363,10 +387,12 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.test('f1', () => {}); - vest.test('f2', () => false); - })(), + vest + .create(() => { + vest.test('f1', () => {}); + vest.test('f2', () => false); + }) + .run(), ), ).valid('f1'), ).toBe(true); @@ -376,21 +402,25 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.test('f1', () => {}); - vest.test('f2', () => false); - })(), + vest + .create(() => { + vest.test('f1', () => {}); + vest.test('f2', () => false); + }) + .run(), ), ).valid('f2'), ).toBe(false); expect( parse( ser( - vest.create(() => { - vest.test('f1', () => {}); - vest.test('f2', () => {}); - vest.test('f2', () => false); - })(), + vest + .create(() => { + vest.test('f1', () => {}); + vest.test('f2', () => {}); + vest.test('f2', () => false); + }) + .run(), ), ).valid('f2'), ).toBe(false); @@ -400,12 +430,14 @@ describe('parser.parse', () => { expect( parse( ser( - vest.create(() => { - vest.test('f1', () => { - vest.warn(); - return false; - }); - })(), + vest + .create(() => { + vest.test('f1', () => { + vest.warn(); + return false; + }); + }) + .run(), ), ).valid('f1'), ).toBe(true); @@ -441,7 +473,7 @@ describe('parser.parse', () => { vest.test('f2', async () => {}); vest.test('f3', async () => {}); }); - suite(); + suite.run(); expect(parse(suite.get()).pending()).toBe(true); }); @@ -451,7 +483,7 @@ describe('parser.parse', () => { vest.test('f2', async () => {}); vest.test('f3', async () => {}); }); - suite(); + suite.run(); expect(parse(suite.get()).pending('f1')).toBe(true); }); diff --git a/packages/vest/src/exports/__tests__/promisify.test.ts b/packages/vest/src/exports/__tests__/promisify.test.ts deleted file mode 100644 index 288dd5b5f..000000000 --- a/packages/vest/src/exports/__tests__/promisify.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { TFieldName } from 'SuiteResultTypes'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -import { dummyTest } from '../../testUtils/testDummy'; -import { TestPromise } from '../../testUtils/testPromise'; -import promisify from '../promisify'; - -import * as vest from 'vest'; - -describe('Utility: promisify', () => { - let validatorFn: vi.Mock, any>; - let validateAsync: ( - ...args: any[] - ) => Promise>; - - beforeEach(() => { - validatorFn = vi.fn( - vest.create( - vi.fn(() => { - dummyTest.failing('field_0'); - }), - ), - ); - validateAsync = promisify(validatorFn); - }); - - describe('Test arguments', () => { - it('Should throw an error', () => { - // @ts-expect-error - testing invalid input - const invalidValidateAsync = promisify('invalid'); - expect(() => invalidValidateAsync()).toThrow(); - }); - }); - - describe('Return value', () => { - it('should be a function', () => { - expect(typeof promisify(vi.fn())).toBe('function'); - }); - - it('should be a promise', () => - TestPromise(done => { - const res = validateAsync(); - expect(typeof res?.then).toBe('function'); - res.then(() => done()); - })); - }); - - describe('When returned function is invoked', () => { - it('Calls `validatorFn` argument', () => - TestPromise(done => { - const validateAsync = promisify( - vest.create(() => { - dummyTest.failing('field_0'); - done(); - }), - ); - validateAsync(); - })); - - it('Passes all arguments over to tests callback', async () => { - const params = [ - 1, - { [faker.lorem.word()]: [1, 2, 3] }, - false, - [faker.lorem.word()], - ]; - - await validateAsync(...params); - expect(validatorFn).toHaveBeenCalledWith(...params); - }); - }); - - describe('Initial run', () => { - it('Produces correct validation', () => - TestPromise(done => { - const validate = vest.create(() => { - dummyTest.failing('field_0'); - dummyTest.failingAsync('field_1'); - }); - - const validatorAsync = promisify(validate); - const p = validatorAsync('me'); - - p.then(result => { - expect(result.hasErrors('field_0')).toBe(true); - expect(result.hasErrors('field_1')).toBe(true); - done(); - }); - })); - }); -}); diff --git a/packages/vest/src/exports/debounce.ts b/packages/vest/src/exports/debounce.ts index 85e82abd2..dc97f1473 100644 --- a/packages/vest/src/exports/debounce.ts +++ b/packages/vest/src/exports/debounce.ts @@ -30,7 +30,7 @@ export default function debounce( }, delay); }); - const i = Isolate.create(isolateType, f, { + const i = Isolate.create(isolateType, f, { clearTimeout: () => { if (timeout) { clearTimeout(timeout); @@ -41,7 +41,7 @@ export default function debounce( return i.output; } -export class IsolateDebounceReconciler { +class IsolateDebounceReconciler { static match(currentNode: TIsolate, historyNode: TIsolate): boolean { return ( IsolateSelectors.isIsolateType(currentNode, isolateType) && @@ -56,9 +56,9 @@ export class IsolateDebounceReconciler { } } -export type TIsolateDebounce = TIsolate; +type TIsolateDebounce = TIsolate; -export type IsolateDebouncePayload = { +type IsolateDebouncePayload = { clearTimeout: () => void; }; diff --git a/packages/vest/src/exports/enforce@compose.ts b/packages/vest/src/exports/enforce@compose.ts deleted file mode 100644 index 855a19b37..000000000 --- a/packages/vest/src/exports/enforce@compose.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'n4s/compose'; diff --git a/packages/vest/src/exports/enforce@compounds.ts b/packages/vest/src/exports/enforce@compounds.ts deleted file mode 100644 index 399008107..000000000 --- a/packages/vest/src/exports/enforce@compounds.ts +++ /dev/null @@ -1 +0,0 @@ -export * as compounds from 'n4s/compounds'; diff --git a/packages/vest/src/exports/enforce@schema.ts b/packages/vest/src/exports/enforce@schema.ts deleted file mode 100644 index ec8835021..000000000 --- a/packages/vest/src/exports/enforce@schema.ts +++ /dev/null @@ -1 +0,0 @@ -export * as schema from 'n4s/schema'; diff --git a/packages/vest/src/exports/memo.ts b/packages/vest/src/exports/memo.ts new file mode 100644 index 000000000..2a85fabc9 --- /dev/null +++ b/packages/vest/src/exports/memo.ts @@ -0,0 +1,95 @@ +import { registerReconciler } from 'vest'; +import { + cache, + CacheApi, + CB, + Nullable, + isNullish, + invariant, +} from 'vest-utils'; +import { TIsolate, IsolateSelectors, Walker } from 'vestjs-runtime'; + +import { TIsolateTest } from 'IsolateTest'; +import { createVestIsolate, TVestIsolate } from 'VestIsolateType'; +import { VestTest } from 'VestTest'; + +const isolateType = 'Memo'; + +export function memo( + callback: Callback, + dependencies: unknown[], +): TIsolateMemo { + return createVestIsolate(isolateType, callback, { + dependencies, + cache: null, + }); +} + +class IsolateMemoReconciler { + static match(currentNode: TIsolate, historyNode: TIsolate): boolean { + return ( + IsolateSelectors.isIsolateType(currentNode, isolateType) && + IsolateSelectors.isIsolateType(historyNode, isolateType) + ); + } + + static reconcile(current: TIsolateMemo, history: TIsolateMemo): TIsolateMemo { + initializeCache(history); + + const hit = history.data.cache.get(current.data.dependencies); + current.data.cache = history.data.cache; + + if (isNullish(hit)) { + return handleCacheMiss(current, history); + } + + const historicHit = hit[1]; + + if (isCanceledTest(historicHit)) { + history.data.cache.invalidate(current.data.dependencies); + return handleCacheMiss(current, history); + } + + return historicHit; + } +} + +type TIsolateMemo = TVestIsolate; + +type TIsolateMemoWithCache = TIsolateMemo & { + data: { cache: CacheApi }; +}; + +type IsolateMemoPayload = { + dependencies: unknown[]; + cache: Nullable>; +}; + +registerReconciler(IsolateMemoReconciler); + +function initializeCache( + history: TIsolateMemo, +): asserts history is TIsolateMemoWithCache { + if (isNullish(history.data.cache)) { + history.data.cache = cache(5); + history.data.cache(history.data.dependencies, () => history); + } + invariant(history.data.cache); +} + +function handleCacheMiss( + current: TIsolateMemo, + history: TIsolateMemo, +): TIsolateMemo { + invariant(history.data.cache); + history.data.cache(current.data.dependencies, () => current); + return current; +} + +function isCanceledTest(historicHit: TIsolateMemo): boolean { + return Walker.some( + historicHit, + i => VestTest.isCanceled(i as TIsolateTest), + VestTest.is, + ); +} diff --git a/packages/vest/src/exports/promisify.ts b/packages/vest/src/exports/promisify.ts deleted file mode 100644 index 0672d0bc0..000000000 --- a/packages/vest/src/exports/promisify.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { invariant, isFunction } from 'vest-utils'; - -import { ErrorStrings } from 'ErrorStrings'; -import { - SuiteResult, - SuiteRunResult, - TFieldName, - TGroupName, -} from 'SuiteResultTypes'; - -function promisify( - validatorFn: (...args: any[]) => SuiteRunResult, -) { - return (...args: any[]): Promise> => { - invariant(isFunction(validatorFn), ErrorStrings.PROMISIFY_REQUIRE_FUNCTION); - - return new Promise(resolve => validatorFn(...args).done(resolve)); - }; -} - -export default promisify; diff --git a/packages/vest/src/hooks/__tests__/__snapshots__/include.test.ts.snap b/packages/vest/src/hooks/__tests__/__snapshots__/include.test.ts.snap index 803f9c880..e21438c0a 100644 --- a/packages/vest/src/hooks/__tests__/__snapshots__/include.test.ts.snap +++ b/packages/vest/src/hooks/__tests__/__snapshots__/include.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`include > Field is excluded via \`skip\` > Should disregard \`include.when\` and avoid running the test 1`] = ` -{ - "done": [Function], +exports[`include > Field is excluded via \`skip\` > should disregard \`include.when\` and avoid running the test 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -50,15 +50,16 @@ exports[`include > Field is excluded via \`skip\` > Should disregard \`include.w "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > Field is excluded via \`skip\` > Should disregard \`include\` and avoid running the test 1`] = ` -{ - "done": [Function], +exports[`include > Field is excluded via \`skip\` > should disregard \`include\` and avoid running the test 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -106,15 +107,16 @@ exports[`include > Field is excluded via \`skip\` > Should disregard \`include\` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > Test is excluded via \`skip.group\` > Should disregard \`include.when\` and avoid running the test 1`] = ` -{ - "done": [Function], +exports[`include > Test is excluded via \`skip.group\` > should disregard \`include.when\` and avoid running the test 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -183,15 +185,16 @@ exports[`include > Test is excluded via \`skip.group\` > Should disregard \`incl "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > Test is excluded via \`skip.group\` > Should disregard \`include\` and avoid running the test 1`] = ` -{ - "done": [Function], +exports[`include > Test is excluded via \`skip.group\` > should disregard \`include\` and avoid running the test 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -260,6 +263,7 @@ exports[`include > Test is excluded via \`skip.group\` > Should disregard \`incl "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -267,8 +271,8 @@ exports[`include > Test is excluded via \`skip.group\` > Should disregard \`incl `; exports[`include > Test is excluded via \`skipWhen\` > Should disregard \`include.when\` and avoid running the matching tests 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -307,6 +311,7 @@ exports[`include > Test is excluded via \`skipWhen\` > Should disregard \`includ "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -314,8 +319,8 @@ exports[`include > Test is excluded via \`skipWhen\` > Should disregard \`includ `; exports[`include > Test is excluded via \`skipWhen\` > Should disregard \`include\` and avoid running the matching tests 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -354,15 +359,16 @@ exports[`include > Test is excluded via \`skipWhen\` > Should disregard \`includ "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > \`include\` is run as-is without modifiers > Should run the included test along with the onlyd test 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > \`include\` is run as-is without modifiers > should run the included test along with the field focused by only() 1`] = ` +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -415,15 +421,16 @@ exports[`include > There is an \`onlyd\` field > \`include\` is run as-is withou "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a boolean > when \`false\` > Should skip run included field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a boolean > when \`false\` > should not run the included field 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -480,15 +487,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a boolean > when \`true\` > Should run included field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a boolean > when \`true\` > should run the included field 1`] = ` +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -550,15 +558,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a function > Callback evaluation > Should evaluate per test run 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a function > Callback evaluation > should evaluate per test run 1`] = ` +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -602,15 +611,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a function > when returning\`false\` > Should skip run included field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a function > when returning\`false\` > should not run the included field 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -667,15 +677,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a function > when returning \`true\` > Should run included field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a function > when returning \`true\` > should run the included field 1`] = ` +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -737,15 +748,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a string > \`when\` param is a name of a non-included field > Should avoid running the included field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a string > \`when\` param is a name of a non-included field > should not run the included field 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -802,15 +814,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a string > \`when\` param is a name of a skipped field > Should avoid running the included field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a string > \`when\` param is a name of a skipped field > should not run the included field 1`] = ` +Promise { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -867,15 +880,16 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a string > \`when\` param is a name of an onlyd field > Should run included field along with the onlyd field 1`] = ` -{ - "done": [Function], +exports[`include > There is an \`onlyd\` field > include().when() > \`when\` param is a string > \`when\` param is a name of an only() field > should run the included field along with the only() field 1`] = ` +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -937,6 +951,7 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -944,8 +959,8 @@ exports[`include > There is an \`onlyd\` field > include().when() > \`when\` par `; exports[`include > When no \`skip\` or \`only\` > include has no effect 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -998,6 +1013,7 @@ exports[`include > When no \`skip\` or \`only\` > include has no effect 1`] = ` "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -1005,8 +1021,8 @@ exports[`include > When no \`skip\` or \`only\` > include has no effect 1`] = ` `; exports[`include > When no \`skip\` or \`only\` > include().when has no effect 1`] = ` -{ - "done": [Function], +Promise { + "dump": [Function], "errorCount": 2, "errors": [ SummaryFailure { @@ -1059,6 +1075,7 @@ exports[`include > When no \`skip\` or \`only\` > include().when has no effect 1 "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/hooks/__tests__/include.test.ts b/packages/vest/src/hooks/__tests__/include.test.ts index d34d1ae30..b32390d1e 100644 --- a/packages/vest/src/hooks/__tests__/include.test.ts +++ b/packages/vest/src/hooks/__tests__/include.test.ts @@ -13,7 +13,7 @@ describe('include', () => { }); describe('When not passing a string fieldName', () => { - it('Should throw an error', () => { + it('should throw an error', () => { // @ts-ignore expect(() => vest.include({})).toThrow(); // @ts-ignore @@ -23,7 +23,7 @@ describe('include', () => { describe('There is an `onlyd` field', () => { describe('`include` is run as-is without modifiers', () => { - it('Should run the included test along with the onlyd test', () => { + it('should run the included test along with the field focused by only()', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2'); @@ -32,7 +32,7 @@ describe('include', () => { vest.test('field_2', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(true); @@ -43,8 +43,8 @@ describe('include', () => { describe('include().when()', () => { describe('`when` param is a string', () => { - describe('`when` param is a name of an onlyd field', () => { - it('Should run included field along with the onlyd field', () => { + describe('`when` param is a name of an only() field', () => { + it('should run the included field along with the only() field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2').when('field_1'); @@ -54,7 +54,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(true); @@ -65,7 +65,7 @@ describe('include', () => { }); }); describe('`when` param is a name of a non-included field', () => { - it('Should avoid running the included field', () => { + it('should not run the included field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2').when('field_3'); @@ -75,7 +75,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(false); @@ -86,7 +86,7 @@ describe('include', () => { }); }); describe('`when` param is a name of a skipped field', () => { - it('Should avoid running the included field', () => { + it('should not run the included field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.skip('field_3'); @@ -97,7 +97,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(false); @@ -110,7 +110,7 @@ describe('include', () => { }); describe('`when` param is a boolean', () => { describe('when `true`', () => { - it('Should run included field', () => { + it('should run the included field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2').when(true); @@ -120,7 +120,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(true); @@ -131,7 +131,7 @@ describe('include', () => { }); }); describe('when `false`', () => { - it('Should skip run included field', () => { + it('should not run the included field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2').when(false); @@ -141,7 +141,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(false); @@ -154,7 +154,7 @@ describe('include', () => { }); describe('`when` param is a function', () => { describe('when returning `true`', () => { - it('Should run included field', () => { + it('should run the included field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2').when(() => true); @@ -164,7 +164,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(true); @@ -175,7 +175,7 @@ describe('include', () => { }); }); describe('when returning`false`', () => { - it('Should skip run included field', () => { + it('should not run the included field', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_2').when(() => false); @@ -185,7 +185,7 @@ describe('include', () => { vest.test('field_3', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(res.hasErrors('field_2')).toBe(false); @@ -197,7 +197,7 @@ describe('include', () => { }); describe('Callback evaluation', () => { - it('Should run the callback for each matching test', () => { + it('should run the callback for each matching test', () => { const cb = vi.fn(() => true); const suite = vest.create(() => { vest.mode(Modes.ALL); @@ -214,10 +214,10 @@ describe('include', () => { expect(cb).toHaveBeenCalledTimes(2); }); - suite(); + suite.run(); expect(cb).toHaveBeenCalledTimes(2); }); - it('Should evaluate per test run', () => { + it('should evaluate per test run', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -238,7 +238,7 @@ describe('include', () => { vest.test('field_1', cb4); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(0); expect(cb2).toHaveBeenCalledTimes(1); expect(cb3).toHaveBeenCalledTimes(0); @@ -253,7 +253,7 @@ describe('include', () => { }); describe('Field is excluded via `skip`', () => { - it('Should disregard `include` and avoid running the test', () => { + it('should disregard `include` and avoid running the test', () => { const suite = vest.create(() => { vest.skip('field_1'); vest.include('field_1'); @@ -262,14 +262,14 @@ describe('include', () => { vest.test('field_2', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(false); expect(res.tests.field_1.testCount).toBe(0); expect(res.hasErrors('field_2')).toBe(true); expect(res.tests.field_2.testCount).toBe(1); expect(res).toMatchSnapshot(); }); - it('Should disregard `include.when` and avoid running the test', () => { + it('should disregard `include.when` and avoid running the test', () => { const suite = vest.create(() => { vest.skip('field_1'); vest.include('field_1').when(true); @@ -278,7 +278,7 @@ describe('include', () => { vest.test('field_2', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(false); expect(res.tests.field_1.testCount).toBe(0); expect(res.hasErrors('field_2')).toBe(true); @@ -288,7 +288,7 @@ describe('include', () => { }); describe('Field is included via `only`', () => { - it('Should disregard `when` condition and test the field anyway', () => { + it('should disregard `when` condition and test the field anyway', () => { const suite = vest.create(() => { vest.only('field_1'); vest.include('field_1').when(false); @@ -296,14 +296,14 @@ describe('include', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); }); }); describe('Test is excluded via `skip.group`', () => { - it('Should disregard `include` and avoid running the test', () => { + it('should disregard `include` and avoid running the test', () => { const suite = vest.create(() => { vest.include('field_1'); @@ -315,7 +315,7 @@ describe('include', () => { vest.test('field_1', cb2); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(cb1).toHaveBeenCalledTimes(0); @@ -324,7 +324,7 @@ describe('include', () => { expect(res.tests.field_2.testCount).toBe(0); expect(res).toMatchSnapshot(); }); - it('Should disregard `include.when` and avoid running the test', () => { + it('should disregard `include.when` and avoid running the test', () => { const suite = vest.create(() => { vest.include('field_1').when(true); @@ -336,7 +336,7 @@ describe('include', () => { vest.test('field_1', cb2); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(cb1).toHaveBeenCalledTimes(0); @@ -357,7 +357,7 @@ describe('include', () => { vest.test('field_1', cb2); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(cb1).not.toHaveBeenCalled(); @@ -374,7 +374,7 @@ describe('include', () => { vest.test('field_1', cb2); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); expect(cb1).not.toHaveBeenCalled(); @@ -391,7 +391,7 @@ describe('include', () => { vest.test('field_1', () => false); vest.test('field_2', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); @@ -408,7 +408,7 @@ describe('include', () => { vest.test('field_1', () => false); vest.test('field_2', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.tests.field_1.testCount).toBe(1); diff --git a/packages/vest/src/hooks/__tests__/mode.test.ts b/packages/vest/src/hooks/__tests__/mode.test.ts index ea680b379..18013ffd5 100644 --- a/packages/vest/src/hooks/__tests__/mode.test.ts +++ b/packages/vest/src/hooks/__tests__/mode.test.ts @@ -1,10 +1,10 @@ -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach } from 'vitest'; import wait from 'wait'; import { dummyTest } from '../../testUtils/testDummy'; import { Modes } from 'Modes'; +import { TTestSuite } from 'TVestMock'; import { create, only, group, mode } from 'vest'; import * as Vest from 'vest'; @@ -26,9 +26,9 @@ describe('mode', () => { }); }); - it('Should fail fast for every failing field', () => { + it('should stop after the first failing test in each field', () => { expect(suite.get().testCount).toBe(0); // sanity - suite(); + suite.run(); expect(suite.get().testCount).toBe(3); expect(suite.get().errorCount).toBe(3); expect(suite.get().getErrors('field_1')).toEqual(['first-of-field_1']); @@ -39,7 +39,7 @@ describe('mode', () => { describe('async tests', () => { describe('When mixed', () => { describe('Failing sync test before the async tests', () => { - it('should stop execution after the first failing sync test', () => + it('should stop after the first failing sync test before pending async tests', () => new Promise(resolve => { const suite = Vest.create(() => { Vest.test('t1', 'f0', () => true); @@ -49,61 +49,57 @@ describe('mode', () => { throw new Error(); }); }); - suite().done(res => { - expect(res.getErrors()).toEqual({ - t1: ['f1'], - }); - resolve(); - }); + suite + .after(() => { + expect(suite.getErrors()).toEqual({ + t1: ['f1'], + }); + resolve(); + }) + .run(); })); }); describe('Failing async test before the sync tests', () => { - it('should stop execution after the first failing sync test', () => - new Promise(resolve => { - const suite = Vest.create(() => { - Vest.test('t1', 'f0', async () => { - await wait(150); - throw new Error(); - }); - Vest.test('t1', 'f1', () => false); - Vest.test('t1', 'f2', () => true); - }); - suite().done(res => { - expect(res.getErrors()).toEqual({ - t1: ['f0', 'f1'], - }); - resolve(); + it('should include earlier failing async test and then stop on first failing sync test', async () => { + const suite = Vest.create(() => { + Vest.test('t1', 'f0', async () => { + await wait(150); + throw new Error(); }); - })); + Vest.test('t1', 'f1', () => false); + Vest.test('t1', 'f2', () => true); + }); + await suite.run(); + expect(suite.getErrors()).toEqual({ + t1: ['f0', 'f1'], + }); + }); }); }); describe('Only async tests', () => { - it('should run all tests', () => - new Promise(resolve => { - const suite = Vest.create(() => { - Vest.test('async_1', 'f1', async () => { - await wait(100); - throw new Error(); - }); - Vest.test('async_1', 'f2', async () => { - await wait(150); - throw new Error(); - }); + it('should run all async tests', async () => { + const suite = Vest.create(() => { + Vest.test('async_1', 'f1', async () => { + await wait(100); + throw new Error(); }); - suite().done(res => { - expect(res.getErrors()).toEqual({ - async_1: ['f1', 'f2'], - }); - resolve(); + Vest.test('async_1', 'f2', async () => { + await wait(150); + throw new Error(); }); - })); + }); + await suite.run(); + expect(suite.getErrors()).toEqual({ + async_1: ['f1', 'f2'], + }); + }); }); }); describe('When test is `only`ed', () => { - it('Should fail fast for failing field', () => { - suite('field_1'); + it('should stop after the first failing test in the focused field', () => { + suite.run('field_1'); expect(suite.get().testCount).toBe(1); expect(suite.get().errorCount).toBe(1); expect(suite.get().getErrors('field_1')).toEqual([ @@ -121,8 +117,8 @@ describe('mode', () => { dummyTest.failing('field_1', 'second-of-field_1'); }); }); - it('Should fail fast for failing field', () => { - suite(); + it('should stop after the first failing test inside the group', () => { + suite.run(); expect(suite.get().testCount).toBe(1); expect(suite.get().errorCount).toBe(1); expect(suite.get().getErrors('field_1')).toEqual([ @@ -144,9 +140,9 @@ describe('mode', () => { }); }); - it('Should fail fast for every failing field', () => { + it('should stop after the first failing test per field (passing tests before failure counted)', () => { expect(suite.get().testCount).toBe(0); // sanity - suite(); + suite.run(); expect(suite.get().testCount).toBe(6); expect(suite.get().errorCount).toBe(3); expect(suite.get().getErrors('field_1')).toEqual(['second-of-field_1']); @@ -170,18 +166,18 @@ describe('mode', () => { }); }); - it('Should treat test as passing', () => { - suite(); + it('should treat previously failing test as passing after it no longer fails', () => { + suite.run(); expect(suite.get().hasErrors()).toBe(true); expect(suite.get().getErrors('field_1')).toEqual(['second-of-field_1']); - suite(); + suite.run(); expect(suite.get().hasErrors()).toBe(false); expect(suite.get().getErrors('field_1')).toEqual([]); }); }); describe('When in a nested block', () => { - it('Should follow the same behavior as if it was not nested', () => { + it('should apply fail-fast behavior even when tests are nested', () => { const suite = create(() => { group('group_1', () => { Vest.test('field_1', 'first-of-field_1', () => false); @@ -193,7 +189,7 @@ describe('mode', () => { }); }); expect(suite.get().testCount).toBe(0); // sanity - suite(); + suite.run(); expect(suite.get().testCount).toBe(3); expect(suite.get().errorCount).toBe(3); @@ -219,9 +215,9 @@ describe('mode', () => { }); }); - it('Should run all tests', () => { + it('should run all tests (ALL mode runs every test)', () => { expect(suite.get().testCount).toBe(0); // sanity - suite(); + suite.run(); expect(suite.get().testCount).toBe(6); expect(suite.get().errorCount).toBe(6); }); @@ -241,9 +237,9 @@ describe('mode', () => { }); }); - it('Should run all tests', () => { + it('should run all tests when none fail', () => { expect(suite.get().testCount).toBe(0); // sanity - suite(); + suite.run(); expect(suite.get().testCount).toBe(6); expect(suite.get().errorCount).toBe(0); }); @@ -262,9 +258,9 @@ describe('mode', () => { }); }); - it('Should skip all tests after a failed tests', () => { + it('should stop running further tests after the first failure', () => { expect(suite.get().testCount).toBe(0); // sanity - suite(); + suite.run(); expect(suite.get().testCount).toBe(3); expect(suite.get().errorCount).toBe(1); expect(suite.get().tests.field_1).toMatchObject({ diff --git a/packages/vest/src/hooks/__tests__/warn.test.ts b/packages/vest/src/hooks/__tests__/warn.test.ts index 44c9b2922..aef17d0df 100644 --- a/packages/vest/src/hooks/__tests__/warn.test.ts +++ b/packages/vest/src/hooks/__tests__/warn.test.ts @@ -1,37 +1,37 @@ import { faker } from '@faker-js/faker'; -import { ErrorStrings } from 'ErrorStrings'; -import { VestTest } from 'VestTest'; import { describe, it, expect, vi } from 'vitest'; +import { ErrorStrings } from 'ErrorStrings'; +import { VestTest } from 'VestTest'; import * as vest from 'vest'; const { create, test, warn } = vest; describe('warn hook', () => { describe('When currentTest exists', () => { - it('Should set warns to true', () => { + it('should set warns to true for the current test', () => { let t; create(() => { t = test(faker.lorem.word(), faker.lorem.sentence(), () => { warn(); }); - })(); + }).run(); expect(VestTest.warns(VestTest.cast(t))).toBe(true); }); }); describe('Error handling', () => { - it('Should throw error when currentTest is not present', () => { + it('should throw when called outside a test body', () => { const done = vi.fn(); create(() => { expect(warn).toThrow(ErrorStrings.WARN_MUST_BE_CALLED_FROM_TEST); done(); - })(); + }).run(); expect(done).toHaveBeenCalled(); }); - it('Should throw error when no suite present', () => { + it('should throw when called without an active suite', () => { expect(warn).toThrow(ErrorStrings.HOOK_CALLED_OUTSIDE); }); }); diff --git a/packages/vest/src/hooks/focused/__tests__/__snapshots__/focused.test.ts.snap b/packages/vest/src/hooks/focused/__tests__/__snapshots__/focused.test.ts.snap index ea0ac466a..8b457261d 100644 --- a/packages/vest/src/hooks/focused/__tests__/__snapshots__/focused.test.ts.snap +++ b/packages/vest/src/hooks/focused/__tests__/__snapshots__/focused.test.ts.snap @@ -2,7 +2,6 @@ exports[`Top Level Focus > Top Level Skip > When passing false > Should run all fields 1`] = ` Promise { - "done": [Function], "dump": [Function], "errorCount": 3, "errors": [ @@ -76,6 +75,7 @@ Promise { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -84,7 +84,6 @@ Promise { exports[`Top Level Focus > Top Level Skip > When passing undefined > Should run all fields 1`] = ` Promise { - "done": [Function], "dump": [Function], "errorCount": 3, "errors": [ @@ -158,6 +157,7 @@ Promise { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/hooks/focused/__tests__/focused.test.ts b/packages/vest/src/hooks/focused/__tests__/focused.test.ts index 7fd280424..cc96860b4 100644 --- a/packages/vest/src/hooks/focused/__tests__/focused.test.ts +++ b/packages/vest/src/hooks/focused/__tests__/focused.test.ts @@ -11,7 +11,7 @@ enum Fields { } function testSuite(callback: CB) { - return vest.staticSuite(callback)(); + return vest.create(callback).run(); } describe('Top Level Focus', () => { diff --git a/packages/vest/src/hooks/focused/focused.ts b/packages/vest/src/hooks/focused/focused.ts index a8fd22009..4b4cd42fa 100644 --- a/packages/vest/src/hooks/focused/focused.ts +++ b/packages/vest/src/hooks/focused/focused.ts @@ -10,16 +10,14 @@ import { import { IsolateSelectors, TIsolate, Isolate } from 'vestjs-runtime'; import { FocusModes } from 'FocusedKeys'; -import { TFieldName, TGroupName } from 'SuiteResultTypes'; +import { TFieldName } from 'SuiteResultTypes'; import { VestIsolateType } from 'VestIsolateType'; -export type ExclusionItem = Maybe>; export type FieldExclusion = Maybe>; -export type GroupExclusion = Maybe>; export type TIsolateFocused = TIsolate; -export type IsolateFocusedPayload = { +type IsolateFocusedPayload = { focusMode: FocusModes; match: FieldExclusion; matchAll: boolean; @@ -67,7 +65,6 @@ export class FocusSelectors { * * only('username'); */ -// @vx-allow use-use export function only(match: FieldExclusion | false) { return IsolateFocused(FocusModes.ONLY, defaultMatch(match)); } @@ -78,7 +75,6 @@ export function only(match: FieldExclusion | false) { * * skip('username'); */ -// @vx-allow use-use export function skip(match: FieldExclusion | boolean) { return IsolateFocused(FocusModes.SKIP, defaultMatch(match)); } diff --git a/packages/vest/src/hooks/focused/useIsExcluded.ts b/packages/vest/src/hooks/focused/useIsExcluded.ts index 702f0d697..4693c9805 100644 --- a/packages/vest/src/hooks/focused/useIsExcluded.ts +++ b/packages/vest/src/hooks/focused/useIsExcluded.ts @@ -1,4 +1,4 @@ -import { Nullable, optionalFunctionValue } from 'vest-utils'; +import { Nullable, dynamicValue } from 'vest-utils'; import { TIsolate, Walker } from 'vestjs-runtime'; import { TIsolateTest } from 'IsolateTest'; @@ -37,7 +37,7 @@ export function useIsExcluded(testObject: TIsolateTest): boolean { // If there is _ANY_ `only`ed test (and we already know this one isn't) return true if (useHasOnliedTests(testObject)) { // Check if inclusion rules for this field (`include` hook) - return !optionalFunctionValue(inclusion[fieldName], testObject); + return !dynamicValue(inclusion[fieldName], testObject); } // We're done here. This field is not excluded diff --git a/packages/vest/src/hooks/include.ts b/packages/vest/src/hooks/include.ts index c279cf5eb..27b5082e0 100644 --- a/packages/vest/src/hooks/include.ts +++ b/packages/vest/src/hooks/include.ts @@ -1,4 +1,4 @@ -import { isStringValue, invariant, optionalFunctionValue } from 'vest-utils'; +import { isStringValue, invariant, dynamicValue } from 'vest-utils'; import { ErrorStrings } from 'ErrorStrings'; import { TIsolateTest } from 'IsolateTest'; @@ -55,10 +55,7 @@ export function include( return useHasOnliedTests(currentNode, condition); } - return optionalFunctionValue( - condition, - optionalFunctionValue(useCreateSuiteResult), - ); + return dynamicValue(condition, dynamicValue(useCreateSuiteResult)); }; } } diff --git a/packages/vest/src/hooks/optional/__tests__/optional.test.ts b/packages/vest/src/hooks/optional/__tests__/optional.test.ts index b20754f20..8b3930dee 100644 --- a/packages/vest/src/hooks/optional/__tests__/optional.test.ts +++ b/packages/vest/src/hooks/optional/__tests__/optional.test.ts @@ -1,4 +1,4 @@ -import { TTestSuite } from 'testUtils/TVestMock'; +import { TTestSuite } from 'TVestMock'; import { BlankValue } from 'vest-utils'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import wait from 'wait'; @@ -18,7 +18,7 @@ describe('optional hook', () => { vest.test('f2', () => false); }); - const res = suite({ f1: '', f2: '' }); + const res = suite.run({ f1: '', f2: '' }); expect(res.hasErrors('f1')).toBe(false); expect(res.hasErrors('f2')).toBe(false); @@ -35,7 +35,7 @@ describe('optional hook', () => { vest.test('f2', () => false); }); - const res = suite({ f1: null, f2: null }); + const res = suite.run({ f1: null, f2: null }); expect(res.hasErrors('f1')).toBe(false); expect(res.hasErrors('f2')).toBe(false); @@ -53,7 +53,7 @@ describe('optional hook', () => { vest.test('f1', () => false); }); - const res = suite({ f1: 'foo' }); + const res = suite.run({ f1: 'foo' }); expect(res.hasErrors('f1')).toBe(false); expect(res.isValid('f1')).toBe(true); @@ -72,7 +72,7 @@ describe('optional hook', () => { vest.test('f2', () => false); }); - const res = suite({}); + const res = suite.run({}); expect(res.hasErrors('f1')).toBe(true); expect(res.hasErrors('f2')).toBe(true); @@ -92,7 +92,7 @@ describe('optional hook', () => { vest.test('f2', () => false); }); - const res = suite({}); + const res = suite.run({}); expect(res.hasErrors('f1')).toBe(true); expect(res.hasErrors('f2')).toBe(false); @@ -117,7 +117,7 @@ describe('optional hook', () => { vest.test('f2', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('f1')).toBe(false); expect(res.hasErrors('f2')).toBe(false); @@ -136,7 +136,7 @@ describe('optional hook', () => { vest.test('f1', fn); }); - suite(); + suite.run(); expect(fn).toHaveBeenCalled(); expect(suite.hasErrors('f1')).toBe(false); @@ -154,7 +154,7 @@ describe('optional hook', () => { vest.test('f2', () => true); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('f1')).toBe(false); expect(res.hasErrors('f2')).toBe(false); @@ -175,7 +175,7 @@ describe('optional hook', () => { vest.test('f1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('f1')).toBe(false); expect(res.isValid('f1')).toBe(true); @@ -194,7 +194,7 @@ describe('optional hook', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(false); expect(res.isValid('field_1')).toBe(true); @@ -210,7 +210,7 @@ describe('optional hook', () => { vest.test('field_1', fn); }); - suite(); + suite.run(); expect(fn).not.toHaveBeenCalled(); }); @@ -225,7 +225,7 @@ describe('optional hook', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.isValid('field_1')).toBe(false); @@ -246,7 +246,7 @@ describe('optional hook', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(false); expect(res.isValid('field_1')).toBe(true); @@ -264,7 +264,7 @@ describe('optional hook', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.isValid('field_1')).toBe(false); @@ -282,7 +282,7 @@ describe('optional hook', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(false); expect(res.isValid('field_1')).toBe(true); @@ -299,7 +299,7 @@ describe('optional hook', () => { vest.test('field_1', () => false); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors('field_1')).toBe(true); expect(res.isValid('field_1')).toBe(false); @@ -336,23 +336,23 @@ describe('optional hook', () => { }); it('Should omit tests based on other tests in the suite', () => { - expect(suite({ a: 1, b: 0, c: 0 }).isValid('a')).toBe(true); - expect(suite({ a: 1, b: 0, c: 0 }).isValid('b')).toBe(true); - expect(suite({ a: 1, b: 0, c: 0 }).isValid('c')).toBe(true); - expect(suite({ a: 1, b: 0, c: 0 }).isValid()).toBe(true); - expect(suite({ a: 0, b: 1, c: 0 }).isValid('a')).toBe(true); - expect(suite({ a: 0, b: 1, c: 0 }).isValid('b')).toBe(true); - expect(suite({ a: 0, b: 1, c: 0 }).isValid('c')).toBe(true); - expect(suite({ a: 0, b: 1, c: 0 }).isValid()).toBe(true); - expect(suite({ a: 0, b: 0, c: 1 }).isValid('a')).toBe(true); - expect(suite({ a: 0, b: 0, c: 1 }).isValid('b')).toBe(true); - expect(suite({ a: 0, b: 0, c: 1 }).isValid('c')).toBe(true); - expect(suite({ a: 0, b: 0, c: 1 }).isValid()).toBe(true); + expect(suite.run({ a: 1, b: 0, c: 0 }).isValid('a')).toBe(true); + expect(suite.run({ a: 1, b: 0, c: 0 }).isValid('b')).toBe(true); + expect(suite.run({ a: 1, b: 0, c: 0 }).isValid('c')).toBe(true); + expect(suite.run({ a: 1, b: 0, c: 0 }).isValid()).toBe(true); + expect(suite.run({ a: 0, b: 1, c: 0 }).isValid('a')).toBe(true); + expect(suite.run({ a: 0, b: 1, c: 0 }).isValid('b')).toBe(true); + expect(suite.run({ a: 0, b: 1, c: 0 }).isValid('c')).toBe(true); + expect(suite.run({ a: 0, b: 1, c: 0 }).isValid()).toBe(true); + expect(suite.run({ a: 0, b: 0, c: 1 }).isValid('a')).toBe(true); + expect(suite.run({ a: 0, b: 0, c: 1 }).isValid('b')).toBe(true); + expect(suite.run({ a: 0, b: 0, c: 1 }).isValid('c')).toBe(true); + expect(suite.run({ a: 0, b: 0, c: 1 }).isValid()).toBe(true); }); describe('when focused with vest.only', () => { it('Should keep optional fields even if not all tests ran yet', () => { - const res = suite({ a: 1, b: 0, c: 0 }, 'a'); + const res = suite.run({ a: 1, b: 0, c: 0 }, 'a'); expect(res.isValid('a')).toBe(true); expect(res.isValid('b')).toBe(true); expect(res.isValid('c')).toBe(true); @@ -360,13 +360,13 @@ describe('optional hook', () => { }); it('Should revert optional fields when none of the fields are valid anymore', () => { - let res = suite({ a: 1, b: 0, c: 0 }, 'a'); + let res = suite.run({ a: 1, b: 0, c: 0 }, 'a'); expect(res.isValid()).toBe(true); - res = suite({ a: 0, b: 0, c: 0 }, 'a'); + res = suite.run({ a: 0, b: 0, c: 0 }, 'a'); expect(res.isValid()).toBe(false); - res = suite({ a: 0, b: 1, c: 0 }, 'b'); + res = suite.run({ a: 0, b: 1, c: 0 }, 'b'); expect(res.isValid()).toBe(true); - res = suite({ a: 0, b: 0, c: 0 }, 'b'); + res = suite.run({ a: 0, b: 0, c: 0 }, 'b'); expect(res.isValid()).toBe(false); }); }); @@ -387,14 +387,14 @@ describe('optional hook', () => { }); describe('Before the test completed', () => { it('Should be considered as non-valid', () => { - const res = suite(); + const res = suite.run(); expect(res.isValid()).toBe(false); expect(res.isValid('field_1')).toBe(false); }); }); describe('After the test completed', () => { it('Should be considered as non-valid', () => { - const res = suite(); + const res = suite.run(); vi.runAllTimers(); expect(res.isValid()).toBe(false); expect(res.isValid('field_1')).toBe(false); @@ -417,7 +417,7 @@ describe('optional hook', () => { describe('Before the test completed', () => { it('Should be considered as non-valid', () => { - const res = suite(); + const res = suite.run(); expect(res.isValid('field_1')).toBe(false); expect(res.isValid()).toBe(false); }); @@ -425,7 +425,7 @@ describe('optional hook', () => { describe('After the test completed', () => { it('Should be considered as valid', async () => { - suite(); + suite.run(); await vi.runAllTimersAsync(); expect(suite.isValid()).toBe(true); expect(suite.isValid('field_1')).toBe(true); @@ -440,7 +440,9 @@ describe('optional hook', () => { beforeEach(() => { suite = vest.create(() => { vest.optional({ - field_1: () => suite.get().isValid('field_2'), + field_1: () => { + return suite.get().isValid('field_2'); + }, }); vest.test('field_1', () => { vest.enforce(1).equals(2); @@ -454,7 +456,7 @@ describe('optional hook', () => { describe('Before the test completed', () => { it('Should be considered as not valid', () => { - const res = suite(); + const res = suite.run(); expect(res.isValid()).toBe(false); expect(res.isValid('field_1')).toBe(false); expect(res.isValid('field_2')).toBe(false); @@ -463,7 +465,7 @@ describe('optional hook', () => { describe('After the test completed', () => { it('Should be considered as valid', async () => { - suite(); + suite.run(); await vi.runAllTimersAsync(); expect(suite.isValid('field_2')).toBe(true); expect(suite.isValid()).toBe(true); diff --git a/packages/vest/src/hooks/optional/mode.ts b/packages/vest/src/hooks/optional/mode.ts index 99a707319..14c4c2e4e 100644 --- a/packages/vest/src/hooks/optional/mode.ts +++ b/packages/vest/src/hooks/optional/mode.ts @@ -15,7 +15,7 @@ import { hasErrorsByTestObjects } from 'hasFailuresByTestObjects'; * ```js * import {Modes, create} from 'vest'; * - * const suite = create('suite_name', () => { + * const suite = create(() => { * vest.mode(Modes.ALL); * * // ... diff --git a/packages/vest/src/hooks/optional/omitOptionalFields.ts b/packages/vest/src/hooks/optional/omitOptionalFields.ts index 2d832a8ab..77dd327dc 100644 --- a/packages/vest/src/hooks/optional/omitOptionalFields.ts +++ b/packages/vest/src/hooks/optional/omitOptionalFields.ts @@ -1,4 +1,4 @@ -import { isEmpty, optionalFunctionValue } from 'vest-utils'; +import { isEmpty, dynamicValue } from 'vest-utils'; import { Bus, VestRuntime } from 'vestjs-runtime'; import { SuiteOptionalFields, TIsolateSuite } from 'IsolateSuite'; @@ -66,7 +66,7 @@ export function useOmitOptionalFields(): void { ); // If the optional was set to a function or a boolean, run it and verify/omit the test - if (optionalFunctionValue(optionalConfig.rule) === true) { + if (dynamicValue(optionalConfig.rule) === true) { shouldOmit.add(fieldName); } diff --git a/packages/vest/src/isolates/__tests__/__snapshots__/each.test.ts.snap b/packages/vest/src/isolates/__tests__/__snapshots__/each.test.ts.snap index e1bd5009f..5ec92e276 100644 --- a/packages/vest/src/isolates/__tests__/__snapshots__/each.test.ts.snap +++ b/packages/vest/src/isolates/__tests__/__snapshots__/each.test.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`each > When callback is not a function > should throw 1`] = `[Error: Each must be called with a function]`; +exports[`each > when callback is not a function > should throw an error 1`] = `[Error: Each must be called with a function]`; diff --git a/packages/vest/src/isolates/__tests__/__snapshots__/group.test.ts.snap b/packages/vest/src/isolates/__tests__/__snapshots__/group.test.ts.snap index d2b3b45de..1242452ab 100644 --- a/packages/vest/src/isolates/__tests__/__snapshots__/group.test.ts.snap +++ b/packages/vest/src/isolates/__tests__/__snapshots__/group.test.ts.snap @@ -1,7 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`named group > Focus > only > group only > Should skip all tests except \`only\` tests within the group 1`] = ` +exports[`named group > focus behavior > only > group only > should run only tests for the focused field within the group and run everything in other groups 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 4, "errors": [ SummaryFailure { @@ -132,14 +133,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Focus > only > top level only > should skip all tests except \`only\` tests 1`] = ` +exports[`named group > focus behavior > only > top level only > should run only tests for the focused field across all groups 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -265,14 +268,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Focus > skip inside the group > should skip only within the group 1`] = ` +exports[`named group > focus behavior > skip inside the group > should skip the field only within the current group 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -351,14 +356,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Focus > skip inside the group > should skip only within the group, not the next group 1`] = ` +exports[`named group > focus behavior > skip inside the group > should skip the field only within the current group and not affect the next group 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -448,14 +455,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Focus > skip inside the group > skip(true) > should skip only within the group 1`] = ` +exports[`named group > focus behavior > skip inside the group > skip(true) > should skip all tests in the current group only 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 5, "errors": [ SummaryFailure { @@ -573,14 +582,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Focus > skip outside of group > Should skip \`skipped\` tests both inside and outside the group 1`] = ` +exports[`named group > focus behavior > skip outside of group > should skip tests for a field both inside and outside the group when skipped before grouping 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -649,14 +660,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Multiple groups > Should run the tests within the groups 1`] = ` +exports[`named group > multiple named groups > should run the tests inside each named group 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 5, "errors": [ SummaryFailure { @@ -792,14 +805,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`named group > Should run the tests within the group 1`] = ` +exports[`named group > should run the tests inside the named group 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -896,14 +911,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`unnamed groups > Should complete without adding the group to the results object 1`] = ` +exports[`unnamed groups > should not add the unnamed group to the results object 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -970,14 +987,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`unnamed groups > Should run tests normally 1`] = ` +exports[`unnamed groups > should run tests inside the unnamed group normally 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 3, "errors": [ SummaryFailure { @@ -1044,16 +1063,24 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`unnamed groups > With skip(true) > Should skip all tests in group 1`] = ` +exports[`unnamed groups > with only > should run only tests for the focused field inside the unnamed group 1`] = ` SuiteSummary { - "errorCount": 0, - "errors": [], + "dump": [Function], + "errorCount": 1, + "errors": [ + SummaryFailure { + "fieldName": "f1", + "groupName": undefined, + "message": undefined, + }, + ], "getError": [Function], "getErrors": [Function], "getErrorsByGroup": [Function], @@ -1072,13 +1099,13 @@ SuiteSummary { "isValidByGroup": [Function], "pendingCount": 0, "suiteName": undefined, - "testCount": 0, + "testCount": 1, "tests": { "f1": SummaryBase { - "errorCount": 0, + "errorCount": 1, "errors": [], "pendingCount": 0, - "testCount": 0, + "testCount": 1, "valid": false, "warnCount": 0, "warnings": [], @@ -1102,18 +1129,25 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`unnamed groups > with only > Should only run the tests specified by only 1`] = ` +exports[`unnamed groups > with skip > should skip tests for the specified field only 1`] = ` SuiteSummary { - "errorCount": 1, + "dump": [Function], + "errorCount": 2, "errors": [ SummaryFailure { - "fieldName": "f1", + "fieldName": "f2", + "groupName": undefined, + "message": undefined, + }, + SummaryFailure { + "fieldName": "f3", "groupName": undefined, "message": undefined, }, @@ -1136,57 +1170,48 @@ SuiteSummary { "isValidByGroup": [Function], "pendingCount": 0, "suiteName": undefined, - "testCount": 1, + "testCount": 2, "tests": { "f1": SummaryBase { - "errorCount": 1, + "errorCount": 0, "errors": [], "pendingCount": 0, - "testCount": 1, + "testCount": 0, "valid": false, "warnCount": 0, "warnings": [], }, "f2": SummaryBase { - "errorCount": 0, + "errorCount": 1, "errors": [], "pendingCount": 0, - "testCount": 0, + "testCount": 1, "valid": false, "warnCount": 0, "warnings": [], }, "f3": SummaryBase { - "errorCount": 0, + "errorCount": 1, "errors": [], "pendingCount": 0, - "testCount": 0, + "testCount": 1, "valid": false, "warnCount": 0, "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`unnamed groups > with skip > Should skip the tests specified by skip 1`] = ` +exports[`unnamed groups > with skip(true) > should skip all tests inside the unnamed group 1`] = ` SuiteSummary { - "errorCount": 2, - "errors": [ - SummaryFailure { - "fieldName": "f2", - "groupName": undefined, - "message": undefined, - }, - SummaryFailure { - "fieldName": "f3", - "groupName": undefined, - "message": undefined, - }, - ], + "dump": [Function], + "errorCount": 0, + "errors": [], "getError": [Function], "getErrors": [Function], "getErrorsByGroup": [Function], @@ -1205,7 +1230,7 @@ SuiteSummary { "isValidByGroup": [Function], "pendingCount": 0, "suiteName": undefined, - "testCount": 2, + "testCount": 0, "tests": { "f1": SummaryBase { "errorCount": 0, @@ -1217,24 +1242,25 @@ SuiteSummary { "warnings": [], }, "f2": SummaryBase { - "errorCount": 1, + "errorCount": 0, "errors": [], "pendingCount": 0, - "testCount": 1, + "testCount": 0, "valid": false, "warnCount": 0, "warnings": [], }, "f3": SummaryBase { - "errorCount": 1, + "errorCount": 0, "errors": [], "pendingCount": 0, - "testCount": 1, + "testCount": 0, "valid": false, "warnCount": 0, "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/isolates/__tests__/__snapshots__/omitWhen.test.ts.snap b/packages/vest/src/isolates/__tests__/__snapshots__/omitWhen.test.ts.snap index 5fc45e43d..86f593677 100644 --- a/packages/vest/src/isolates/__tests__/__snapshots__/omitWhen.test.ts.snap +++ b/packages/vest/src/isolates/__tests__/__snapshots__/omitWhen.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`omitWhen > When conditional is falsy > boolean conditional > Should have all tests within the omit block referenced in the result 1`] = ` +exports[`omitWhen > when conditional is falsy > boolean conditional > should include all fields inside the omitWhen block in the result object 1`] = ` { "field_1": { "errorCount": 1, @@ -41,7 +41,7 @@ exports[`omitWhen > When conditional is falsy > boolean conditional > Should hav } `; -exports[`omitWhen > When conditional is falsy > boolean conditional > Should run tests normally 1`] = ` +exports[`omitWhen > when conditional is falsy > boolean conditional > should run tests normally 1`] = ` { "field_1": { "errorCount": 1, @@ -82,7 +82,7 @@ exports[`omitWhen > When conditional is falsy > boolean conditional > Should run } `; -exports[`omitWhen > When conditional is falsy > boolean conditional > Should run tests normally 2`] = ` +exports[`omitWhen > when conditional is falsy > boolean conditional > should run tests normally 2`] = ` { "field_1": { "errorCount": 1, @@ -123,7 +123,7 @@ exports[`omitWhen > When conditional is falsy > boolean conditional > Should run } `; -exports[`omitWhen > When conditional is falsy > function conditional > Should have all tests within the omit block referenced in the result 1`] = ` +exports[`omitWhen > when conditional is falsy > function conditional > should include all fields inside the omitWhen block in the result object 1`] = ` { "field_1": { "errorCount": 1, @@ -164,7 +164,7 @@ exports[`omitWhen > When conditional is falsy > function conditional > Should ha } `; -exports[`omitWhen > When conditional is falsy > function conditional > Should run tests normally 1`] = ` +exports[`omitWhen > when conditional is falsy > function conditional > should run tests normally 1`] = ` { "field_1": { "errorCount": 1, @@ -205,7 +205,7 @@ exports[`omitWhen > When conditional is falsy > function conditional > Should ru } `; -exports[`omitWhen > When conditional is falsy > function conditional > Should run tests normally 2`] = ` +exports[`omitWhen > when conditional is falsy > function conditional > should run tests normally 2`] = ` { "field_1": { "errorCount": 1, @@ -246,8 +246,9 @@ exports[`omitWhen > When conditional is falsy > function conditional > Should ru } `; -exports[`omitWhen > When conditional is truthy > boolean conditional > Should avoid running the omitted tests 1`] = ` +exports[`omitWhen > when conditional is truthy > boolean conditional > should avoid running the omitted tests 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -313,14 +314,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`omitWhen > When conditional is truthy > boolean conditional > Should skip and not run omitted fields when no filter provided 1`] = ` +exports[`omitWhen > when conditional is truthy > boolean conditional > should skip and not run omitted fields when no field filter is provided 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -386,14 +389,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`omitWhen > When conditional is truthy > function conditional > Should avoid running the omitted tests 1`] = ` +exports[`omitWhen > when conditional is truthy > function conditional > should avoid running the omitted tests 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -459,14 +464,16 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`omitWhen > When conditional is truthy > function conditional > Should skip and not run omitted fields when no filter provided 1`] = ` +exports[`omitWhen > when conditional is truthy > function conditional > should skip and not run omitted fields when no field filter is provided 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 1, "errors": [ SummaryFailure { @@ -532,6 +539,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/isolates/__tests__/__snapshots__/skipWhen.test.ts.snap b/packages/vest/src/isolates/__tests__/__snapshots__/skipWhen.test.ts.snap index 504aabb55..058535985 100644 --- a/packages/vest/src/isolates/__tests__/__snapshots__/skipWhen.test.ts.snap +++ b/packages/vest/src/isolates/__tests__/__snapshots__/skipWhen.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`skipWhen > Should pass result draft to the functional condition 1`] = ` +exports[`skipWhen > should pass the current result draft to the functional condition 1`] = ` SuiteSummary { "errorCount": 0, "errors": [], @@ -24,13 +24,14 @@ SuiteSummary { "suiteName": undefined, "testCount": 0, "tests": {}, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`skipWhen > Should pass result draft to the functional condition 2`] = ` +exports[`skipWhen > should pass the current result draft to the functional condition 2`] = ` SuiteSummary { "errorCount": 1, "errors": [ @@ -72,13 +73,14 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`skipWhen > Should pass result draft to the functional condition 3`] = ` +exports[`skipWhen > should pass the current result draft to the functional condition 3`] = ` SuiteSummary { "errorCount": 2, "errors": [ @@ -136,6 +138,7 @@ SuiteSummary { "warnings": [], }, }, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/isolates/__tests__/each.test.ts b/packages/vest/src/isolates/__tests__/each.test.ts index d50a9cdab..1cf066983 100644 --- a/packages/vest/src/isolates/__tests__/each.test.ts +++ b/packages/vest/src/isolates/__tests__/each.test.ts @@ -12,8 +12,8 @@ vi.mock('vest-utils', async () => { }); describe('each', () => { - describe('When callback is not a function', () => { - it('should throw', () => { + describe('when callback is not a function', () => { + it('should throw an error', () => { const control = vi.fn(); const suite = vest.create(() => { expect(() => { @@ -23,18 +23,18 @@ describe('each', () => { control(); }); - suite(); + suite.run(); expect(control).toHaveBeenCalledTimes(1); }); }); - it('Should pass to callback the current list item and index', () => { + it('should pass the current list item and index to the callback', () => { const cb = vi.fn(); const suite = vest.create(() => { vest.each([1, 2, 3, 'str'], cb); }); - suite(); + suite.run(); expect(cb).toHaveBeenCalledTimes(4); @@ -44,21 +44,21 @@ describe('each', () => { expect(cb).toHaveBeenNthCalledWith(4, 'str', 3); }); - describe('Test Reorder', () => { - it('Should allow reorder', () => { + describe('test reorder', () => { + it('should allow reordering within each()', () => { const suite = vest.create(() => { vest.each([0, 1], v => { vest.test(v === 0 ? 'a' : 'b', 'test', () => false); }); }); - suite(); + suite.run(); - expect(() => suite()).not.toThrow(); + expect(() => suite.run()).not.toThrow(); }); - describe('Sanity', () => { - it('Should disallow reorder outside of each', () => { + describe('sanity', () => { + it('should disallow reordering outside of each()', () => { let firstRun = true; const suite = vest.create(() => { if (firstRun) { @@ -69,14 +69,14 @@ describe('each', () => { firstRun = false; }); - suite(); - suite(); + suite.run(); + suite.run(); expect(deferThrow).toHaveBeenCalled(); }); }); }); - it('Should retain failed/passing tests even after skipping', () => { + it('should retain failed/passing tests even after skipping runs', () => { let run = 0; const suite = vest.create((data: number[], only: number) => { vest.only(`item.${only}`); @@ -93,7 +93,7 @@ describe('each', () => { run++; }); const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - data.forEach((_, idx) => suite(data, idx + 1)); + data.forEach((_, idx) => suite.run(data, idx + 1)); expect(suite.get().errors).toHaveLength(5); expect(suite.hasErrors('item.1')).toBe(false); expect(suite.hasErrors('item.2')).toBe(true); diff --git a/packages/vest/src/isolates/__tests__/group.test.ts b/packages/vest/src/isolates/__tests__/group.test.ts index 335ddc7e9..719b11846 100644 --- a/packages/vest/src/isolates/__tests__/group.test.ts +++ b/packages/vest/src/isolates/__tests__/group.test.ts @@ -15,16 +15,18 @@ enum FieldNames { } describe('named group', () => { - it('should run group callback', () => { + it('should run the group callback', () => { const groupName = 'groupName'; const callback = vi.fn(); - vest.create(() => { - vest.group(groupName, callback); - })(); + vest + .create(() => { + vest.group(groupName, callback); + }) + .run(); expect(callback).toHaveBeenCalled(); }); - it('Should run the tests within the group', () => { + it('should run the tests inside the named group', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => {}); @@ -39,7 +41,7 @@ describe('named group', () => { }); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(res.hasErrors(FieldNames.F3)).toBe(false); @@ -51,10 +53,6 @@ describe('named group', () => { expect(cb2).toHaveBeenCalled(); expect(cb3).toHaveBeenCalled(); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F3)).toBe(true); expect(res.tests[FieldNames.F1].testCount).toBe(2); expect(res.tests[FieldNames.F1].pendingCount).toBe(0); expect(res.tests[FieldNames.F2].testCount).toBe(1); @@ -76,8 +74,8 @@ describe('named group', () => { expect(suite.get()).toMatchSnapshot(); }); - describe('Multiple groups', () => { - it('Should run the tests within the groups', () => { + describe('multiple named groups', () => { + it('should run the tests inside each named group', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => {}); @@ -98,7 +96,7 @@ describe('named group', () => { }); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(res.hasErrors(FieldNames.F3)).toBe(false); @@ -114,15 +112,7 @@ describe('named group', () => { expect(cb2).toHaveBeenCalledTimes(2); expect(cb3).toHaveBeenCalledTimes(2); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F3)).toBe(true); - expect(res.isValidByGroup(GroupNames.G2)).toBe(false); - - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F3)).toBe(true); + expect(res.tests[FieldNames.F1].testCount).toBe(3); expect(res.tests[FieldNames.F2].testCount).toBe(2); expect(res.tests[FieldNames.F3].testCount).toBe(2); @@ -145,9 +135,9 @@ describe('named group', () => { }); }); - describe('Focus', () => { + describe('focus behavior', () => { describe('skip outside of group', () => { - it('Should skip `skipped` tests both inside and outside the group', () => { + it('should skip tests for a field both inside and outside the group when skipped before grouping', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const suite = vest.create(() => { @@ -161,7 +151,7 @@ describe('named group', () => { vest.test(FieldNames.F2, cb2); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).not.toHaveBeenCalled(); expect(cb2).toHaveBeenCalledTimes(1); expect(res.tests[FieldNames.F1].testCount).toBe(0); @@ -169,9 +159,6 @@ describe('named group', () => { expect(res.groups[GroupNames.G1][FieldNames.F1].testCount).toBe(0); expect(res.groups[GroupNames.G1][FieldNames.F2].testCount).toBe(1); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); expect(res.hasErrors(FieldNames.F1)).toBe(false); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(suite.get()).toMatchSnapshot(); @@ -179,7 +166,7 @@ describe('named group', () => { }); describe('skip inside the group', () => { - it('should skip only within the group', () => { + it('should skip the field only within the current group', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -194,7 +181,7 @@ describe('named group', () => { }); vest.test(FieldNames.F2, cb3); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(2); expect(cb2).toHaveBeenCalledTimes(0); expect(cb3).toHaveBeenCalledTimes(1); @@ -203,15 +190,12 @@ describe('named group', () => { expect(res.groups[GroupNames.G1][FieldNames.F1].testCount).toBe(1); expect(res.groups[GroupNames.G1][FieldNames.F2].testCount).toBe(0); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(suite.get()).toMatchSnapshot(); }); - it('should skip only within the group, not the next group', () => { + it('should skip the field only within the current group and not affect the next group', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -228,7 +212,7 @@ describe('named group', () => { vest.test(FieldNames.F2, cb3); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(2); expect(cb2).toHaveBeenCalledTimes(0); expect(cb3).toHaveBeenCalledTimes(1); @@ -238,18 +222,13 @@ describe('named group', () => { expect(res.groups[GroupNames.G1][FieldNames.F2].testCount).toBe(0); expect(res.groups[GroupNames.G2][FieldNames.F2].testCount).toBe(1); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F2)).toBe(false); expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(suite.get()).toMatchSnapshot(); }); describe('skip(true)', () => { - it('should skip only within the group', () => { + it('should skip all tests in the current group only', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -270,7 +249,7 @@ describe('named group', () => { vest.test(FieldNames.F2, cb2); vest.test(FieldNames.F3, cb3); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(0); expect(cb2).toHaveBeenCalledTimes(2); expect(cb3).toHaveBeenCalledTimes(3); @@ -282,12 +261,6 @@ describe('named group', () => { expect(res.groups[GroupNames.G2][FieldNames.F2].testCount).toBe(1); expect(res.groups[GroupNames.G2][FieldNames.F3].testCount).toBe(1); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F3)).toBe(false); expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(res.hasErrors(FieldNames.F3)).toBe(true); @@ -298,7 +271,7 @@ describe('named group', () => { describe('only', () => { describe('top level only', () => { - it('should skip all tests except `only` tests', () => { + it('should run only tests for the focused field across all groups', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -322,7 +295,7 @@ describe('named group', () => { vest.test(FieldNames.F3, cb4); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(1); expect(cb3).toHaveBeenCalledTimes(1); @@ -337,15 +310,7 @@ describe('named group', () => { expect(res.groups[GroupNames.G2][FieldNames.F2].testCount).toBe(0); expect(res.groups[GroupNames.G2][FieldNames.F3].testCount).toBe(0); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F3)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F2)).toBe(false); - - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F3)).toBe(false); + expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(false); expect(res.hasErrors(FieldNames.F3)).toBe(false); @@ -354,7 +319,7 @@ describe('named group', () => { }); describe('group only', () => { - it('Should skip all tests except `only` tests within the group', () => { + it('should run only tests for the focused field within the group and run everything in other groups', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -374,7 +339,7 @@ describe('named group', () => { vest.test(FieldNames.F3, cb3); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(0); expect(cb3).toHaveBeenCalledTimes(3); @@ -388,14 +353,6 @@ describe('named group', () => { expect(res.groups[GroupNames.G2][FieldNames.F2].testCount).toBe(1); expect(res.groups[GroupNames.G2][FieldNames.F3].testCount).toBe(1); expect(res.isValid()).toBe(false); - expect(res.isValidByGroup(GroupNames.G1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G1, FieldNames.F3)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F1)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F2)).toBe(false); - expect(res.isValidByGroup(GroupNames.G2, FieldNames.F3)).toBe(false); expect(res.hasErrors(FieldNames.F1)).toBe(true); expect(res.hasErrors(FieldNames.F2)).toBe(true); expect(res.hasErrors(FieldNames.F3)).toBe(true); @@ -407,7 +364,7 @@ describe('named group', () => { }); describe('unnamed groups', () => { - it('Should run tests normally', () => { + it('should run tests inside the unnamed group normally', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -420,7 +377,7 @@ describe('unnamed groups', () => { vest.test(FieldNames.F3, cb3); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(1); expect(cb3).toHaveBeenCalledTimes(1); @@ -434,7 +391,7 @@ describe('unnamed groups', () => { expect(suite.get()).toMatchSnapshot(); }); - it('Should complete without adding the group to the results object', () => { + it('should not add the unnamed group to the results object', () => { const suite = vest.create(() => { vest.group(() => { vest.test(FieldNames.F1, () => false); @@ -442,14 +399,14 @@ describe('unnamed groups', () => { vest.test(FieldNames.F3, () => false); }); }); - const res = suite(); + const res = suite.run(); expect(res.groups).toEqual({}); expect(res.isValid()).toBe(false); expect(suite.get()).toMatchSnapshot(); }); describe('with only', () => { - it('Should only run the tests specified by only', () => { + it('should run only tests for the focused field inside the unnamed group', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -463,7 +420,7 @@ describe('unnamed groups', () => { vest.test(FieldNames.F3, cb3); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(0); expect(cb3).toHaveBeenCalledTimes(0); @@ -479,7 +436,7 @@ describe('unnamed groups', () => { }); describe('with skip', () => { - it('Should skip the tests specified by skip', () => { + it('should skip tests for the specified field only', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -493,7 +450,7 @@ describe('unnamed groups', () => { vest.test(FieldNames.F3, cb3); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(0); expect(cb2).toHaveBeenCalledTimes(1); expect(cb3).toHaveBeenCalledTimes(1); @@ -508,8 +465,8 @@ describe('unnamed groups', () => { }); }); - describe('With skip(true)', () => { - it('Should skip all tests in group', () => { + describe('with skip(true)', () => { + it('should skip all tests inside the unnamed group', () => { const cb1 = vi.fn(() => false); const cb2 = vi.fn(() => false); const cb3 = vi.fn(() => false); @@ -523,7 +480,7 @@ describe('unnamed groups', () => { vest.test(FieldNames.F3, cb3); }); }); - const res = suite(); + const res = suite.run(); expect(cb1).toHaveBeenCalledTimes(0); expect(cb2).toHaveBeenCalledTimes(0); expect(cb3).toHaveBeenCalledTimes(0); @@ -539,8 +496,8 @@ describe('unnamed groups', () => { }); }); -describe('pendingCount within group', () => { - it('Should count pending tests within group', () => { +describe('pending count within group', () => { + it('should count pending tests within the group', () => { const suite = vest.create(() => { vest.test(FieldNames.F1, () => {}); vest.group(GroupNames.G1, () => { @@ -551,7 +508,7 @@ describe('pendingCount within group', () => { }); }); - const res = suite(); + const res = suite.run(); expect(res.tests[FieldNames.F1].pendingCount).toBe(1); expect(res.tests[FieldNames.F2].pendingCount).toBe(2); diff --git a/packages/vest/src/isolates/__tests__/omitWhen.test.ts b/packages/vest/src/isolates/__tests__/omitWhen.test.ts index fd47813a3..db17115fa 100644 --- a/packages/vest/src/isolates/__tests__/omitWhen.test.ts +++ b/packages/vest/src/isolates/__tests__/omitWhen.test.ts @@ -1,4 +1,4 @@ -import { TTestSuite } from 'testUtils/TVestMock'; +import { TTestSuite } from 'TVestMock'; import { Maybe } from 'vest-utils'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; @@ -38,13 +38,13 @@ describe('omitWhen', () => { allFieldsPass = undefined; }); - describe('When conditional is falsy', () => { + describe('when conditional is falsy', () => { describe.each([ ['boolean conditional', false], ['function conditional', () => false], ])('%s', (_, omitConditional) => { - it('Should run tests normally', () => { - suite(omitConditional, 'field_1'); + it('should run tests normally', () => { + suite.run(omitConditional, 'field_1'); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).not.toHaveBeenCalled(); expect(cb3).toHaveBeenCalledTimes(1); @@ -57,7 +57,7 @@ describe('omitWhen', () => { expect(suite.get().hasErrors('field_3')).toBe(false); expect(suite.get().hasErrors('field_4')).toBe(false); expect(suite.get().tests).toMatchSnapshot(); - suite(omitConditional, 'field_4'); + suite.run(omitConditional, 'field_4'); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).not.toHaveBeenCalled(); expect(cb3).toHaveBeenCalledTimes(1); @@ -74,45 +74,45 @@ describe('omitWhen', () => { expect(suite.get().tests).toMatchSnapshot(); }); - it('Should have all tests within the omit block referenced in the result', () => { - suite(omitConditional, 'field_1'); + it('should include all fields inside the omitWhen block in the result object', () => { + suite.run(omitConditional, 'field_1'); expect(suite.get().tests.field_1).toBeDefined(); expect(suite.get().tests.field_3).toBeDefined(); expect(suite.get().tests.field_4).toBeDefined(); expect(suite.get().tests).toMatchSnapshot(); }); - it('Should retain normal `isValid` functionality', () => { + it('should retain normal isValid functionality', () => { expect(suite.get().isValid()).toBe(false); - suite(omitConditional, 'field_1'); + suite.run(omitConditional, 'field_1'); expect(suite.get().isValid()).toBe(false); allFieldsPass = true; - suite(omitConditional); + suite.run(omitConditional); expect(suite.get().isValid()).toBe(true); }); }); }); - describe('When conditional is truthy', () => { + describe('when conditional is truthy', () => { describe.each([ ['boolean conditional', true], ['function conditional', () => true], ])('%s', (_, omitConditional) => { - it('Should avoid running the omitted tests', () => { - suite(omitConditional, 'field_1'); + it('should avoid running the omitted tests', () => { + suite.run(omitConditional, 'field_1'); expect(suite.get().tests.field_1.testCount).toBe(1); - suite(omitConditional, 'field_2'); + suite.run(omitConditional, 'field_2'); expect(suite.get().tests.field_2.testCount).toBe(1); - suite(omitConditional, 'field_3'); + suite.run(omitConditional, 'field_3'); expect(suite.get().tests.field_3.testCount).toBe(0); - suite(omitConditional, 'field_4'); + suite.run(omitConditional, 'field_4'); expect(suite.get().tests.field_4.testCount).toBe(0); expect(cb1).toHaveBeenCalledTimes(1); expect(cb2).toHaveBeenCalledTimes(1); expect(cb3).toHaveBeenCalledTimes(0); expect(cb4).toHaveBeenCalledTimes(0); expect(cb5).toHaveBeenCalledTimes(0); - suite(omitConditional); + suite.run(omitConditional); expect(cb1).toHaveBeenCalledTimes(2); expect(cb2).toHaveBeenCalledTimes(2); expect(cb3).toHaveBeenCalledTimes(0); @@ -121,14 +121,14 @@ describe('omitWhen', () => { expect(suite.get()).toMatchSnapshot(); }); - it('Should consider the suite as valid even without the omitted tests', () => { + it('should consider the suite valid even without the omitted tests', () => { expect(suite.get().isValid()).toBe(false); - suite(omitConditional, 'field_1'); + suite.run(omitConditional, 'field_1'); expect(suite.get().isValid()).toBe(false); - suite(omitConditional, 'field_2'); + suite.run(omitConditional, 'field_2'); expect(suite.get().isValid()).toBe(false); allFieldsPass = true; - suite(omitConditional, 'field_2'); + suite.run(omitConditional, 'field_2'); expect(suite.get().tests.field_1.testCount).toBe(1); expect(suite.get().tests.field_2.testCount).toBe(1); expect(suite.get().tests.field_3.testCount).toBe(0); @@ -137,8 +137,8 @@ describe('omitWhen', () => { expect(suite.get().isValid()).toBe(true); }); - it('Should skip and not run omitted fields when no filter provided', () => { - suite(omitConditional); + it('should skip and not run omitted fields when no field filter is provided', () => { + suite.run(omitConditional); expect(suite.get().tests.field_1.testCount).toBe(1); expect(suite.get().tests.field_2.testCount).toBe(1); expect(suite.get().tests.field_3.testCount).toBe(0); @@ -148,23 +148,23 @@ describe('omitWhen', () => { }); }); - describe('When the conditional changes between runs', () => { - it('Should omit previously run fields if changes to `true`', () => { - suite(false, 'field_1'); + describe('when the conditional changes between runs', () => { + it('should omit previously run fields when changing to true', () => { + suite.run(false, 'field_1'); expect(suite.get().tests.field_1.testCount).toBe(2); expect(cb1).toHaveBeenCalledTimes(1); expect(cb3).toHaveBeenCalledTimes(1); - suite(true, 'field_1'); + suite.run(true, 'field_1'); expect(suite.get().tests.field_1.testCount).toBe(1); expect(cb1).toHaveBeenCalledTimes(2); expect(cb3).toHaveBeenCalledTimes(1); }); - it('Should run fields that were previously omitted when changing to `false`', () => { - suite(true, 'field_3'); + it('should run fields that were previously omitted when changing to false', () => { + suite.run(true, 'field_3'); expect(suite.get().tests.field_3.testCount).toBe(0); expect(cb4).toHaveBeenCalledTimes(0); - suite(false, 'field_3'); + suite.run(false, 'field_3'); expect(suite.get().tests.field_3.testCount).toBe(1); expect(cb4).toHaveBeenCalledTimes(1); }); @@ -173,7 +173,7 @@ describe('omitWhen', () => { describe('nested calls', () => { let suite: TTestSuite; - describe('omitted in non-omitted', () => { + describe('omitted inside non-omitted', () => { beforeEach(() => { suite = vest.create(() => { vest.omitWhen(false, () => { @@ -184,16 +184,16 @@ describe('omitWhen', () => { }); }); }); - suite(); + suite.run(); }); - it('Should run `outer` and omit `inner`', () => { + it('should run outer and omit inner', () => { expect(suite.get().testCount).toBe(1); expect(suite.get().hasErrors('outer')).toBe(true); expect(suite.get().hasErrors('inner')).toBe(false); }); }); - describe('omitted in omitted', () => { + describe('omitted inside omitted', () => { beforeEach(() => { suite = vest.create(() => { vest.omitWhen(true, () => { @@ -204,15 +204,15 @@ describe('omitWhen', () => { }); }); }); - suite(); + suite.run(); }); - it('Should omit both `outer` and `inner`', () => { + it('should omit both outer and inner', () => { expect(suite.get().testCount).toBe(0); expect(suite.get().hasErrors('outer')).toBe(false); expect(suite.get().hasErrors('inner')).toBe(false); }); }); - describe('non-omitted in omitted', () => { + describe('non-omitted inside omitted', () => { beforeEach(() => { suite = vest.create(() => { vest.omitWhen(true, () => { @@ -223,9 +223,9 @@ describe('omitWhen', () => { }); }); }); - suite(); + suite.run(); }); - it('Should omit both', () => { + it('should omit both outer and inner tests', () => { expect(suite.get().testCount).toBe(0); expect(suite.get().hasErrors('outer')).toBe(false); expect(suite.get().hasErrors('inner')).toBe(false); @@ -233,15 +233,17 @@ describe('omitWhen', () => { }); }); - describe('When some tests of the same field are inside omitWhen and some not', () => { - it('Should mark the field as invalid when failing', () => { - const res = vest.create(() => { - vest.test('f1', () => false); - - vest.omitWhen(true, () => { + describe('mixed: some tests of a field are inside omitWhen and some not', () => { + it('should mark the field invalid when any test fails even if others are omitted', () => { + const res = vest + .create(() => { vest.test('f1', () => false); - }); - })(); + + vest.omitWhen(true, () => { + vest.test('f1', () => false); + }); + }) + .run(); expect(res.isValid()).toBe(false); expect(res.isValid('f1')).toBe(false); }); diff --git a/packages/vest/src/isolates/__tests__/skipWhen.test.ts b/packages/vest/src/isolates/__tests__/skipWhen.test.ts index e7c1fdd88..3e0024c8d 100644 --- a/packages/vest/src/isolates/__tests__/skipWhen.test.ts +++ b/packages/vest/src/isolates/__tests__/skipWhen.test.ts @@ -1,8 +1,8 @@ -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { dummyTest } from '../../testUtils/testDummy'; +import { TTestSuite } from 'TVestMock'; import * as vest from 'vest'; describe('skipWhen', () => { @@ -11,7 +11,7 @@ describe('skipWhen', () => { fn = vi.fn(); suite.reset(); }); - it('Should run callback both when condition is true or false', () => { + it('should run the callback whether condition is true or false', () => { let counter = 0; const suite = vest.create(() => { vest.skipWhen(counter === 1, fn); @@ -19,13 +19,13 @@ describe('skipWhen', () => { counter++; }); expect(fn).toHaveBeenCalledTimes(0); - suite(); + suite.run(); expect(fn).toHaveBeenCalledTimes(1); - suite(); + suite.run(); expect(fn).toHaveBeenCalledTimes(2); }); - it('Should respect both boolean and function conditions', () => { + it('should accept both boolean and function conditions', () => { const suite = vest.create(() => { vest.skipWhen(false, fn); vest.skipWhen(true, fn); @@ -33,62 +33,64 @@ describe('skipWhen', () => { vest.skipWhen(() => true, fn); }); - suite(); + suite.run(); expect(fn).toHaveBeenCalledTimes(4); }); - it('Should pass result draft to the functional condition', () => { + it('should pass the current result draft to the functional condition', () => { const f = vi.fn(); const control = vi.fn(); - vest.create(() => { - vest.skipWhen(draft => { - expect(draft.hasErrors()).toBe(false); - expect(draft).toMatchSnapshot(); - control(); - return false; - }, f); - dummyTest.failing('f1', 'msg'); - vest.skipWhen(draft => { - expect(draft.hasErrors()).toBe(true); - expect(draft.hasErrors('f1')).toBe(true); - expect(draft.hasErrors('f2')).toBe(false); - expect(draft.hasErrors('f3')).toBe(false); - expect(draft).toMatchSnapshot(); - control(); - return false; - }, f); - dummyTest.failing('f2', 'msg'); - vest.skipWhen(draft => { - expect(draft.hasErrors()).toBe(true); - expect(draft.hasErrors('f1')).toBe(true); - expect(draft.hasErrors('f2')).toBe(true); - expect(draft.hasErrors('f3')).toBe(false); - expect(draft).toMatchSnapshot(); - control(); - return false; - }, f); - dummyTest.failing('f3', 'msg'); - })(); + vest + .create(() => { + vest.skipWhen(draft => { + expect(draft.hasErrors()).toBe(false); + expect(draft).toMatchSnapshot(); + control(); + return false; + }, f); + dummyTest.failing('f1', 'msg'); + vest.skipWhen(draft => { + expect(draft.hasErrors()).toBe(true); + expect(draft.hasErrors('f1')).toBe(true); + expect(draft.hasErrors('f2')).toBe(false); + expect(draft.hasErrors('f3')).toBe(false); + expect(draft).toMatchSnapshot(); + control(); + return false; + }, f); + dummyTest.failing('f2', 'msg'); + vest.skipWhen(draft => { + expect(draft.hasErrors()).toBe(true); + expect(draft.hasErrors('f1')).toBe(true); + expect(draft.hasErrors('f2')).toBe(true); + expect(draft.hasErrors('f3')).toBe(false); + expect(draft).toMatchSnapshot(); + control(); + return false; + }, f); + dummyTest.failing('f3', 'msg'); + }) + .run(); expect(control).toHaveBeenCalledTimes(3); }); - it('Should skip tests when the condition is truthy', () => { - const res = suite(true); + it('should skip tests when the condition is truthy', () => { + const res = suite.run(true); expect(res.tests.username.testCount).toBe(0); }); - it('Should run tests when the condition is falsy', () => { - const res = suite(false); + it('should run tests when the condition is falsy', () => { + const res = suite.run(false); expect(res.tests.username.testCount).toBe(1); }); - it('Should correctly refill the state when field is skipped', () => { - const res = suite(false); + it('should keep previous test state when field is later skipped', () => { + const res = suite.run(false); expect(res.tests.username.testCount).toBe(1); - suite(true); + suite.run(true); expect(suite.get().tests.username.testCount).toBe(1); }); @@ -96,7 +98,7 @@ describe('skipWhen', () => { describe('nested calls', () => { let suite: TTestSuite; - describe('skipped in non-skipped', () => { + describe('skipped inside non-skipped', () => { beforeEach(() => { suite = vest.create(() => { vest.skipWhen(false, () => { @@ -107,16 +109,16 @@ describe('skipWhen', () => { }); }); }); - suite(); + suite.run(); }); - it('Should run `outer` and skip `inner`', () => { + it('should run outer and skip inner', () => { expect(suite.get().testCount).toBe(1); expect(suite.get().hasErrors('outer')).toBe(true); expect(suite.get().hasErrors('inner')).toBe(false); }); }); - describe('skipped in skipped', () => { + describe('skipped inside skipped', () => { beforeEach(() => { suite = vest.create(() => { vest.skipWhen(true, () => { @@ -127,15 +129,15 @@ describe('skipWhen', () => { }); }); }); - suite(); + suite.run(); }); - it('Should skip both `outer` and `inner`', () => { + it('should skip both outer and inner', () => { expect(suite.get().testCount).toBe(0); expect(suite.get().hasErrors('outer')).toBe(false); expect(suite.get().hasErrors('inner')).toBe(false); }); }); - describe('non-skipped in skipped', () => { + describe('non-skipped inside skipped', () => { beforeEach(() => { suite = vest.create(() => { vest.skipWhen(true, () => { @@ -146,9 +148,9 @@ describe('skipWhen', () => { }); }); }); - suite(); + suite.run(); }); - it('Should skip both', () => { + it('should skip both outer and inner tests', () => { expect(suite.get().testCount).toBe(0); expect(suite.get().hasErrors('outer')).toBe(false); expect(suite.get().hasErrors('inner')).toBe(false); diff --git a/packages/vest/src/isolates/group.ts b/packages/vest/src/isolates/group.ts index 01e91544c..bca041e8b 100644 --- a/packages/vest/src/isolates/group.ts +++ b/packages/vest/src/isolates/group.ts @@ -1,9 +1,12 @@ import { CB } from 'vest-utils'; -import { TIsolate, Isolate } from 'vestjs-runtime'; +import { TIsolate } from 'vestjs-runtime'; -import { SuiteContext } from 'SuiteContext'; import { TGroupName } from 'SuiteResultTypes'; -import { VestIsolateType } from 'VestIsolateType'; +import { + createVestIsolate, + TVestIsolate, + VestIsolateType, +} from 'VestIsolateType'; export function group( groupName: G, @@ -12,10 +15,14 @@ export function group( export function group(callback: CB): TIsolate; export function group( ...args: [groupName: G, callback: CB] | [callback: CB] -): TIsolate { +): TIsolateGroup { const [callback, groupName] = args.reverse() as [CB, G]; - return Isolate.create(VestIsolateType.Group, () => { - return SuiteContext.run({ ...(groupName && { groupName }) }, callback); + return createVestIsolate(VestIsolateType.Group, callback, { + groupName, }); } + +export type TIsolateGroup = TVestIsolate<{ + groupName: G; +}>; diff --git a/packages/vest/src/isolates/omitWhen.ts b/packages/vest/src/isolates/omitWhen.ts index 61ea0b145..b822548de 100644 --- a/packages/vest/src/isolates/omitWhen.ts +++ b/packages/vest/src/isolates/omitWhen.ts @@ -1,11 +1,10 @@ import type { CB } from 'vest-utils'; -import { optionalFunctionValue } from 'vest-utils'; -import { Isolate } from 'vestjs-runtime'; +import { dynamicValue } from 'vest-utils'; import { LazyDraft } from 'LazyDraft'; import { SuiteContext, useOmitted } from 'SuiteContext'; import { TFieldName, TGroupName } from 'SuiteResultTypes'; -import { VestIsolateType } from 'VestIsolateType'; +import { createVestIsolate, VestIsolateType } from 'VestIsolateType'; import { TDraftCondition } from 'getTypedMethods'; /** @@ -22,16 +21,22 @@ export function omitWhen( conditional: TDraftCondition, callback: CB, ): void { - Isolate.create(VestIsolateType.OmitWhen, () => { - SuiteContext.run( - { - omitted: - useWithinActiveOmitWhen() || - optionalFunctionValue(conditional, LazyDraft()), - }, - callback, - ); - }); + createVestIsolate( + VestIsolateType.OmitWhen, + () => { + SuiteContext.run( + { + omitted: + useWithinActiveOmitWhen() || + dynamicValue(conditional, LazyDraft()), + }, + callback, + ); + }, + { + tests: [], + }, + ); } // Checks that we're currently in an active omitWhen block diff --git a/packages/vest/src/isolates/skipWhen.ts b/packages/vest/src/isolates/skipWhen.ts index 53dd27750..755b6a6a9 100644 --- a/packages/vest/src/isolates/skipWhen.ts +++ b/packages/vest/src/isolates/skipWhen.ts @@ -1,10 +1,9 @@ -import { CB, optionalFunctionValue } from 'vest-utils'; -import { Isolate } from 'vestjs-runtime'; +import { CB, dynamicValue } from 'vest-utils'; import { LazyDraft } from 'LazyDraft'; import { SuiteContext, useSkipped } from 'SuiteContext'; import { TFieldName, TGroupName } from 'SuiteResultTypes'; -import { VestIsolateType } from 'VestIsolateType'; +import { createVestIsolate, VestIsolateType } from 'VestIsolateType'; import { TDraftCondition } from 'getTypedMethods'; /** @@ -21,19 +20,25 @@ export function skipWhen( condition: TDraftCondition, callback: CB, ): void { - Isolate.create(VestIsolateType.SkipWhen, () => { - SuiteContext.run( - { - skipped: - // Checking for nested conditional. If we're in a nested skipWhen, - // we should skip the test if the parent conditional is true. - useIsExcludedIndividually() || - // Otherwise, we should skip the test if the conditional is true. - optionalFunctionValue(condition, LazyDraft()), - }, - callback, - ); - }); + createVestIsolate( + VestIsolateType.SkipWhen, + () => { + SuiteContext.run( + { + skipped: + // Checking for nested conditional. If we're in a nested skipWhen, + // we should skip the test if the parent conditional is true. + useIsExcludedIndividually() || + // Otherwise, we should skip the test if the conditional is true. + dynamicValue(condition, LazyDraft()), + }, + callback, + ); + }, + { + tests: [], + }, + ); } export function useIsExcludedIndividually(): boolean { diff --git a/packages/vest/src/suite/SuiteTypes.ts b/packages/vest/src/suite/SuiteTypes.ts index efd362cf4..f4cbe79fe 100644 --- a/packages/vest/src/suite/SuiteTypes.ts +++ b/packages/vest/src/suite/SuiteTypes.ts @@ -1,35 +1,56 @@ +import type { RuleInstance } from 'n4s'; import { CB } from 'vest-utils'; import { TIsolateSuite } from 'IsolateSuite'; -import { - SuiteResult, - SuiteRunResult, - TFieldName, - TGroupName, -} from 'SuiteResultTypes'; +import { SuiteResult, TFieldName, TGroupName } from 'SuiteResultTypes'; import { Subscribe } from 'VestBus'; -import { StaticSuiteRunResult } from 'createSuite'; +import { FieldExclusion } from 'focused'; import { TTypedMethods } from 'getTypedMethods'; import { SuiteSelectors } from 'suiteSelectors'; +export type InferSuiteData< + S extends RuleInstance | undefined, +> = S extends RuleInstance ? Data : any; + export type Suite< F extends TFieldName, - G extends TGroupName, - T extends CB = CB, -> = ((...args: Parameters) => SuiteRunResult) & SuiteMethods; + G extends TGroupName = string, + T extends (...args: any[]) => any = (...args: any[]) => any, + S extends RuleInstance | undefined = undefined, +> = SuiteMethods; -export type SuiteMethods< +type SuiteMethods< F extends TFieldName, G extends TGroupName, - T extends CB, + T extends (...args: any[]) => any, + S extends RuleInstance | undefined, > = { dump: CB; - get: CB>; + + get: CB>; resume: CB; reset: CB; remove: CB; resetField: CB; - runStatic: CB, Parameters>; + run: (...args: Parameters) => SuiteResult; + runStatic: (...args: Parameters) => SuiteResult; subscribe: Subscribe; -} & TTypedMethods & +} & AfterMethods & + TTypedMethods & SuiteSelectors; + +type AfterMethods< + F extends TFieldName, + G extends TGroupName, + T extends (...args: any[]) => any, + S extends RuleInstance | undefined, +> = { + after: CB, [callback: CB]>; + afterField: CB, [fieldName: F, callback: CB]>; + focus: CB, [config: SuiteModifiers]>; + run: (...args: Parameters) => SuiteResult; +}; + +export type SuiteModifiers = { + only?: FieldExclusion; +}; diff --git a/packages/vest/src/suite/SuiteWalker.ts b/packages/vest/src/suite/SuiteWalker.ts index 1ebb15231..431217f14 100644 --- a/packages/vest/src/suite/SuiteWalker.ts +++ b/packages/vest/src/suite/SuiteWalker.ts @@ -1,10 +1,10 @@ import { Predicate, Predicates, isEmpty, isNullish } from 'vest-utils'; -import { TIsolate, VestRuntime, Walker } from 'vestjs-runtime'; +import { VestRuntime } from 'vestjs-runtime'; +import { TIsolateSuite } from 'IsolateSuite'; import { TIsolateTest } from 'IsolateTest'; -import { PreAggCache, usePreAggCache } from 'Runtime'; import { TFieldName } from 'SuiteResultTypes'; -import { VestIsolate } from 'VestIsolate'; +import { isVestIsolate } from 'VestIsolateType'; import { VestTest } from 'VestTest'; import { matchesOrHasNoFieldName } from 'matchingFieldName'; @@ -14,11 +14,11 @@ export class SuiteWalker { static useHasPending(predicate?: Predicate): boolean { const root = SuiteWalker.defaultRoot(); - if (!root) { + if (!isVestIsolate(root)) { return false; } - const allPending = SuiteWalker.usePreAggs().pending; + const allPending = root.data.tests.filter(VestTest.isPending); if (isEmpty(allPending)) { return false; @@ -27,8 +27,14 @@ export class SuiteWalker { return allPending.some(Predicates.all(predicate ?? true)); } - static usePreAggs() { - return usePreAggCache(buildPreAggCache); + static useResolve() { + const root = SuiteWalker.defaultRoot() as TIsolateSuite; + + if (!root) { + return; + } + + root.data.resolver(); } // Checks whether there are pending isolates in the tree. @@ -47,47 +53,3 @@ export class SuiteWalker { ); } } - -function buildPreAggCache(): PreAggCache { - const root = SuiteWalker.defaultRoot(); - - const base: PreAggCache = { - pending: [], - failures: { - errors: {}, - warnings: {}, - }, - }; - - if (!root) { - return base; - } - - return Walker.reduce( - root, - // eslint-disable-next-line complexity, max-statements - (agg, isolate: TIsolate) => { - if (VestIsolate.isPending(isolate)) { - agg.pending.push(isolate); - } - - if (VestTest.is(isolate)) { - const fieldName = VestTest.getData(isolate).fieldName; - - if (VestTest.isWarning(isolate)) { - agg.failures.warnings[fieldName] = - agg.failures.warnings[fieldName] ?? []; - agg.failures.warnings[fieldName].push(isolate); - } - - if (VestTest.isFailing(isolate)) { - agg.failures.errors[fieldName] = agg.failures.errors[fieldName] ?? []; - agg.failures.errors[fieldName].push(isolate); - } - } - - return agg; - }, - base, - ); -} diff --git a/packages/vest/src/suite/__tests__/__snapshots__/create.test.ts.snap b/packages/vest/src/suite/__tests__/__snapshots__/create.test.ts.snap index b7515cb25..1ef5d0646 100644 --- a/packages/vest/src/suite/__tests__/__snapshots__/create.test.ts.snap +++ b/packages/vest/src/suite/__tests__/__snapshots__/create.test.ts.snap @@ -1,37 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test createSuite module > Initial run > Should be able to get the suite from the result of createSuite 1`] = ` -SuiteSummary { - "errorCount": 0, - "errors": [], - "getError": [Function], - "getErrors": [Function], - "getErrorsByGroup": [Function], - "getMessage": [Function], - "getWarning": [Function], - "getWarnings": [Function], - "getWarningsByGroup": [Function], - "groups": {}, - "hasErrors": [Function], - "hasErrorsByGroup": [Function], - "hasWarnings": [Function], - "hasWarningsByGroup": [Function], - "isPending": [Function], - "isTested": [Function], - "isValid": [Function], - "isValidByGroup": [Function], - "pendingCount": 0, - "suiteName": undefined, - "testCount": 0, - "tests": {}, - "valid": false, - "warnCount": 0, - "warnings": [], -} -`; - -exports[`Test createSuite module > Initial run > Should initialize with an empty result object 1`] = ` +exports[`Test createSuite module > Initial run > should initialize with an empty result object 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -54,6 +25,7 @@ SuiteSummary { "suiteName": undefined, "testCount": 0, "tests": {}, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], @@ -62,6 +34,7 @@ SuiteSummary { exports[`Test createSuite module > Test suite Arguments > allows omitting suite name 1`] = ` SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -84,6 +57,7 @@ SuiteSummary { "suiteName": undefined, "testCount": 0, "tests": {}, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/suite/__tests__/__snapshots__/focus.test.ts.snap b/packages/vest/src/suite/__tests__/__snapshots__/focus.test.ts.snap new file mode 100644 index 000000000..5ef00d927 --- /dev/null +++ b/packages/vest/src/suite/__tests__/__snapshots__/focus.test.ts.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`suite.focus: only > focus return value > should be the rest of the suite methods 1`] = ` +{ + "after": [Function], + "afterField": [Function], + "focus": [Function], + "run": [Function], +} +`; diff --git a/packages/vest/src/suite/__tests__/__snapshots__/staticSuite.test.ts.snap b/packages/vest/src/suite/__tests__/__snapshots__/staticSuite.test.ts.snap deleted file mode 100644 index e7b2b14c1..000000000 --- a/packages/vest/src/suite/__tests__/__snapshots__/staticSuite.test.ts.snap +++ /dev/null @@ -1,190 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`staticSuite > dump > should output a dump of the suite 1`] = ` -{ - "$type": "Suite", - "abortController": AbortController {}, - "allowReorder": undefined, - "children": [ - { - "$type": "Test", - "abortController": AbortController {}, - "allowReorder": undefined, - "children": null, - "data": { - "fieldName": "t1", - "severity": "error", - "testFn": [Function], - }, - "key": null, - "keys": null, - "output": undefined, - "parent": [Circular], - "status": "FAILED", - }, - { - "$type": "Test", - "abortController": AbortController {}, - "allowReorder": undefined, - "children": null, - "data": { - "fieldName": "t2", - "severity": "error", - "testFn": [Function], - }, - "key": null, - "keys": null, - "output": undefined, - "parent": [Circular], - "status": "FAILED", - }, - { - "$type": "Group", - "abortController": AbortController {}, - "allowReorder": undefined, - "children": [ - { - "$type": "Test", - "abortController": AbortController {}, - "allowReorder": undefined, - "children": null, - "data": { - "fieldName": "t1", - "groupName": "g1", - "severity": "error", - "testFn": [Function], - }, - "key": null, - "keys": null, - "output": undefined, - "parent": [Circular], - "status": "SKIPPED", - }, - { - "$type": "Test", - "abortController": AbortController {}, - "allowReorder": undefined, - "children": null, - "data": { - "fieldName": "t3", - "groupName": "g1", - "severity": "error", - "testFn": [Function], - }, - "key": null, - "keys": null, - "output": undefined, - "parent": [Circular], - "status": "FAILED", - }, - ], - "data": {}, - "key": null, - "keys": null, - "output": undefined, - "parent": [Circular], - "status": "DONE", - }, - ], - "data": { - "optional": {}, - }, - "key": null, - "keys": null, - "output": { - "done": [Function], - "errorCount": 3, - "errors": [ - SummaryFailure { - "fieldName": "t1", - "groupName": undefined, - "message": undefined, - }, - SummaryFailure { - "fieldName": "t2", - "groupName": undefined, - "message": undefined, - }, - SummaryFailure { - "fieldName": "t3", - "groupName": "g1", - "message": undefined, - }, - ], - "getError": [Function], - "getErrors": [Function], - "getErrorsByGroup": [Function], - "getMessage": [Function], - "getWarning": [Function], - "getWarnings": [Function], - "getWarningsByGroup": [Function], - "groups": { - "g1": { - "t1": SummaryBase { - "errorCount": 0, - "errors": [], - "pendingCount": 0, - "testCount": 0, - "valid": false, - "warnCount": 0, - "warnings": [], - }, - "t3": SummaryBase { - "errorCount": 1, - "errors": [], - "pendingCount": 0, - "testCount": 1, - "valid": false, - "warnCount": 0, - "warnings": [], - }, - }, - }, - "hasErrors": [Function], - "hasErrorsByGroup": [Function], - "hasWarnings": [Function], - "hasWarningsByGroup": [Function], - "isPending": [Function], - "isTested": [Function], - "isValid": [Function], - "isValidByGroup": [Function], - "pendingCount": 0, - "suiteName": undefined, - "testCount": 3, - "tests": { - "t1": { - "errorCount": 1, - "errors": [], - "pendingCount": 0, - "testCount": 1, - "valid": false, - "warnCount": 0, - "warnings": [], - }, - "t2": SummaryBase { - "errorCount": 1, - "errors": [], - "pendingCount": 0, - "testCount": 1, - "valid": false, - "warnCount": 0, - "warnings": [], - }, - "t3": SummaryBase { - "errorCount": 1, - "errors": [], - "pendingCount": 0, - "testCount": 1, - "valid": false, - "warnCount": 0, - "warnings": [], - }, - }, - "valid": false, - "warnCount": 0, - "warnings": [], - }, - "parent": null, - "status": "DONE", -} -`; diff --git a/packages/vest/src/suite/__tests__/__snapshots__/suite.dump.test.ts.snap b/packages/vest/src/suite/__tests__/__snapshots__/suite.dump.test.ts.snap new file mode 100644 index 000000000..183c5af01 --- /dev/null +++ b/packages/vest/src/suite/__tests__/__snapshots__/suite.dump.test.ts.snap @@ -0,0 +1,739 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SuiteSerializer > should produce a valid serialized dump 1`] = ` +{ + "$type": "Suite", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Focused", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "focusMode": 0, + "match": [], + "matchAll": false, + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + { + "$type": "Focused", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "focusMode": 0, + "match": [ + "field_1", + ], + "matchAll": false, + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_1", + "message": "field_1_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_2", + "message": "field_2_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + "data": { + "groupName": "group_1", + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + { + "$type": "SkipWhen", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_5", + "message": "field_5_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + "data": { + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_5", + "message": "field_5_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + ], + "data": { + "optional": {}, + "resolver": [Function], + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_1", + "message": "field_1_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "FAILED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_2", + "message": "field_2_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + "data": { + "groupName": "group_1", + "tests": [ + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + "data": { + "groupName": "group_1", + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + [Circular], + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_4", + "message": "field_4_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "Group", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + [Circular], + ], + "data": { + "groupName": "group_1", + "tests": [ + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_1", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_3", + "message": "field_3_message_2", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "SKIPPED", + }, + [Circular], + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "SKIPPED", + }, + { + "$type": "Test", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": null, + "data": { + "fieldName": "field_5", + "message": "field_5_message", + "severity": "error", + "testFn": [Function], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": { + "$type": "SkipWhen", + "abortController": AbortController {}, + "allowReorder": undefined, + "children": [ + [Circular], + ], + "data": { + "tests": [ + [Circular], + ], + }, + "key": null, + "keys": null, + "output": undefined, + "parent": [Circular], + "status": "DONE", + }, + "status": "SKIPPED", + }, + ], + }, + "key": null, + "keys": null, + "output": SuiteSummary { + "dump": [Function], + "errorCount": 1, + "errors": [ + SummaryFailure { + "fieldName": "field_1", + "groupName": undefined, + "message": "field_1_message", + }, + ], + "getError": [Function], + "getErrors": [Function], + "getErrorsByGroup": [Function], + "getMessage": [Function], + "getWarning": [Function], + "getWarnings": [Function], + "getWarningsByGroup": [Function], + "groups": { + "group_1": { + "field_3": { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 0, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_4": SummaryBase { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 0, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + }, + }, + "hasErrors": [Function], + "hasErrorsByGroup": [Function], + "hasWarnings": [Function], + "hasWarningsByGroup": [Function], + "isPending": [Function], + "isTested": [Function], + "isValid": [Function], + "isValidByGroup": [Function], + "pendingCount": 0, + "suiteName": undefined, + "testCount": 1, + "tests": { + "field_1": SummaryBase { + "errorCount": 1, + "errors": [ + "field_1_message", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_2": SummaryBase { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 0, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_3": { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 0, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_4": SummaryBase { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 0, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_5": SummaryBase { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 0, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + }, + "types": undefined, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "parent": null, + "status": "DONE", +} +`; diff --git a/packages/vest/src/suite/__tests__/create.test.ts b/packages/vest/src/suite/__tests__/create.test.ts index 7148ca2dd..9abe06117 100644 --- a/packages/vest/src/suite/__tests__/create.test.ts +++ b/packages/vest/src/suite/__tests__/create.test.ts @@ -1,17 +1,17 @@ import { faker } from '@faker-js/faker'; -import { ErrorStrings } from 'ErrorStrings'; import { noop } from 'lodash'; import { describe, it, expect, vi } from 'vitest'; import { dummyTest } from '../../testUtils/testDummy'; import { TestPromise } from '../../testUtils/testPromise'; -import { create } from 'vest'; +import { ErrorStrings } from 'ErrorStrings'; +import { create, enforce } from 'vest'; describe('Test createSuite module', () => { describe('Test suite Arguments', () => { it('allows omitting suite name', () => { - expect(typeof create(vi.fn())).toBe('function'); + expect(typeof create(vi.fn()).run).toBe('function'); expect(typeof create(vi.fn()).get).toBe('function'); expect(typeof create(vi.fn()).reset).toBe('function'); expect(typeof create(vi.fn()).remove).toBe('function'); @@ -28,28 +28,27 @@ describe('Test createSuite module', () => { }, ); - describe('When suite name is provided', () => { - it('Should add suite name to suite result', () => { - const res = create('form_name', () => {})(); + it('initializes with an undefined suite name and no types metadata', () => { + const res = create(() => {}).run(); - expect(res.suiteName).toBe('form_name'); - }); + expect(res.suiteName).toBeUndefined(); + expect(res.types).toBeUndefined(); }); }); describe('Return value', () => { - it('should be a function', () => { - expect(typeof create(noop)).toBe('function'); + it('should expose Suite.run as a function', () => { + expect(typeof create(noop).run).toBe('function'); }); }); describe('When returned function is invoked', () => { it('Calls `tests` argument', () => TestPromise(done => { - const validate = create(() => { + const suite = create(() => { done(); }); - validate(); + suite.run(); })); it('Passes all arguments over to tests callback', () => { @@ -62,38 +61,42 @@ describe('Test createSuite module', () => { false, [faker.lorem.word()], ]; - const validate = create(testsCallback); - validate(...params); + const suite = create(testsCallback); + suite.run(...params); expect(testsCallback).toHaveBeenCalledWith(...params); }); }); describe('Initial run', () => { const testsCb = vi.fn(); - const genValidate = () => create(testsCb); + const genSuite = () => create(testsCb); - it('Should initialize with an empty result object', () => { - const validate = genValidate(); - expect(Object.keys(validate.get().tests)).toHaveLength(0); - expect(Object.keys(validate.get().groups)).toHaveLength(0); + it('should initialize with an empty result object', () => { + const suite = genSuite(); + expect(Object.keys(suite.get().tests)).toHaveLength(0); + expect(Object.keys(suite.get().groups)).toHaveLength(0); - expect(validate.get().errorCount).toBe(0); - expect(validate.get().warnCount).toBe(0); - expect(validate.get().testCount).toBe(0); + expect(suite.get().errorCount).toBe(0); + expect(suite.get().warnCount).toBe(0); + expect(suite.get().testCount).toBe(0); - expect(validate.get()).toMatchSnapshot(); + expect(suite.get()).toMatchSnapshot(); }); - it('Should be able to get the suite from the result of createSuite', () => { - const testsCb = vi.fn(); - expect(create(testsCb).get()).toMatchSnapshot(); + it('should include types metadata when schema is provided', () => { + const schema = enforce.shape({ + username: enforce.isString(), + }); + + const suite = create(() => {}, schema); + expect(suite.get().types?.schema).toBe(schema); }); - it('Should be able to reset the suite from the result of createSuite', () => { + it('should be able to reset the suite from the result of createSuite', () => { const testSuite = create(() => { dummyTest.failing('f1', 'm1'); }); - testSuite(); + testSuite.run(); expect(testSuite.get().hasErrors()).toBe(true); expect(testSuite.get().testCount).toBe(1); testSuite.reset(); @@ -101,10 +104,10 @@ describe('Test createSuite module', () => { expect(testSuite.get().testCount).toBe(0); }); - it('Should return without calling tests callback', () => { - const validate = create(testsCb); + it('should return without calling the tests callback', () => { + const suite = create(testsCb); expect(testsCb).not.toHaveBeenCalled(); - validate(); + suite.run(); expect(testsCb).toHaveBeenCalled(); }); }); diff --git a/packages/vest/src/suite/__tests__/createWithSchema.test.ts b/packages/vest/src/suite/__tests__/createWithSchema.test.ts new file mode 100644 index 000000000..5dab4a006 --- /dev/null +++ b/packages/vest/src/suite/__tests__/createWithSchema.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { enforce } from 'n4s'; +import { create } from 'vest'; + +describe('createSuite with schema parameter', () => { + it('passes typed data into the suite callback', () => { + const schema = enforce.shape({ + email: enforce.isString(), + age: enforce.isNumber(), + }); + + const callback = vi.fn(); + const suite = create(callback, schema); + + const data = { email: 'user@example.com', age: 32 }; + suite.run(data); + + expect(callback).toHaveBeenCalledWith(data); + expect(suite.get().types?.schema).toBe(schema); + }); + + it('exposes schema metadata on the suite result', () => { + const schema = enforce.partial({ + nickname: enforce.isString(), + }); + + const suite = create(() => {}, schema); + const result = suite.run({ nickname: 'vest' }); + + expect(result.types?.schema).toBe(schema); + expect(result.types?.data).toBeUndefined(); + }); + + it('leaves types undefined when schema is omitted', () => { + const suite = create(() => {}); + + expect(suite.run().types).toBeUndefined(); + }); +}); diff --git a/packages/vest/src/suite/__tests__/focus.test.ts b/packages/vest/src/suite/__tests__/focus.test.ts new file mode 100644 index 000000000..010c955f7 --- /dev/null +++ b/packages/vest/src/suite/__tests__/focus.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; + +import { dummyTest } from '../../testUtils/testDummy'; + +import * as vest from 'vest'; + +describe('suite.focus: only', () => { + it('focus should be a function', () => { + const suite = vest.create(() => {}); + + expect(suite.focus).toBeTypeOf('function'); + }); + + describe('focus return value', () => { + it('should be the rest of the suite methods', () => { + const suite = vest.create(() => {}); + + const focused = suite.focus({ only: ['field_1'] }); + + expect(focused).toBeTypeOf('object'); + expect(focused.after).toBeTypeOf('function'); + expect(focused.afterField).toBeTypeOf('function'); + expect(focused.run).toBeTypeOf('function'); + expect(focused).toMatchSnapshot(); + }); + }); + + describe('behavior', () => { + it('should focus on the specified field when a single field is provided', () => { + const suite = vest.create(() => { + dummyTest.failing('field_1'); + dummyTest.failing('field_2'); + dummyTest.failing('field_3'); + }); + + const res = suite.focus({ only: 'field_1' }).run(); + + expect(res.hasErrors('field_1')).toBe(true); + expect(res.hasErrors('field_2')).toBe(false); + expect(res.hasErrors('field_3')).toBe(false); + + expect(res.tests.field_1.testCount).toBe(1); + expect(res.tests.field_2.testCount).toBe(0); + expect(res.tests.field_3.testCount).toBe(0); + }); + + it('should focus on the specified fields when multiple fields are provided', () => { + const suite = vest.create(() => { + dummyTest.failing('field_1'); + dummyTest.failing('field_2'); + dummyTest.failing('field_3'); + }); + + const res = suite.focus({ only: ['field_1', 'field_3'] }).run(); + + expect(res.hasErrors('field_1')).toBe(true); + expect(res.hasErrors('field_2')).toBe(false); + expect(res.hasErrors('field_3')).toBe(true); + + expect(res.tests.field_1.testCount).toBe(1); + expect(res.tests.field_2.testCount).toBe(0); + expect(res.tests.field_3.testCount).toBe(1); + }); + + describe('multiple runs', () => { + it('should reevaluate the focused fields on each run', () => { + const suite = vest.create(() => { + dummyTest.failing('field_1'); + dummyTest.failing('field_2'); + dummyTest.failing('field_3'); + }); + + suite.focus({ only: 'field_1' }).run(); + expect(suite.hasErrors('field_1')).toBe(true); + expect(suite.hasErrors('field_2')).toBe(false); + expect(suite.hasErrors('field_3')).toBe(false); + + suite.focus({ only: 'field_2' }).run(); + expect(suite.hasErrors('field_1')).toBe(true); + expect(suite.hasErrors('field_2')).toBe(true); + expect(suite.hasErrors('field_3')).toBe(false); + + suite.focus({ only: 'field_3' }).run(); + expect(suite.hasErrors('field_1')).toBe(true); + expect(suite.hasErrors('field_2')).toBe(true); + expect(suite.hasErrors('field_3')).toBe(true); + }); + }); + }); +}); diff --git a/packages/vest/src/suite/__tests__/remove.test.ts b/packages/vest/src/suite/__tests__/remove.test.ts index 5a4ded20f..c9d79a603 100644 --- a/packages/vest/src/suite/__tests__/remove.test.ts +++ b/packages/vest/src/suite/__tests__/remove.test.ts @@ -7,7 +7,7 @@ import { dummyTest } from '../../testUtils/testDummy'; import * as vest from 'vest'; describe('suite.remove', () => { - it('Should remove field from validation result', async () => { + it('should remove the field from the validation result', async () => { const suite = vest.create(() => { vest.mode(Modes.ALL); dummyTest.failing('field1'); @@ -17,7 +17,7 @@ describe('suite.remove', () => { dummyTest.passing('field2'); dummyTest.passing('field1'); }); - suite(); + suite.run(); expect(suite.get().testCount).toBe(6); expect(suite.get().tests.field1.testCount).toBe(4); expect(suite.get().tests.field2.testCount).toBe(2); @@ -29,23 +29,23 @@ describe('suite.remove', () => { expect(suite.get().tests.field1).toBeUndefined(); }); - it('Should clear the cache when removing a field', () => { + it('should clear the cache when removing a field', () => { const suite = vest.create(() => { dummyTest.failing('field1'); dummyTest.failing('field2'); }); - suite(); + suite.run(); const res = suite.get(); suite.remove('field2'); expect(suite.get()).not.toBe(res); }); - it('Should return silently when removing a field that does not exist', () => { + it('should return silently when removing a field that does not exist', () => { const suite = vest.create(() => { dummyTest.failing('field1'); dummyTest.passing('field2'); }); - suite(); + suite.run(); const res = suite.get(); suite.remove('field3'); expect(suite.get()).isDeepCopyOf(res); diff --git a/packages/vest/src/suite/__tests__/resetField.test.ts b/packages/vest/src/suite/__tests__/resetField.test.ts index 6a4063158..3343a08d0 100644 --- a/packages/vest/src/suite/__tests__/resetField.test.ts +++ b/packages/vest/src/suite/__tests__/resetField.test.ts @@ -1,6 +1,6 @@ -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach } from 'vitest'; +import { TTestSuite } from 'TVestMock'; import { create, test } from 'vest'; describe('suite.resetField', () => { @@ -11,10 +11,10 @@ describe('suite.resetField', () => { test('field1', 'f1 error', () => false); test('field2', 'f2 error', () => false); }); - suite(); + suite.run(); }); - it('Should reset the validity state of a field', () => { + it('should reset the validity state of a field', () => { expect(suite.get().hasErrors('field1')).toBe(true); expect(suite.get().hasErrors('field2')).toBe(true); expect(suite.get().getErrors('field1')).toEqual(['f1 error']); @@ -31,18 +31,18 @@ describe('suite.resetField', () => { expect(suite.get().getErrors('field2')).toEqual([]); }); - it('Should refresh the suite result', () => { + it('should refresh the suite result', () => { const res = suite.get(); expect(res).toBe(suite.get()); suite.resetField('field1'); expect(res).not.toBe(suite.get()); }); - it('Should allow the field to keep updating (no final status)', () => { + it('should allow the field to keep updating (no final status)', () => { suite.resetField('field1'); expect(suite.get().hasErrors('field1')).toBe(false); expect(suite.get().hasErrors('field2')).toBe(true); - suite(); + suite.run(); expect(suite.get().hasErrors('field1')).toBe(true); expect(suite.get().hasErrors('field2')).toBe(true); }); diff --git a/packages/vest/src/suite/__tests__/schema.types.test.ts b/packages/vest/src/suite/__tests__/schema.types.test.ts new file mode 100644 index 000000000..f76028af6 --- /dev/null +++ b/packages/vest/src/suite/__tests__/schema.types.test.ts @@ -0,0 +1,148 @@ +import { describe, it } from 'vitest'; + +import { enforce } from 'n4s'; +import { create } from 'vest'; + +type AssertTrue = T; +type IsEqual = (() => T extends A ? 1 : 2) extends (() => + T extends B ? 1 : 2 +) + ? (() => T extends B ? 1 : 2) extends (() => T extends A ? 1 : 2) + ? true + : false + : false; + +describe('schema driven suite types', () => { + it('infers data type from enforce.shape schema', () => { + const schema = enforce.shape({ + name: enforce.isString(), + age: enforce.isNumber(), + }); + + const suite = create((data) => { + void (0 as unknown as AssertTrue< + IsEqual + >); + void (0 as unknown as AssertTrue< + IsEqual<(typeof data)['name'], string> + >); + void (0 as unknown as AssertTrue< + IsEqual<(typeof data)['age'], number> + >); + }, schema); + + void (0 as unknown as AssertTrue< + IsEqual[0], { name: string; age: number }> + >); + + suite.run({ name: 'john', age: 42 }); + + void (0 as unknown as AssertTrue< + IsEqual< + ReturnType['types'], + { data: { name: string; age: number }; schema: typeof schema } + > + >); + + // @ts-expect-error - requires an argument matching the schema + suite.run(); + + // @ts-expect-error - empty object does not satisfy schema + suite.run({}); + + // @ts-expect-error - missing age should fail type-check + suite.run({ name: 'jane' }); + + // @ts-expect-error - wrong property types should fail + suite.run({ name: 100, age: true }); + + // @ts-expect-error - unexpected keys that don't satisfy schema should fail + suite.run({ number: 'john' }); + }); + + it('disallows invalid runs for custom schema', () => { + const schema = enforce.shape({ + name: enforce.isString(), + number: enforce.isNumber(), + }); + + const suite = create((data) => { + void (0 as unknown as AssertTrue< + IsEqual + >); + }, schema); + + suite.run({ name: 'valid', number: 1 }); + + // @ts-expect-error - run requires argument + suite.run(); + + // @ts-expect-error - empty object does not match schema + suite.run({}); + + // @ts-expect-error - incorrect property types + suite.run({ name: 100 }); + + // @ts-expect-error - incorrect keys should not be allowed + suite.run({ number: 'john' }); + }); + + it('infers loose and partial schemas', () => { + const looseSchema = enforce.loose({ + title: enforce.isString(), + }); + + const partialSchema = enforce.partial({ + id: enforce.isNumber(), + label: enforce.isString(), + }); + + const looseSuite = create((data) => { + void (0 as unknown as AssertTrue< + IsEqual> + >); + }, looseSchema); + + const partialSuite = create((data) => { + void (0 as unknown as AssertTrue< + IsEqual< + typeof data, + { id?: number | undefined; label?: string | undefined } + > + >); + }, partialSchema); + + void (0 as unknown as AssertTrue< + IsEqual< + ReturnType['types']['data'], + { title: string } & Record + > + >); + + void (0 as unknown as AssertTrue< + IsEqual< + ReturnType['types']['data'], + { id?: number | undefined; label?: string | undefined } + > + >); + + looseSuite.run({ title: 'post', extra: true }); + partialSuite.run({ label: 'hello' }); + + // @ts-expect-error - label must be string when present + partialSuite.run({ label: 10 }); + }); + + it('keeps backwards compatibility without schema', () => { + const suite = create((value: number, flag: boolean) => { + void (0 as unknown as AssertTrue>); + void (0 as unknown as AssertTrue>); + }); + + suite.run(10, true); + + void (0 as unknown as AssertTrue< + IsEqual['types'], undefined> + >); + }); +}); diff --git a/packages/vest/src/suite/__tests__/schemaTypeSafety.test.ts b/packages/vest/src/suite/__tests__/schemaTypeSafety.test.ts new file mode 100644 index 000000000..79c9910fa --- /dev/null +++ b/packages/vest/src/suite/__tests__/schemaTypeSafety.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; + +import { enforce } from 'n4s'; +import { create, test } from 'vest'; + +/** + * Type Safety Tests + * + * These tests demonstrate that TypeScript properly enforces types + * when using schemas with createSuite. + */ + +describe('Schema Type Safety', () => { + it('should allow valid data that matches schema', () => { + const schema = enforce.shape({ + username: enforce.isString(), + age: enforce.isNumber(), + }); + + const suite = create(data => { + test('username', () => { + enforce(data.username).isNotEmpty(); + }); + test('age', () => { + enforce(data.age).greaterThan(0); + }); + }, schema); + + // This should compile and work + const result = suite.run({ username: 'john', age: 30 }); + expect(result.hasErrors()).toBe(false); + }); + + it('callback parameter has correct type inference', () => { + const schema = enforce.shape({ + email: enforce.isString(), + count: enforce.isNumber(), + }); + + const suite = create(data => { + // TypeScript knows data.email is a string + expect(data.email.length).toBeGreaterThan(0); + + // TypeScript knows data.count is a number + expect(data.count).toBeGreaterThan(0); + + test('email', () => { + enforce(data.email).isNotEmpty(); + }); + + test('count', () => { + enforce(data.count).isNumber(); + }); + }, schema); + + const result = suite.run({ email: 'test@example.com', count: 5 }); + expect(result.hasErrors()).toBe(false); + }); + + it('nested schema properties are properly typed', () => { + const addressSchema = enforce.shape({ + street: enforce.isString(), + city: enforce.isString(), + zipCode: enforce.isString(), + }); + + const userSchema = enforce.shape({ + name: enforce.isString(), + address: addressSchema, + }); + + const suite = create(data => { + // TypeScript knows data.address.city is a string + expect(data.address.city.length).toBeGreaterThan(0); + + test('city', () => { + enforce(data.address.city).isNotEmpty(); + }); + }, userSchema); + + const result = suite.run({ + name: 'John', + address: { + street: '123 Main St', + city: 'Springfield', + zipCode: '12345', + }, + }); + + expect(result.hasErrors()).toBe(false); + }); + + it('loose schema allows extra properties', () => { + const schema = enforce.loose({ + id: enforce.isNumber(), + name: enforce.isString(), + }); + + const suite = create(data => { + // TypeScript knows about id and name + expect(data.id).toBeGreaterThan(0); + expect(data.name.length).toBeGreaterThan(0); + + test('id', () => { + enforce(data.id).isNumber(); + }); + }, schema); + + // Extra properties are allowed with loose schema + const result = suite.run({ + id: 1, + name: 'Test', + extra: 'This is fine', + another: 42, + }); + + expect(result.hasErrors()).toBe(false); + }); + + it('suite without schema accepts any data', () => { + const suite = create((data: any) => { + test('test', () => { + expect(data).toBeDefined(); + }); + }); + + // Without schema, any data is accepted + const result1 = suite.run({ anything: 'goes' }); + const result2 = suite.run([1, 2, 3]); + const result3 = suite.run('string'); + const result4 = suite.run(42); + + expect(result1.hasErrors()).toBe(false); + expect(result2.hasErrors()).toBe(false); + expect(result3.hasErrors()).toBe(false); + expect(result4.hasErrors()).toBe(false); + }); + + describe('non-compliant schema runs', () => { + const schema = enforce.shape({ + name: enforce.isString(), + number: enforce.isNumber(), + }); + const suite = create(data => { + test('name', () => { + enforce(data.name).isString(); + }); + test('number', () => { + enforce(data.number).isNumber(); + }); + }, schema); + + it('should fail when run() is called with no arguments', () => { + // @ts-expect-error - run requires argument matching schema + const result = suite.run(); + expect(result.hasErrors()).toBe(true); + }); + + it('should fail when run() is called with empty object', () => { + // @ts-expect-error - empty object does not satisfy schema + const result = suite.run({}); + expect(result.hasErrors()).toBe(true); + }); + + it('should fail when run() is called with incorrect object values', () => { + // @ts-expect-error - incorrect property values violate schema + const result = suite.run({ name: 100, number: true }); + expect(result.hasErrors()).toBe(true); + }); + }); +}); diff --git a/packages/vest/src/suite/__tests__/staticSuite.test.ts b/packages/vest/src/suite/__tests__/staticSuite.test.ts index 972b8fc8b..f29825d5b 100644 --- a/packages/vest/src/suite/__tests__/staticSuite.test.ts +++ b/packages/vest/src/suite/__tests__/staticSuite.test.ts @@ -4,113 +4,9 @@ import wait from 'wait'; import { SuiteSerializer } from 'SuiteSerializer'; import { VestIsolateType } from 'VestIsolateType'; import * as vest from 'vest'; -import { staticSuite } from 'vest'; - -describe('staticSuite', () => { - it('Should return a function', () => { - expect(typeof staticSuite(() => {})).toBe('function'); - }); - - it('Should return a "suite instance"', () => { - const suite = staticSuite(() => {}); - const result = suite(); - expect(typeof result).toBe('object'); - expect(typeof result.tests).toBe('object'); - expect(typeof result.groups).toBe('object'); - expect(typeof result.warnCount).toBe('number'); - expect(typeof result.errorCount).toBe('number'); - expect(typeof result.testCount).toBe('number'); - expect(typeof result.getWarning).toBe('function'); - expect(typeof result.getError).toBe('function'); - expect(typeof result.getErrors).toBe('function'); - expect(typeof result.getErrors).toBe('function'); - expect(typeof result.getWarnings).toBe('function'); - expect(typeof result.getWarnings).toBe('function'); - expect(typeof result.getErrorsByGroup).toBe('function'); - expect(typeof result.getErrorsByGroup).toBe('function'); - expect(typeof result.getWarningsByGroup).toBe('function'); - expect(typeof result.getWarningsByGroup).toBe('function'); - expect(typeof result.hasErrors).toBe('function'); - expect(typeof result.hasWarnings).toBe('function'); - expect(typeof result.hasErrorsByGroup).toBe('function'); - expect(typeof result.hasWarningsByGroup).toBe('function'); - expect(typeof result.isValid).toBe('function'); - expect(typeof result.isValidByGroup).toBe('function'); - expect(typeof result.done).toBe('function'); - }); - - it('On consecutive calls, should return a new "suite instance"', () => { - const suite = staticSuite(only => { - vest.only(only); - vest.test('t1', () => false); - vest.test('t2', () => false); - }); - - const res1 = suite('t1'); - const res2 = suite('t2'); - - expect(res1).not.toBe(res2); - expect(res1.hasErrors('t1')).toBe(true); - expect(res1.hasErrors('t2')).toBe(false); - expect(res2.hasErrors('t1')).toBe(false); - expect(res2.hasErrors('t2')).toBe(true); - }); - - it('Should run async tests normally', () => { - const suite = staticSuite(() => { - vest.test('t1', async () => { - await new Promise(resolve => setTimeout(resolve, 0)); - throw new Error(); - }); - }); - return new Promise(resolve => { - const res = suite(); - - expect(res.hasErrors('t1')).toBe(false); - - res.done(() => { - resolve(); - }); - }); - }); - - describe('dump', () => { - it('should output a dump of the suite', () => { - const suite = staticSuite(() => { - vest.test('t1', () => false); - vest.test('t2', () => false); - - vest.group('g1', () => { - vest.test('t1', () => false); - vest.test('t3', () => false); - }); - }); - - const res = suite(); - - expect(res.dump()).toHaveProperty('$type', VestIsolateType.Suite); - expect(res.dump()).toHaveProperty('children'); - expect(res.dump().children).toHaveLength(3); - expect(res.dump().children?.[0]).toHaveProperty( - '$type', - VestIsolateType.Test, - ); - expect(res.dump().children?.[1]).toHaveProperty( - '$type', - VestIsolateType.Test, - ); - expect(res.dump().children?.[2]).toHaveProperty( - '$type', - VestIsolateType.Group, - ); - - expect(res.dump()).toMatchSnapshot(); - }); - }); -}); describe('runStatic', () => { - it('Should run a static suite', () => { + it('should run a static suite', () => { const suite = vest.create(() => { vest.test('t1', () => false); vest.test('t2', () => false); @@ -123,7 +19,7 @@ describe('runStatic', () => { expect(res.hasErrors('t2')).toBe(true); }); - it('Should serialize and resume a static suite', () => { + it('should serialize and resume a static suite', () => { const suite = vest.create(() => { vest.test('t1', () => false); vest.test('t2', () => false); @@ -144,7 +40,7 @@ describe('runStatic', () => { }); describe('runStatic (promise)', () => { - it("Should resolve with the suite's result", async () => { + it("should resolve with the suite's result", async () => { const suite = vest.create(() => { vest.test('t1', async () => { await wait(100); @@ -158,7 +54,7 @@ describe('runStatic', () => { expect(result.errorCount).toBe(1); }); - it("Should have a dump method on the resolved suite's result", async () => { + it("should have a dump method on the resolved suite's result", async () => { const suite = vest.create(() => { vest.test('t1', async () => { await wait(100); @@ -172,15 +68,15 @@ describe('runStatic', () => { expect(result.dump()).toHaveProperty('$type', VestIsolateType.Suite); }); }); - describe('When creating the suite with a name', () => { - it("Should set the suite's name", () => { - const suite = vest.create('user_form', () => { + describe('suite metadata defaults', () => { + it('should keep suiteName undefined by default', () => { + const suite = vest.create(() => { vest.test('t1', () => false); }); const res = suite.runStatic(); - expect(res.suiteName).toBe('user_form'); + expect(res.suiteName).toBeUndefined(); }); }); }); diff --git a/packages/vest/src/suite/__tests__/subscribe.test.ts b/packages/vest/src/suite/__tests__/subscribe.test.ts index 1ac458eab..e969870d4 100644 --- a/packages/vest/src/suite/__tests__/subscribe.test.ts +++ b/packages/vest/src/suite/__tests__/subscribe.test.ts @@ -6,19 +6,19 @@ import { SuiteSerializer } from 'SuiteSerializer'; import * as vest from 'vest'; describe('suite.subscribe', () => { - it('Should be a function', () => { - const suite = vest.create('suite', () => {}); + it('should be a function', () => { + const suite = vest.create(() => {}); expect(typeof suite.subscribe).toBe('function'); }); - it('Should call the callback on suite updates', async () => { + it('should call the callback on suite updates', async () => { const cb = vi.fn(() => { dumps.push(SuiteSerializer.serialize(suite)); }); let callCount = cb.mock.calls.length; - const suite = vest.create('suite', () => { + const suite = vest.create(() => { expect(cb.mock.calls.length).toBeGreaterThan(callCount); callCount = cb.mock.calls.length; vest.test('field', () => {}); @@ -39,7 +39,7 @@ describe('suite.subscribe', () => { suite.subscribe(cb); expect(cb.mock.calls).toHaveLength(0); - suite(); + suite.run(); expect(cb.mock.calls.length).toBeGreaterThan(callCount); callCount = cb.mock.calls.length; @@ -53,13 +53,13 @@ describe('suite.subscribe', () => { }); describe('Subscribe with event name', () => { - it('Should only call the callback on the specified event', () => { + it('should only call the callback on the specified event', () => { const cbAllDone = vi.fn(); const testDone = vi.fn(); const testStarted = vi.fn(); const suiteStart = vi.fn(); - const suite = vest.create('suite', () => { + const suite = vest.create(() => { vest.test('field1', () => false); vest.test('field2', () => true); vest.test('field3', () => false); @@ -70,7 +70,7 @@ describe('suite.subscribe', () => { suite.subscribe('TEST_RUN_STARTED', testStarted); suite.subscribe('SUITE_RUN_STARTED', suiteStart); - suite(); + suite.run(); expect(cbAllDone).toHaveBeenCalledTimes(1); expect(testDone).toHaveBeenCalledTimes(3); expect(testStarted).toHaveBeenCalledTimes(3); @@ -79,28 +79,28 @@ describe('suite.subscribe', () => { }); describe('unsubscribe', () => { - it('Should unsubscribe future events', () => { + it('should unsubscribe future events', () => { const cb = vi.fn(); - const suite = vest.create('suite', () => { + const suite = vest.create(() => { vest.test('field', () => {}); }); const unsubscribe = suite.subscribe(cb); - suite(); + suite.run(); let callCount = cb.mock.calls.length; enforce(callCount).greaterThan(1); - suite(); + suite.run(); enforce(cb.mock.calls.length).greaterThan(callCount); callCount = cb.mock.calls.length; unsubscribe(); - suite(); + suite.run(); enforce(cb.mock.calls.length).equals(callCount); }); }); }); describe('#1157 (@codrin-iftimie) suite.get() in subscribe() skips the first validation of the field', () => { - it('Should fail for the first field in both runs', () => { + it('should fail for the first field in both runs', () => { const suite = vest.create(data => { vest.test('a', 'Enter a value', () => { enforce(data.a).isNotEmpty(); @@ -115,9 +115,9 @@ describe('#1157 (@codrin-iftimie) suite.get() in subscribe() skips the first val suite.get(); }); - suite({ a: '' }); + suite.run({ a: '' }); expect(suite.getErrors('a')).toEqual(['Enter a value']); - suite({ a: '' }); + suite.run({ a: '' }); expect(suite.getErrors('a')).toEqual(['Enter a value']); }); }); diff --git a/packages/vest/src/suite/__tests__/suite.dump.test.ts b/packages/vest/src/suite/__tests__/suite.dump.test.ts new file mode 100644 index 000000000..59198d275 --- /dev/null +++ b/packages/vest/src/suite/__tests__/suite.dump.test.ts @@ -0,0 +1,65 @@ +import * as vest from 'vest'; + +describe('SuiteSerializer', () => { + it('should produce a valid serialized dump', () => { + const suite = vest.create(() => { + vest.only('field_1'); + + vest.test('field_1', 'field_1_message', () => false); + vest.test('field_2', 'field_2_message', () => false); + + vest.group('group_1', () => { + vest.test('field_3', 'field_3_message_1', () => false); + vest.test('field_3', 'field_3_message_2', () => false); + vest.test('field_4', 'field_4_message', () => false); + }); + + vest.skipWhen(false, () => { + vest.test('field_5', 'field_5_message', () => false); + }); + }); + suite.run(); + + const dump = suite.dump(); + expect(dump).toMatchSnapshot(); + }); +}); + +describe('suite.resume', () => { + it('should resume a suite from a serialized dump', () => { + const suite = vest.create(() => { + vest.only('field_1'); + + vest.test('field_1', 'field_1_message', () => false); + vest.test('field_2', 'field_2_message', () => false); + + vest.group('group_1', () => { + vest.test('field_3', 'field_3_message_1', () => false); + vest.test('field_3', 'field_3_message_2', () => false); + vest.test('field_4', 'field_4_message', () => false); + }); + + vest.skipWhen(false, () => { + vest.test('field_5', 'field_5_message', () => false); + }); + }); + + suite.run(); + suite.get(); + + const dump = suite.dump(); + + const suite2 = vest.create(() => {}); + + suite2.run(); + + expect(suite.get()).not.toEqual(suite2.get()); + + suite2.resume(dump); + + expect(suite.get()).isDeepCopyOf(suite2.get()); + + suite2.run(); + expect(suite.get()).not.toEqual(suite2.get()); + }); +}); diff --git a/packages/vest/src/suite/__tests__/suite.dump.ts b/packages/vest/src/suite/__tests__/suite.dump.ts index 11594ef7d..59198d275 100644 --- a/packages/vest/src/suite/__tests__/suite.dump.ts +++ b/packages/vest/src/suite/__tests__/suite.dump.ts @@ -1,8 +1,8 @@ import * as vest from 'vest'; describe('SuiteSerializer', () => { - it('Should produce a valid serialized dump', () => { - const suite = vest.create('suite_serialize_test', () => { + it('should produce a valid serialized dump', () => { + const suite = vest.create(() => { vest.only('field_1'); vest.test('field_1', 'field_1_message', () => false); @@ -18,7 +18,7 @@ describe('SuiteSerializer', () => { vest.test('field_5', 'field_5_message', () => false); }); }); - suite(); + suite.run(); const dump = suite.dump(); expect(dump).toMatchSnapshot(); @@ -26,7 +26,7 @@ describe('SuiteSerializer', () => { }); describe('suite.resume', () => { - it('Should resume a suite from a serialized dump', () => { + it('should resume a suite from a serialized dump', () => { const suite = vest.create(() => { vest.only('field_1'); @@ -44,14 +44,14 @@ describe('suite.resume', () => { }); }); - suite(); + suite.run(); suite.get(); const dump = suite.dump(); const suite2 = vest.create(() => {}); - suite2(); + suite2.run(); expect(suite.get()).not.toEqual(suite2.get()); @@ -59,7 +59,7 @@ describe('suite.resume', () => { expect(suite.get()).isDeepCopyOf(suite2.get()); - suite2(); + suite2.run(); expect(suite.get()).not.toEqual(suite2.get()); }); }); diff --git a/packages/vest/src/suite/__tests__/suiteSelectorsOnSuite.test.ts b/packages/vest/src/suite/__tests__/suiteSelectorsOnSuite.test.ts index 87b6e3800..d31b0772f 100644 --- a/packages/vest/src/suite/__tests__/suiteSelectorsOnSuite.test.ts +++ b/packages/vest/src/suite/__tests__/suiteSelectorsOnSuite.test.ts @@ -1,13 +1,13 @@ -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, test, expect, beforeEach } from 'vitest'; +import { TTestSuite } from 'TVestMock'; import * as vest from 'vest'; describe('Suite Selectors on Suite', () => { let suite: TTestSuite; beforeEach(() => { - suite = vest.create('suite_name', () => { + suite = vest.create(() => { // failing vest.test('f1', 'f1_message', () => false); // passing @@ -108,6 +108,5 @@ describe('Suite Selectors on Suite', () => { expect(res.isValid('f2')).toEqual(suite.isValid('f2')); expect(res.isValid('f3')).toEqual(suite.isValid('f3')); expect(res.isValid('f4')).toEqual(suite.isValid('f4')); - expect(res.isValidByGroup('g1')).toEqual(suite.isValidByGroup('g1')); }); }); diff --git a/packages/vest/src/suite/__tests__/typedSuite.test.ts b/packages/vest/src/suite/__tests__/typedSuite.test.ts index d2e84daef..91a8785e1 100644 --- a/packages/vest/src/suite/__tests__/typedSuite.test.ts +++ b/packages/vest/src/suite/__tests__/typedSuite.test.ts @@ -12,7 +12,7 @@ describe('typed suite', () => { suite = vest.create(() => {}); }); - it('Should support typed field names and group names', () => { + it('should support typed field names and group names', () => { const result = suite.get(); // Checking that TS doesn't hiccup expect(result.tests.F1).toBeUndefined(); @@ -34,7 +34,7 @@ describe('typed suite', () => { expect(result.groups.G100?.F1).toBeUndefined(); }); - it('Should only support annotated group and field names in the suite methods', () => { + it('should only support annotated group and field names in the suite methods', () => { const res = suite.get(); res.hasErrors('F1'); @@ -47,7 +47,6 @@ describe('typed suite', () => { res.hasWarningsByGroup('G2'); res.hasWarningsByGroup('G3', 'F1'); res.isValid('F1'); - res.isValidByGroup('G2', 'F1'); // @ts-expect-error res.hasErrors('F5'); @@ -58,14 +57,15 @@ describe('typed suite', () => { // @ts-expect-error res.hasWarnings('F10'); - suite().done('F1', res => { - expect(res.tests.F1).toBeUndefined(); - // @ts-expect-error - expect(res.tests.F14).toBeUndefined(); - }); + suite + .after(() => { + expect(suite.get().tests.F1).toBeUndefined(); + // @ts-expect-error + expect(suite.get().tests.F14).toBeUndefined(); + }) + .run(); - // @ts-expect-error - suite().done('F10', () => {}); + suite.after(() => {}).run(); }); }); @@ -78,16 +78,16 @@ describe('typed methods', () => { }); const { test, only } = suite; - suite(); + suite.run(); expect(suite.get().hasErrors('PASSWORD')).toBe(true); }); - test('The suite exposes all typed methods', () => { + it('should expose all typed methods', () => { const suite = vest.create(() => {}); expect(typeof suite.test).toBe('function'); - expect(typeof suite.test.memo).toBe('function'); + expect(typeof suite.test).toBe('function'); expect(typeof suite.only).toBe('function'); expect(typeof suite.skip).toBe('function'); expect(typeof suite.include).toBe('function'); diff --git a/packages/vest/src/suite/after/__tests__/__snapshots__/after.test.ts.snap b/packages/vest/src/suite/after/__tests__/__snapshots__/after.test.ts.snap new file mode 100644 index 000000000..ad5d31929 --- /dev/null +++ b/packages/vest/src/suite/after/__tests__/__snapshots__/after.test.ts.snap @@ -0,0 +1,75 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`suite resolve > awaiting suite > should resolve when all tests finish and after() runs 1`] = ` +{ + "field_1": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_1", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_2": SummaryBase { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 1, + "valid": true, + "warnCount": 0, + "warnings": [], + }, + "field_3": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_3", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, +} +`; + +exports[`suite resolve > should immediately return all sync fields 1`] = ` +{ + "field_1": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_1", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_2": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_2", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_3": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_3", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, +} +`; diff --git a/packages/vest/src/suite/after/__tests__/__snapshots__/done.test.ts.snap b/packages/vest/src/suite/after/__tests__/__snapshots__/done.test.ts.snap new file mode 100644 index 000000000..80a31678a --- /dev/null +++ b/packages/vest/src/suite/after/__tests__/__snapshots__/done.test.ts.snap @@ -0,0 +1,75 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`suite resolve > Should immediately return all sync fields 1`] = ` +{ + "field_1": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_1", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_2": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_2", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_3": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_3", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, +} +`; + +exports[`suite resolve > awaiting suite > Should return a promise that resolves when all tests are done, and the after callback is called 1`] = ` +{ + "field_1": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_1", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, + "field_2": SummaryBase { + "errorCount": 0, + "errors": [], + "pendingCount": 0, + "testCount": 1, + "valid": true, + "warnCount": 0, + "warnings": [], + }, + "field_3": SummaryBase { + "errorCount": 1, + "errors": [ + "field_statement_3", + ], + "pendingCount": 0, + "testCount": 1, + "valid": false, + "warnCount": 0, + "warnings": [], + }, +} +`; diff --git a/packages/vest/src/suite/after/__tests__/after.additional.test.ts b/packages/vest/src/suite/after/__tests__/after.additional.test.ts new file mode 100644 index 000000000..4c1e6282a --- /dev/null +++ b/packages/vest/src/suite/after/__tests__/after.additional.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, test } from 'vitest'; +import wait from 'wait'; + +import { dummyTest } from '../../../testUtils/testDummy'; + +import * as vest from 'vest'; + +describe('after - additional test coverage', () => { + describe('Chaining multiple after callbacks', () => { + it('should execute multiple chained after callbacks in the order they were added', () => { + const executionOrder: number[] = []; + const callback1 = vi.fn(() => { + executionOrder.push(1); + }); + const callback2 = vi.fn(() => { + executionOrder.push(2); + }); + const callback3 = vi.fn(() => { + executionOrder.push(3); + }); + + const suite = vest.create(() => { + dummyTest.passing(); + dummyTest.failing(); + }); + + suite.after(callback1).after(callback2).after(callback3).run(); + + expect(callback1).toHaveBeenCalledOnce(); + expect(callback2).toHaveBeenCalledOnce(); + expect(callback3).toHaveBeenCalledOnce(); + expect(executionOrder).toEqual([1, 2, 3]); + }); + }); + + describe('Return value of after', () => { + it('should return a runnable', () => { + const suite = vest.create(() => { + dummyTest.passing(); + }); + + const returnValue = suite.after(() => {}); + + expect(typeof returnValue.run).toBe('function'); + expect(typeof returnValue.after).toBe('function'); + expect(typeof returnValue.afterField).toBe('function'); + expect(typeof returnValue.focus).toBe('function'); + }); + }); + + describe('Error handling in after callbacks', () => { + it('should not prevent other callbacks from running when one throws an error', () => { + const callback1 = vi.fn(() => { + throw new Error('Test error'); + }); + const callback2 = vi.fn(); + + const suite = vest.create(() => { + dummyTest.passing(); + }); + + // We need to catch the error to prevent it from failing the test + suite.after(callback1).after(callback2).run(); + + expect(callback1).toHaveBeenCalledOnce(); + expect(callback2).toHaveBeenCalledOnce(); + }); + }); + + describe('Different types of callbacks', () => { + it('should work with arrow functions', () => { + const callback = vi.fn(() => 'arrow'); + + const suite = vest.create(() => { + dummyTest.passing(); + }); + + suite.after(callback).run(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveReturnedWith('arrow'); + }); + + it('should work with regular functions', () => { + function regularCallback() { + return 'regular'; + } + const spy = vi.fn(regularCallback); + + const suite = vest.create(() => { + dummyTest.passing(); + }); + + suite.after(spy).run(); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveReturnedWith('regular'); + }); + }); + + describe('after with async tests and multiple callbacks', () => { + it('should call all callbacks after each async test completes', async () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const suite = vest.create(() => { + dummyTest.passingAsync('field_1', { time: 10 }); + dummyTest.failingAsync('field_2', { time: 20 }); + }); + + const result = suite.after(callback1).after(callback2).run(); + + // Initial sync run + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + + // After first async test + await wait(15); + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + + // After second async test + await wait(15); + expect(callback1).toHaveBeenCalledTimes(3); + expect(callback2).toHaveBeenCalledTimes(3); + + await result; + }); + }); + + describe('after callback parameters', () => { + test('should not receive any arguments', () => { + const callback = vi.fn(); + + const suite = vest.create(() => { + vest.test('field_1', () => { + vest.enforce('value').isNotEmpty(); + }); + + vest.test('field_2', () => { + throw new Error('Test error'); + }); + }); + + suite.after(callback).run(); + + expect(callback).toHaveBeenCalledOnce(); + + const param = callback.mock.calls[0][0]; + expect(param).toBeUndefined(); + }); + }); +}); diff --git a/packages/vest/src/suite/after/__tests__/after.test.ts b/packages/vest/src/suite/after/__tests__/after.test.ts new file mode 100644 index 000000000..8fb6ea0aa --- /dev/null +++ b/packages/vest/src/suite/after/__tests__/after.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi } from 'vitest'; +import wait from 'wait'; + +import { dummyTest } from '../../../testUtils/testDummy'; + +import * as vest from 'vest'; + +describe('after', () => { + describe('When no async tests', () => { + it('should call the after callback immediately once', async () => { + const afterCallback = vi.fn(); + + const res = vest + .create(() => { + dummyTest.passing(); + dummyTest.passing(); + dummyTest.failing(); + dummyTest.failing(); + dummyTest.passing(); + dummyTest.failingWarning('field_2'); + }) + .after(afterCallback) + .run(); + + expect(afterCallback).toHaveBeenCalledOnce(); + + await res; + expect(afterCallback).toHaveBeenCalledOnce(); + }); + }); + + describe('When both sync and async tests', () => { + it('should call the `after` callback once when the sync tests are done and again for each async test', async () => { + const afterCallback = vi.fn(); + const suite = vest.create(() => { + dummyTest.passing(); + dummyTest.failingAsync('field_1', { time: 10 }); + dummyTest.failingAsync('field_2', { time: 15 }); + }); + suite.after(afterCallback).run(); + expect(afterCallback).toHaveBeenCalledTimes(1); + await wait(10); + expect(afterCallback).toHaveBeenCalledTimes(2); + await wait(10); + expect(afterCallback).toHaveBeenCalledTimes(3); + }); + }); + + describe('When suite lags and callbacks are registered again', () => { + it('should cancel any pending callback runs and only conclude the most recent ones', async () => { + const test = []; + let count = 0; + const suite = vest.create(() => { + test.push( + dummyTest.failingAsync('test', { + time: 100, + message: 'run ' + count++, + }), + ); + }); + + const firstCall_1 = vi.fn(() => 'a'); + const firstCall_2 = vi.fn(() => 'b'); + const secondCall_1 = vi.fn(() => 'c'); + const secondCall_2 = vi.fn(() => 'd'); + + suite.after(firstCall_1).after(firstCall_2).run(); + + await suite.after(secondCall_1).after(secondCall_2).run(); + // the second run canceled the first run callbacks so they only ran once + expect(firstCall_1).toHaveBeenCalledTimes(1); + expect(firstCall_2).toHaveBeenCalledTimes(1); + + // the second run callbacks ran twice because they also ran for the async tests + expect(secondCall_1).toHaveBeenCalledTimes(2); + expect(secondCall_2).toHaveBeenCalledTimes(2); + }); + }); + + describe('When there are async tests', () => { + it('should run after each async test finishes', async () => { + const afterCallback = vi.fn(); + expect(afterCallback).toHaveBeenCalledTimes(0); + const res = vest + .create(() => { + dummyTest.passingAsync('field_1', { time: 0 }); + dummyTest.failingAsync('field_2', { time: 20 }); + dummyTest.passingAsync('field_3', { time: 40 }); + dummyTest.failing(); + dummyTest.passing(); + }) + .after(afterCallback) + .run(); + + expect(afterCallback).toHaveBeenCalledTimes(1); + await wait(0); + expect(afterCallback).toHaveBeenCalledTimes(2); + await wait(20); + expect(afterCallback).toHaveBeenCalledTimes(3); + + await wait(40); + expect(afterCallback).toHaveBeenCalledTimes(4); + + await res; + expect(afterCallback).toHaveBeenCalledTimes(4); + }); + }); + + describe('When no tests are run', () => { + it('should run the callback', () => { + const cb = vi.fn(); + + const suite = vest.create(() => {}); + + suite.after(cb).run(); + + expect(cb).toHaveBeenCalled(); + }); + + describe('When tests are omitted', () => { + it('should run the callback', () => { + const cb = vi.fn(); + + const suite = vest.create(() => { + vest.optional({ f1: true }); + + vest.test('f1', () => {}); + }); + + suite.after(cb).run(); + expect(suite.get().tests.f1.testCount).toBe(0); + expect(cb).toHaveBeenCalled(); + }); + }); + }); + + describe('Async Isolate', () => { + describe('When async isolate is pending', () => { + it('should call the callback for the sync run completion only', async () => { + const cb = vi.fn(); + + const suite = vest.create(() => { + vest.test('f1', () => false); + + vest.test('f2', async () => { + await wait(100); + throw new Error(); + }); + }); + + const res = suite.after(cb).run(); + + expect(cb).toHaveBeenCalledOnce(); + + await res; + expect(cb).toHaveBeenCalledTimes(2); + }); + }); + + describe('When async isolate is completed', () => { + it('should call the callback', async () => { + const cb = vi.fn(); + + const suite = vest.create(() => { + vest.test('test', () => false); + + vest.group('group', async () => { + await wait(1000); + }); + }); + + suite.after(cb).run(); + await wait(1000); + expect(cb).toHaveBeenCalled(); + }); + }); + }); +}); + +describe('suite resolve', () => { + it('should immediately return all sync fields', () => { + const suite = vest.create(() => { + vest.test('field_1', 'field_statement_1', () => false); + vest.test('field_2', 'field_statement_2', () => false); + vest.test('field_3', 'field_statement_3', () => false); + }); + + const result = suite.run(); + + expect(result.tests).toHaveProperty('field_1'); + expect(result.tests).toHaveProperty('field_2'); + expect(result.tests).toHaveProperty('field_3'); + expect(result.hasErrors('field_1')).toBe(true); + expect(result.hasErrors('field_2')).toBe(true); + expect(result.hasErrors('field_3')).toBe(true); + expect(result.tests).toMatchSnapshot(); + }); + describe('awaiting suite', () => { + it('should resolve when all tests finish and after() runs', async () => { + const suite = vest.create(() => { + vest.test('field_1', 'field_statement_1', async () => { + await wait(100); + throw new Error(); + }); + vest.test('field_2', 'field_statement_2', async () => { + await wait(100); + }); + vest.test('field_3', 'field_statement_3', async () => { + await wait(100); + throw new Error(); + }); + }); + + const result = await suite.run(); + + expect(result.tests).toHaveProperty('field_1'); + expect(result.tests).toHaveProperty('field_2'); + expect(result.tests).toHaveProperty('field_3'); + expect(result.hasErrors('field_1')).toBe(true); + expect(result.hasErrors('field_2')).toBe(false); + expect(result.hasErrors('field_3')).toBe(true); + expect(result.tests).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/vest/src/suiteResult/done/deferDoneCallback.ts b/packages/vest/src/suite/after/deferDoneCallback.ts similarity index 75% rename from packages/vest/src/suiteResult/done/deferDoneCallback.ts rename to packages/vest/src/suite/after/deferDoneCallback.ts index 857dce482..c884acdf9 100644 --- a/packages/vest/src/suiteResult/done/deferDoneCallback.ts +++ b/packages/vest/src/suite/after/deferDoneCallback.ts @@ -11,7 +11,7 @@ export function useDeferDoneCallback( const [, setDoneCallbacks] = useDoneCallbacks(); if (fieldName) { - setFieldCallbacks(fieldCallbacks => + setFieldCallbacks((fieldCallbacks: Record) => assign(fieldCallbacks, { [fieldName]: (fieldCallbacks[fieldName] || []).concat(doneCallback), }), @@ -20,5 +20,7 @@ export function useDeferDoneCallback( return; } - setDoneCallbacks(doneCallbacks => doneCallbacks.concat(doneCallback)); + setDoneCallbacks((doneCallbacks: DoneCallback[]) => + doneCallbacks.concat(doneCallback), + ); } diff --git a/packages/vest/src/suite/createSuite.ts b/packages/vest/src/suite/createSuite.ts index 202027f96..92ef8216e 100644 --- a/packages/vest/src/suite/createSuite.ts +++ b/packages/vest/src/suite/createSuite.ts @@ -1,193 +1,209 @@ -import { asArray, assign, CB } from 'vest-utils'; +import type { RuleInstance } from 'n4s'; +import { CB, assign, withResolvers } from 'vest-utils'; import { Bus, VestRuntime } from 'vestjs-runtime'; -import { TTypedMethods, getTypedMethods } from './getTypedMethods'; +import { getTypedMethods } from './getTypedMethods'; import { IsolateSuite, TIsolateSuite } from 'IsolateSuite'; import { useCreateVestState, useLoadSuite } from 'Runtime'; import { SuiteContext } from 'SuiteContext'; -import { - SuiteName, - SuiteResult, - SuiteRunResult, - TFieldName, - TGroupName, -} from 'SuiteResultTypes'; -import { Suite } from 'SuiteTypes'; +import { SuiteResult, TFieldName, TGroupName } from 'SuiteResultTypes'; +import { InferSuiteData, Suite, SuiteModifiers } from 'SuiteTypes'; import { useInitVestBus } from 'VestBus'; import { VestReconciler } from 'VestReconciler'; +import { useDeferDoneCallback } from 'deferDoneCallback'; +import { only } from 'focused'; import { useCreateSuiteResult } from 'suiteResult'; -import { useSuiteRunResult } from 'suiteRunResult'; import { bindSuiteSelectors } from 'suiteSelectors'; import { validateSuiteCallback } from 'validateSuiteParams'; function createSuite< - F extends TFieldName = string, + F extends TFieldName, G extends TGroupName = string, - T extends CB = CB, ->(suiteName: SuiteName, suiteCallback: T): Suite; + T extends (...args: any[]) => any = (...args: any[]) => any, +>(suiteCallback: T): Suite; function createSuite< - F extends TFieldName = string, + F extends TFieldName, + S extends RuleInstance, G extends TGroupName = string, - T extends CB = CB, ->(suiteCallback: T): Suite; + Rest extends any[] = [], + T extends (...args: [InferSuiteData, ...Rest]) => any = ( + ...args: [InferSuiteData, ...Rest] + ) => any, +>(suiteCallback: T, schema: S): Suite; // @vx-allow use-use // eslint-disable-next-line max-lines-per-function function createSuite< - F extends TFieldName = string, + F extends TFieldName, G extends TGroupName = string, - T extends CB = CB, ->( - ...args: [suiteName: SuiteName, suiteCallback: T] | [suiteCallback: T] -): Suite { - const [suiteCallback, suiteName] = asArray(args).reverse() as [T, SuiteName]; - + T extends (...args: any[]) => any = (...args: any[]) => any, + S extends RuleInstance | undefined = undefined, +>(suiteCallback: T, schema?: S): Suite { validateSuiteCallback(suiteCallback); // Create a stateRef for the suite // It holds the suite's persisted values that may remain between runs. - const stateRef = useCreateVestState({ suiteName, VestReconciler }); - - function suite(...args: Parameters): SuiteRunResult { - return SuiteContext.run( - { - suiteParams: args, - }, - () => { - Bus.useEmit('SUITE_RUN_STARTED'); - - return IsolateSuite( - useRunSuiteCallback(suiteCallback, ...args), - ); - }, - ).output; - } - - const mountedStatic = staticSuite(...(args as [T])); + const stateRef = useCreateVestState({ suiteName: undefined, VestReconciler }); // Assign methods to the suite // We do this within the VestRuntime so that the suite methods // will be bound to the suite's stateRef and be able to access it. return VestRuntime.Run(stateRef, () => { - // @vx-allow use-use const VestBus = useInitVestBus(); + return createSuiteInstance(); - return assign( - // We're also binding the suite to the stateRef, so that the suite - // can access the stateRef when it's called. - VestRuntime.persist(suite), - { - dump: VestRuntime.persist( - () => VestRuntime.useAvailableRoot() as TIsolateSuite, - ), - get: VestRuntime.persist(useCreateSuiteResult), + function createSuiteInstance(): Suite { + const modifiers: SuiteModifiers = { only: undefined }; + + const persistedRun = VestRuntime.persist( + useCreateSuiteRunner(suiteCallback, modifiers, schema), + ); + + const getResult = VestRuntime.persist( + () => useCreateSuiteResult(schema) as SuiteResult, + ); + const getResultForSelectors = VestRuntime.persist( + () => + useCreateSuiteResult(schema) as SuiteResult, + ); + + const { after, afterField, focus } = useCreateSuiteMethods( + persistedRun, + modifiers, + ); + + return { + after: VestRuntime.persist(initCallback(after)), + afterField: VestRuntime.persist(initCallback(afterField)), + dump: VestRuntime.persist(VestRuntime.useAvailableRoot), + focus: VestRuntime.persist(focus), + get: getResult, remove: Bus.usePrepareEmitter('REMOVE_FIELD'), reset: Bus.usePrepareEmitter('RESET_SUITE'), resetField: Bus.usePrepareEmitter('RESET_FIELD'), resume: VestRuntime.persist(useLoadSuite), - runStatic: (...args: Parameters): StaticSuiteRunResult => - mountedStatic(...args) as StaticSuiteRunResult, + run: VestRuntime.persist(initCallback(persistedRun)), + runStatic: VestRuntime.persist(createStaticRunner()), subscribe: VestBus.subscribe, - ...bindSuiteSelectors(VestRuntime.persist(useCreateSuiteResult)), + ...bindSuiteSelectors(getResultForSelectors), ...getTypedMethods(), - }, - ); + }; + + function initCallback any>(cb: U): U { + return ((...args: Parameters) => { + Bus.useEmit('INITIALIZING_CALLBACKS'); + return cb(...args); + }) as U; + } + } }); + + function createStaticRunner(): ( + ...runArgs: Parameters + ) => SuiteResult { + return function runStatic( + ...runArgs: Parameters + ): SuiteResult { + const suite = schema + ? (createSuite(suiteCallback as T, schema) as Suite) + : (createSuite(suiteCallback as T) as Suite); + + return suite.run(...runArgs) as SuiteResult; + }; + } } -function useRunSuiteCallback< - T extends CB, +function useCreateSuiteMethods< F extends TFieldName, G extends TGroupName, ->(suiteCallback: T, ...args: Parameters): CB> { - const emit = Bus.useEmit(); - - return () => { - suiteCallback(...args); - emit('SUITE_CALLBACK_RUN_FINISHED'); - return useSuiteRunResult(); - }; -} - -/** - * Creates a static suite for server-side validation. - * - * @param {Function} validationFn - The validation function that defines the suite's tests. - * @returns {Function} - A function that runs the validations defined in the suite. - * - * @example - * import { staticSuite, test, enforce } from 'vest'; - * - * const suite = staticSuite(data => { - * test('username', 'username is required', () => { - * enforce(data.username).isNotEmpty(); - * }); - * }); - * - * suite(data); - */ - -function staticSuite< - F extends TFieldName = string, - G extends TGroupName = string, - T extends CB = CB, ->(suiteName: SuiteName, suiteCallback: T): StaticSuite; -function staticSuite< - F extends TFieldName = string, - G extends TGroupName = string, - T extends CB = CB, ->(suiteCallback: T): StaticSuite; -// @vx-allow use-use -// eslint-disable-next-line max-lines-per-function -function staticSuite< - F extends TFieldName = string, - G extends TGroupName = string, T extends CB = CB, + S extends RuleInstance | undefined = undefined, >( - ...createArgs: [suiteName: SuiteName, suiteCallback: T] | [suiteCallback: T] -): StaticSuite { - return assign( - (...args: Parameters): StaticSuiteRunResult => { - const suite = createSuite( - ...(createArgs as unknown as [SuiteName, T]), - ); + persistedRun: (...args: Parameters) => SuiteResult, + modifiers: SuiteModifiers, +) { + return getPreRunMethods(); - const result = suite(...args); + function after(cb: CB) { + return addAfter(cb); + } - return assign( - new Promise>(resolve => { - result.done(res => { - resolve(withDump(res) as SuiteWithDump); - }); - }), - withDump(result), - ); + function afterField(fieldName: F, cb: CB) { + return addAfter(cb, fieldName); + } - function withDump(o: any) { - return assign({ dump: suite.dump }, o); - } - }, - { - ...getTypedMethods(), - }, - ); -} + function focus(config: SuiteModifiers) { + modifiers.only = config.only; -export type StaticSuite< - F extends TFieldName = string, - G extends TGroupName = string, - T extends CB = CB, -> = (...args: Parameters) => StaticSuiteRunResult; + return getPreRunMethods(); + } -export type StaticSuiteRunResult< - F extends TFieldName = string, - G extends TGroupName = string, -> = Promise> & - WithDump & TTypedMethods>; + function addAfter(cb: CB, fieldName?: F) { + const returnValue = getPreRunMethods(); -type WithDump = T & { dump: CB }; -type SuiteWithDump = WithDump< - SuiteResult ->; + useDeferDoneCallback(withCatch(cb), fieldName); + return returnValue; + } + + function getPreRunMethods() { + return { + after: VestRuntime.persist(after), + afterField: VestRuntime.persist(afterField), + focus, + run: persistedRun, + }; + } +} + +function withCatch(cb: CB): () => T | unknown { + return () => { + try { + cb(); + } catch (error) { + return error; + } + }; +} + +function useCreateSuiteRunner< + F extends TFieldName, + G extends TGroupName, + T extends CB = CB, + S extends RuleInstance | undefined = undefined, +>(suiteCallback: T, modifiers: SuiteModifiers, schema?: S) { + return function runSuite(...args: Parameters): SuiteResult { + const { resolve, promise } = withResolvers>(); + return assign( + promise, + SuiteContext.run( + { + schema, + suiteParams: args, + }, + () => { + Bus.useEmit('SUITE_RUN_STARTED'); + + function resolver() { + const result = useCreateSuiteResult(schema) as SuiteResult; + resolve(result); + return result as unknown as SuiteResult; + } + + return IsolateSuite(() => { + only(modifiers.only); + // TODO: Implement automatic schema validation on suite.run() + suiteCallback(...args); + Bus.useEmit('SUITE_CALLBACK_RUN_FINISHED'); + return useCreateSuiteResult(schema) as unknown as SuiteResult< + TFieldName, + TGroupName, + S + >; + }, resolver); + }, + ).output, + ); + }; +} -export { createSuite, staticSuite }; +export { createSuite }; diff --git a/packages/vest/src/suite/getTypedMethods.ts b/packages/vest/src/suite/getTypedMethods.ts index 5536e307a..56328e81b 100644 --- a/packages/vest/src/suite/getTypedMethods.ts +++ b/packages/vest/src/suite/getTypedMethods.ts @@ -13,7 +13,6 @@ import { include } from 'include'; import { omitWhen } from 'omitWhen'; import { skipWhen } from 'skipWhen'; import { test } from 'test'; -import { TestMemo } from 'test.memo'; export function getTypedMethods< F extends TFieldName, @@ -49,8 +48,6 @@ export type TTypedMethods = { (fieldName: F, cb: TestFn): TIsolateTest; (fieldName: F, message: string, cb: TestFn, key: IsolateKey): TIsolateTest; (fieldName: F, cb: TestFn, key: IsolateKey): TIsolateTest; - } & { - memo: TestMemo; }; group: { (callback: () => void): TIsolate; diff --git a/packages/vest/src/suite/runCallbacks.ts b/packages/vest/src/suite/runCallbacks.ts index 813579805..a72fe2bc6 100644 --- a/packages/vest/src/suite/runCallbacks.ts +++ b/packages/vest/src/suite/runCallbacks.ts @@ -7,11 +7,10 @@ import { SuiteWalker } from 'SuiteWalker'; /** * Runs done callback per field when async tests are finished running. */ -export function useRunFieldCallbacks(fieldName?: TFieldName): void { +export function useRunFieldCallbacks(fieldName: TFieldName): void { const [fieldCallbacks] = useFieldCallbacks(); if ( - fieldName && !SuiteWalker.useHasRemainingWithTestNameMatching(fieldName) && isArray(fieldCallbacks[fieldName]) ) { @@ -19,6 +18,20 @@ export function useRunFieldCallbacks(fieldName?: TFieldName): void { } } +export function useRunSyncFieldCallbacks(): void { + const [fieldCallbacks] = useFieldCallbacks(); + + for (const fieldName in fieldCallbacks) { + if (SuiteWalker.useHasRemainingWithTestNameMatching(fieldName)) { + continue; + } + + if (isArray(fieldCallbacks[fieldName])) { + callEach(fieldCallbacks[fieldName]); + } + } +} + /** * Runs unlabelled done callback when async tests are finished running. */ diff --git a/packages/vest/src/suiteResult/SuiteResultTypes.ts b/packages/vest/src/suiteResult/SuiteResultTypes.ts index e3b9fc05b..5d43a14c8 100644 --- a/packages/vest/src/suiteResult/SuiteResultTypes.ts +++ b/packages/vest/src/suiteResult/SuiteResultTypes.ts @@ -1,8 +1,9 @@ -import { Maybe, Nullable } from 'vest-utils'; +import type { RuleInstance } from 'n4s'; +import { CB, Maybe, Nullable } from 'vest-utils'; +import { TIsolateSuite } from 'IsolateSuite'; import { Severity } from 'Severity'; import { SummaryFailure } from 'SummaryFailure'; -import { Done } from 'suiteRunResult'; import { SuiteSelectors } from 'suiteSelectors'; export class SummaryBase { @@ -26,37 +27,46 @@ export class SuiteSummary< export type TestsContainer = | Group | Tests; -export type GroupTestSummary = SingleTestSummary; -export type Groups = Record< - G, - Group ->; -export type Group = Record; +export type Groups = { + [key in G]: Group; +}; +type Group = Record & ValidProperty; export type Tests = Record; -export type SingleTestSummary = SummaryBase & { +export type SingleTestSummary = SummaryBase & + CommonSummaryProperties & + ValidProperty; + +type ValidProperty = { + valid: Nullable; +}; + +export type CommonSummaryProperties = SummaryBase & { errors: string[]; warnings: string[]; - valid: Nullable; - pendingCount: number; }; export type GetFailuresResponse = FailureMessages | string[]; export type FailureMessages = Record; -export type SuiteResult< - F extends TFieldName, - G extends TGroupName, -> = SuiteSummary & SuiteSelectors & { suiteName: SuiteName }; +export type SuiteSchemaTypes< + S extends RuleInstance | undefined, +> = S extends RuleInstance + ? { data: Data; schema: S } + : undefined; -export type SuiteRunResult< +export type SuiteResult< F extends TFieldName, G extends TGroupName, -> = SuiteResult & { - done: Done; -}; + S extends RuleInstance | undefined = undefined, +> = SuiteSummary & + SuiteSelectors & { + suiteName: SuiteName; + dump: CB; + types: SuiteSchemaTypes; + }; export type SuiteName = Maybe; diff --git a/packages/vest/src/suiteResult/SummaryFailure.ts b/packages/vest/src/suiteResult/SummaryFailure.ts index 4ea4981fc..efd8e2c60 100644 --- a/packages/vest/src/suiteResult/SummaryFailure.ts +++ b/packages/vest/src/suiteResult/SummaryFailure.ts @@ -13,10 +13,11 @@ export class SummaryFailure ) {} static fromTestObject( - testObject: TIsolateTest, - ) { - const { fieldName, message, groupName } = VestTest.getData(testObject); + testObject: TIsolateTest, + ): SummaryFailure { + const { fieldName, message } = VestTest.getData(testObject); + const groupName = VestTest.getGroupName(testObject); - return new SummaryFailure(fieldName, message, groupName); + return new SummaryFailure(fieldName, message, groupName); } } diff --git a/packages/vest/src/suiteResult/__tests__/__snapshots__/useProduceSuiteSummary.test.ts.snap b/packages/vest/src/suiteResult/__tests__/__snapshots__/useProduceSuiteSummary.test.ts.snap index 784f43cc2..e7bd11872 100644 --- a/packages/vest/src/suiteResult/__tests__/__snapshots__/useProduceSuiteSummary.test.ts.snap +++ b/packages/vest/src/suiteResult/__tests__/__snapshots__/useProduceSuiteSummary.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`suite() > exposed methods > Should have all exposed methods 1`] = ` -{ - "done": [Function], +exports[`suite.get() > exposed methods > Should have all exposed methods 1`] = ` +SuiteSummary { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -25,14 +25,16 @@ exports[`suite() > exposed methods > Should have all exposed methods 1`] = ` "suiteName": undefined, "testCount": 0, "tests": {}, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], } `; -exports[`suite.get() > exposed methods > Should have all exposed methods 1`] = ` -SuiteSummary { +exports[`suite.run() > exposed methods > Should have all exposed methods 1`] = ` +Promise { + "dump": [Function], "errorCount": 0, "errors": [], "getError": [Function], @@ -55,6 +57,7 @@ SuiteSummary { "suiteName": undefined, "testCount": 0, "tests": {}, + "types": undefined, "valid": false, "warnCount": 0, "warnings": [], diff --git a/packages/vest/src/suiteResult/__tests__/useProduceSuiteSummary.test.ts b/packages/vest/src/suiteResult/__tests__/useProduceSuiteSummary.test.ts index bb6c3cefa..70e943d2c 100644 --- a/packages/vest/src/suiteResult/__tests__/useProduceSuiteSummary.test.ts +++ b/packages/vest/src/suiteResult/__tests__/useProduceSuiteSummary.test.ts @@ -1,24 +1,24 @@ -import { Modes } from 'Modes'; import { describe, it, expect } from 'vitest'; import wait from 'wait'; import { ser } from '../../testUtils/suiteDummy'; import { dummyTest } from '../../testUtils/testDummy'; +import { Modes } from 'Modes'; import * as vest from 'vest'; describe('useProduceSuiteSummary', () => { describe('Base structure', () => { it('Should match snapshot', () => { const suite = vest.create(() => {}); - expect(suite()).toMatchObject({ + expect(suite.run()).toMatchObject({ errorCount: 0, groups: {}, testCount: 0, tests: {}, warnCount: 0, }); - expect(ser(suite())).toEqual(ser(suite.get())); + expect(ser(suite.run())).toEqual(ser(suite.get())); }); it('Its methods should reflect the correct test data', () => { @@ -42,7 +42,7 @@ describe('useProduceSuiteSummary', () => { }); }); - const res = suite(); + const res = suite.run(); expect(ser(suite.get())).toEqual(ser(res)); @@ -88,7 +88,7 @@ describe('useProduceSuiteSummary', () => { dummyTest.passing('field_1'); dummyTest.failing('field_1', 'message'); }); - const res = suite(); + const res = suite.run(); expect(ser(res)).toEqual(ser(suite.get())); expect(suite.get()).toBe(suite.get()); }); @@ -102,9 +102,9 @@ describe('useProduceSuiteSummary', () => { vest.enforce(v2).equals(2); }); }); - const res1 = suite(1, 2); - const res2 = suite(1, 1); - suite(2, 1); + const res1 = suite.run(1, 2); + const res2 = suite.run(1, 1); + suite.run(2, 1); expect(res1).not.toMatchObject(suite.get()); expect(res1).not.toBe(suite.get()); expect(res2).not.toMatchObject(suite.get()); @@ -122,10 +122,10 @@ describe('suite.get()', () => { }); }); -describe('suite()', () => { +describe('suite.run()', () => { describe('exposed methods', () => { it('Should have all exposed methods', () => { - expect(vest.create(() => {})()).toMatchSnapshot(); + expect(vest.create(() => {}).run()).toMatchSnapshot(); }); }); }); @@ -136,9 +136,9 @@ describe('pendingCount', () => { vest.test('f1', () => {}); vest.test('f2', () => {}); }); - expect(suite().pendingCount).toBe(0); - expect(suite().tests.f1.pendingCount).toBe(0); - expect(suite().tests.f2.pendingCount).toBe(0); + expect(suite.run().pendingCount).toBe(0); + expect(suite.run().tests.f1.pendingCount).toBe(0); + expect(suite.run().tests.f2.pendingCount).toBe(0); }); it('Should increment when a test is pending', () => { @@ -146,10 +146,10 @@ describe('pendingCount', () => { vest.test('f1', async () => {}); vest.test('f2', async () => {}); }); - suite(); - expect(suite().pendingCount).toBe(2); - expect(suite().tests.f1.pendingCount).toBe(1); - expect(suite().tests.f2.pendingCount).toBe(1); + suite.run(); + expect(suite.run().pendingCount).toBe(2); + expect(suite.run().tests.f1.pendingCount).toBe(1); + expect(suite.run().tests.f2.pendingCount).toBe(1); }); it('Should increment per multiple pending tests of the same field', () => { @@ -158,10 +158,10 @@ describe('pendingCount', () => { vest.test('f1', async () => {}); vest.test('f2', async () => {}); }); - suite(); - expect(suite().pendingCount).toBe(3); - expect(suite().tests.f1.pendingCount).toBe(2); - expect(suite().tests.f2.pendingCount).toBe(1); + suite.run(); + expect(suite.run().pendingCount).toBe(3); + expect(suite.run().tests.f1.pendingCount).toBe(2); + expect(suite.run().tests.f2.pendingCount).toBe(1); }); it('Should decrement when a test is done', async () => { @@ -169,10 +169,10 @@ describe('pendingCount', () => { vest.test('f1', async () => {}); vest.test('f2', async () => {}); }); - suite(); - expect(suite().pendingCount).toBe(2); - expect(suite().tests.f1.pendingCount).toBe(1); - expect(suite().tests.f2.pendingCount).toBe(1); + suite.run(); + expect(suite.run().pendingCount).toBe(2); + expect(suite.run().tests.f1.pendingCount).toBe(1); + expect(suite.run().tests.f2.pendingCount).toBe(1); await wait(0); expect(suite.get().pendingCount).toBe(0); expect(suite.get().tests.f1.pendingCount).toBe(0); diff --git a/packages/vest/src/suiteResult/done/__tests__/done.test.ts b/packages/vest/src/suiteResult/done/__tests__/done.test.ts deleted file mode 100644 index fcb06248f..000000000 --- a/packages/vest/src/suiteResult/done/__tests__/done.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import wait from 'wait'; - -import { dummyTest } from '../../../testUtils/testDummy'; -import { TestPromise } from '../../../testUtils/testPromise'; - -import * as vest from 'vest'; - -describe('done', () => { - describe('When no async tests', () => { - it('Should call done callback immediately', () => { - const result = vest.create(() => { - dummyTest.passing(); - dummyTest.passing(); - dummyTest.failing(); - dummyTest.failing(); - dummyTest.passing(); - dummyTest.failingWarning('field_2'); - })(); - - const doneCallback = vi.fn(); - const fieldDoneCallback = vi.fn(); - - result.done(doneCallback).done('field_2', fieldDoneCallback); - - expect(doneCallback).toHaveBeenCalled(); - expect(fieldDoneCallback).toHaveBeenCalled(); - }); - }); - - describe('When suite lags and callbacks are registered again', () => { - it('should only run most recent registered callbacks', async () => { - const test = []; - const suite = vest.create(() => { - test.push(dummyTest.failingAsync('test', { time: 100 })); - }); - - const doneCallback1 = vi.fn(); - const fieldDoneCallback1 = vi.fn(); - const doneCallback2 = vi.fn(); - const fieldDoneCallback2 = vi.fn(); - - suite().done(doneCallback1).done('test', fieldDoneCallback1); - await wait(10); - suite().done(doneCallback2).done('test', fieldDoneCallback2); - await wait(100); - expect(doneCallback2).toHaveBeenCalledTimes(1); - expect(fieldDoneCallback2).toHaveBeenCalledTimes(1); - expect(doneCallback1).toHaveBeenCalledTimes(0); - expect(fieldDoneCallback1).toHaveBeenCalledTimes(0); - }); - }); - - describe('When there are async tests', () => { - describe('When field name is not passed', () => { - it('Should run the done callback after all the fields finished running', () => { - const check1 = vi.fn(); - const check2 = vi.fn(); - const check3 = vi.fn(); - return TestPromise(done => { - const doneCallback = vi.fn(() => { - expect(check1).toHaveBeenCalled(); - expect(check2).toHaveBeenCalled(); - expect(check3).toHaveBeenCalled(); - done(); - }); - const result = vest.create(() => { - dummyTest.passingAsync('field_1', { time: 1000 }); - dummyTest.failingAsync('field_2', { time: 100 }); - dummyTest.passingAsync('field_3', { time: 0 }); - dummyTest.failing(); - dummyTest.passing(); - })(); - - result.done(doneCallback); - - setTimeout(() => { - expect(doneCallback).not.toHaveBeenCalled(); - check1(); - }); - setTimeout(() => { - expect(doneCallback).not.toHaveBeenCalled(); - check2(); - }, 150); - setTimeout(() => { - expect(doneCallback).not.toHaveBeenCalled(); - check3(); - }, 900); - }); - }); - }); - }); - - describe('done arguments', () => { - it('Should pass down the up to date validation result', () => { - return TestPromise(done => { - const result = vest.create(() => { - dummyTest.failing('field_1', 'error message'); - dummyTest.passing('field_2'); - dummyTest.passingAsync('field_3', { time: 0 }); - dummyTest.failingAsync('field_4', { - message: 'error_message', - time: 100, - }); - dummyTest.passingAsync('field_5', { time: 1000 }); - })(); - - result - .done('field_2', res => { - expect(res.getErrors()).toEqual({ - field_1: ['error message'], - }); - expect(res).toMatchObject({ - errorCount: 1, - groups: {}, - testCount: 5, - tests: { - field_1: { - errorCount: 1, - errors: ['error message'], - testCount: 1, - warnCount: 0, - }, - field_2: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_3: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_4: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_5: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - }, - warnCount: 0, - }); - }) - .done('field_3', res => { - expect(res).toMatchObject({ - errorCount: 1, - groups: {}, - testCount: 5, - tests: { - field_1: { - errorCount: 1, - errors: ['error message'], - testCount: 1, - warnCount: 0, - }, - field_2: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_3: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_4: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_5: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - }, - warnCount: 0, - }); - }) - .done('field_4', res => { - expect(res.getErrors()).toEqual({ - field_1: ['error message'], - field_4: ['error_message'], - }); - expect(res).toMatchObject({ - errorCount: 2, - groups: {}, - testCount: 5, - tests: { - field_1: { - errorCount: 1, - errors: ['error message'], - testCount: 1, - warnCount: 0, - }, - field_2: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_3: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_4: { - errorCount: 1, - errors: ['error_message'], - testCount: 1, - warnCount: 0, - }, - field_5: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - }, - warnCount: 0, - }); - }) - .done(res => { - expect(res).toMatchObject({ - errorCount: 2, - groups: {}, - testCount: 5, - tests: { - field_1: { - errorCount: 1, - errors: ['error message'], - testCount: 1, - warnCount: 0, - }, - field_2: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_3: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - field_4: { - errorCount: 1, - errors: ['error_message'], - testCount: 1, - warnCount: 0, - }, - field_5: { - errorCount: 0, - testCount: 1, - warnCount: 0, - }, - }, - warnCount: 0, - }); - done(); - }); - }); - }); - }); - - describe('When a different field is run while a field is pending', () => { - it('Should wait running done callbacks until all tests complete', () => { - const suite = vest.create(only => { - vest.only(only); - - vest.test('async_1', async () => { - await wait(1000); - throw new Error(); - }); - - vest.test('sync_2', () => false); - }); - - suite('async_1'); - - return TestPromise(done => { - suite('sync_2').done(res => { - expect(res.hasErrors('async_1')).toBe(true); - done(); - }); - }); - }); - }); - - describe('When suite re-runs and a pending test is now skipped', () => { - it('Should immediately call the second done callback, omit the first', async () => { - const done_0 = vi.fn(); - const done_1 = vi.fn(); - - const suite = vest.create(username => { - vest.test('username', () => { - vest.enforce(username).isNotBlank(); - }); - - vest.skipWhen(suite.get().hasErrors('username'), () => { - vest.test('username', async () => { - await wait(1000); - if (username === 'ealush') { - throw new Error(); - } - }); - }); - }); - - suite('ealush').done(done_0); - await wait(0); - expect(done_0).not.toHaveBeenCalled(); - suite('').done(done_1); - expect(done_0).not.toHaveBeenCalled(); - expect(done_1).toHaveBeenCalled(); - await wait(1000); - expect(done_0).not.toHaveBeenCalled(); - }); - }); - - describe('Passing a field that does not exist', () => { - it('Should avoid calling the callback', () => { - const cb = vi.fn(); - - const suite = vest.create(() => { - vest.test('test', () => {}); - }); - - suite().done('non-existent', cb); - - expect(cb).not.toHaveBeenCalled(); - }); - }); - - describe('When no tests are run', () => { - it('Should run the callback', () => { - const cb = vi.fn(); - - const suite = vest.create(() => {}); - - suite().done(cb); - - expect(cb).toHaveBeenCalled(); - }); - - describe('When tests are omitted', () => { - it('Should run the callback', () => { - const cb = vi.fn(); - - const suite = vest.create(() => { - vest.optional({ f1: true }); - - vest.test('f1', () => {}); - }); - - suite().done(cb); - expect(suite.get().tests.f1.testCount).toBe(0); - expect(cb).toHaveBeenCalled(); - }); - }); - }); - - describe('When focused done call does not match executed tests', () => { - it('Should not call the callback', () => { - const cb = vi.fn(); - - const suite = vest.create(() => { - vest.test('test', () => false); - }); - - suite().done('non-existent', cb); - - expect(cb).not.toHaveBeenCalled(); - }); - }); - - describe('Async Isolate', () => { - describe('When async isolate is pending', () => { - it('Should not call the callback', () => { - const cb = vi.fn(); - - const suite = vest.create(() => { - vest.test('test', () => false); - - vest.group('group', async () => { - await wait(1000); - }); - }); - - suite().done(cb); - - expect(cb).not.toHaveBeenCalled(); - }); - }); - - describe('When async isolate is completed', () => { - it('Should call the callback', async () => { - const cb = vi.fn(); - - const suite = vest.create(() => { - vest.test('test', () => false); - - vest.group('group', async () => { - await wait(1000); - }); - }); - - suite().done(cb); - await wait(1000); - expect(cb).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/packages/vest/src/suiteResult/done/shouldSkipDoneRegistration.ts b/packages/vest/src/suiteResult/done/shouldSkipDoneRegistration.ts deleted file mode 100644 index f224dfad1..000000000 --- a/packages/vest/src/suiteResult/done/shouldSkipDoneRegistration.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * DONE is here and not in its own module to prevent circular dependency issues. - */ - -import { Maybe, isFunction, numberEquals } from 'vest-utils'; - -import { - SuiteResult, - SuiteRunResult, - TFieldName, - TGroupName, -} from 'SuiteResultTypes'; - -export function shouldSkipDoneRegistration< - F extends TFieldName, - G extends TGroupName, ->( - callback: (res: SuiteResult) => void, - - fieldName: Maybe, - output: SuiteRunResult, -): boolean { - // If we do not have any test runs for the current field - return !!( - !isFunction(callback) || - (fieldName && numberEquals(output.tests[fieldName]?.testCount ?? 0, 0)) - ); -} diff --git a/packages/vest/src/suiteResult/selectors/LazyDraft.ts b/packages/vest/src/suiteResult/selectors/LazyDraft.ts index 8426c77c7..4527a522c 100644 --- a/packages/vest/src/suiteResult/selectors/LazyDraft.ts +++ b/packages/vest/src/suiteResult/selectors/LazyDraft.ts @@ -15,7 +15,6 @@ export function LazyDraft< return new Proxy(emptySummary, { get: (_, prop) => { - // @vx-allow use-use const result = useCreateSuiteResult(); return result[prop as keyof SuiteResult]; diff --git a/packages/vest/src/suiteResult/selectors/__tests__/collectFailureMessages.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/collectFailureMessages.test.ts index 7f0602876..771ebb564 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/collectFailureMessages.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/collectFailureMessages.test.ts @@ -1,4 +1,4 @@ -import { TTestSuite } from 'testUtils/TVestMock'; +import { TTestSuite } from 'TVestMock'; import { describe, it, expect, beforeEach, test } from 'vitest'; import { dummyTest } from '../../../testUtils/testDummy'; @@ -117,7 +117,7 @@ describe('collectFailureMessages', () => { dummyTest.passing('x'); }); }); - suite(); + suite.run(); res = suite.get(); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/getFailure.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/getFailure.test.ts index faeeb9899..8b837a959 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/getFailure.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/getFailure.test.ts @@ -4,22 +4,22 @@ import * as vest from 'vest'; describe('->getFailure (singular form)', () => { describe('getError', () => { - describe('When not passing a field name', () => { - describe('When there are no errors', () => { - it('Should return undefined', () => { + describe('when not passing a field name', () => { + describe('when there are no errors', () => { + it('should return undefined', () => { const suite = vest.create(() => {}); - expect(suite().getErrors()).toEqual({}); + expect(suite.run().getErrors()).toEqual({}); expect(suite.get().getError()).toBeUndefined(); }); }); - describe('When there are errors', () => { - it('Should return the first error object', () => { + describe('when there are errors', () => { + it('should return the first error object', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); vest.test('field_2', 'msg_2', () => false); }); - expect(suite().getError()).toEqual({ + expect(suite.run().getError()).toEqual({ fieldName: 'field_1', message: 'msg_1', groupName: undefined, @@ -28,64 +28,64 @@ describe('->getFailure (singular form)', () => { }); }); - describe('When no tests', () => { - describe('When requesting a fieldName', () => { - it('Should return undefined', () => { + describe('when no tests', () => { + describe('when requesting a fieldName', () => { + it('should return undefined', () => { const suite = vest.create(() => {}); - expect(suite().getErrors()).toEqual({}); + expect(suite.run().getErrors()).toEqual({}); expect(suite.get().getError('field_2')).toBeUndefined(); }); }); }); - describe('When no errors', () => { - it('Should return undefined', () => { + describe('when no errors', () => { + it('should return undefined', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => {}); }); - expect(suite().getError('field_1')).toBeUndefined(); + expect(suite.run().getError('field_1')).toBeUndefined(); expect(suite.get().getError('field_1')).toBeUndefined(); }); }); - describe('When there are errors', () => { - it('Should return the first error', () => { + describe('when there are errors', () => { + it('should return the first error', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); vest.test('field_2', 'msg_2', () => false); }); - expect(suite().getError('field_1')).toBe('msg_1'); + expect(suite.run().getError('field_1')).toBe('msg_1'); }); }); - describe('When there are errors', () => { - describe('When there is only one error', () => { - it('Should return the error', () => { + describe('when there are errors', () => { + describe('when there is only one error', () => { + it('should return the error', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); }); - expect(suite().getError('field_1')).toBe('msg_1'); + expect(suite.run().getError('field_1')).toBe('msg_1'); expect(suite.get().getError('field_1')).toBe('msg_1'); }); }); - describe('When there are multiple errors', () => { - it('Should return the first error', () => { + describe('when there are multiple errors', () => { + it('should return the first error', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); vest.test('field_1', 'msg_2', () => false); }); - expect(suite().getError('field_1')).toBe('msg_1'); + expect(suite.run().getError('field_1')).toBe('msg_1'); expect(suite.get().getError('field_1')).toBe('msg_1'); }); }); - describe('When checking the incorrect field', () => { - it('Should return undefined', () => { + describe('when checking an incorrect field', () => { + it('should return undefined', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); }); - expect(suite().getError('field_2')).toBeUndefined(); + expect(suite.run().getError('field_2')).toBeUndefined(); expect(suite.get().getError('field_2')).toBeUndefined(); }); }); @@ -93,17 +93,17 @@ describe('->getFailure (singular form)', () => { }); describe('getWarning', () => { - describe('When not passing a field name', () => { - describe('When there are no warnings', () => { - it('Should return undefined', () => { + describe('when not passing a field name', () => { + describe('when there are no warnings', () => { + it('should return undefined', () => { const suite = vest.create(() => {}); - expect(suite().getWarnings()).toEqual({}); + expect(suite.run().getWarnings()).toEqual({}); expect(suite.get().getWarning()).toBeUndefined(); }); }); - describe('When there are warnings', () => { - it('Should return the first warning object', () => { + describe('when there are warnings', () => { + it('should return the first warning object', () => { const suite = vest.create(() => { vest.test('t1', 't1 message', () => { vest.warn(); @@ -116,7 +116,7 @@ describe('->getFailure (singular form)', () => { }); }); - expect(suite().getWarning()).toEqual({ + expect(suite.run().getWarning()).toEqual({ fieldName: 't1', message: 't1 message', groupName: undefined, @@ -125,42 +125,42 @@ describe('->getFailure (singular form)', () => { }); }); - describe('When no tests', () => { - describe('When requesting a fieldName', () => { - it('Should return undefined', () => { + describe('when no tests', () => { + describe('when requesting a fieldName', () => { + it('should return undefined', () => { const suite = vest.create(() => {}); - expect(suite().getWarnings()).toEqual({}); + expect(suite.run().getWarnings()).toEqual({}); expect(suite.get().getWarning('field_2')).toBeUndefined(); }); }); }); - describe('When no warnings', () => { - it('Should return undefined', () => { + describe('when there are no warnings', () => { + it('should return undefined', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => {}); }); - expect(suite().getWarning('field_1')).toBeUndefined(); + expect(suite.run().getWarning('field_1')).toBeUndefined(); expect(suite.get().getWarning('field_1')).toBeUndefined(); }); }); - describe('When there are warnings', () => { - describe('When there is only one warning', () => { - it('Should return the warning', () => { + describe('when there are warnings', () => { + describe('when there is only one warning', () => { + it('should return the warning', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => { vest.warn(); return false; }); }); - expect(suite().getWarning('field_1')).toBe('msg_1'); + expect(suite.run().getWarning('field_1')).toBe('msg_1'); expect(suite.get().getWarning('field_1')).toBe('msg_1'); }); }); - describe('When there are multiple warnings', () => { - it('Should return the first warning', () => { + describe('when there are multiple warnings', () => { + it('should return the first warning', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => { vest.warn(); @@ -171,20 +171,20 @@ describe('->getFailure (singular form)', () => { return false; }); }); - expect(suite().getWarning('field_1')).toBe('msg_1'); + expect(suite.run().getWarning('field_1')).toBe('msg_1'); expect(suite.get().getWarning('field_1')).toBe('msg_1'); }); }); - describe('When checking the incorrect field', () => { - it('Should return undefined', () => { + describe('when checking an incorrect field', () => { + it('should return undefined', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => { vest.warn(); return false; }); }); - expect(suite().getWarning('field_2')).toBeUndefined(); + expect(suite.run().getWarning('field_2')).toBeUndefined(); expect(suite.get().getWarning('field_2')).toBeUndefined(); }); }); @@ -192,41 +192,41 @@ describe('->getFailure (singular form)', () => { }); describe('getMessage', () => { - describe('When the field has an error', () => { - it('Should return the error message', () => { + describe('when the field has an error', () => { + it('should return the error message', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); }); - expect(suite().getMessage('field_1')).toBe('msg_1'); + expect(suite.run().getMessage('field_1')).toBe('msg_1'); expect(suite.get().getMessage('field_1')).toBe('msg_1'); }); }); - describe('When the field has a warning', () => { - it('Should return the warning message', () => { + describe('when the field has a warning', () => { + it('should return the warning message', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => { vest.warn(); return false; }); }); - expect(suite().getMessage('field_1')).toBe('msg_1'); + expect(suite.run().getMessage('field_1')).toBe('msg_1'); expect(suite.get().getMessage('field_1')).toBe('msg_1'); }); }); - describe('When the field has no errors or warnings', () => { - it('Should return undefined', () => { + describe('when the field has no errors or warnings', () => { + it('should return undefined', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => {}); }); - expect(suite().getMessage('field_1')).toBeUndefined(); + expect(suite.run().getMessage('field_1')).toBeUndefined(); expect(suite.get().getMessage('field_1')).toBeUndefined(); }); }); - describe('When the field has both an error and a warning', () => { - it('Should return the error message', () => { + describe('when the field has both an error and a warning', () => { + it('should return the error message', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); vest.test('field_1', 'msg_2', () => { @@ -234,24 +234,24 @@ describe('->getFailure (singular form)', () => { return false; }); }); - expect(suite().getMessage('field_1')).toBe('msg_1'); + expect(suite.run().getMessage('field_1')).toBe('msg_1'); expect(suite.get().getMessage('field_1')).toBe('msg_1'); }); }); - describe('When the field has multiple errors', () => { - it('Should return the first error message', () => { + describe('when the field has multiple errors', () => { + it('should return the first error message', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => false); vest.test('field_1', 'msg_2', () => false); }); - expect(suite().getMessage('field_1')).toBe('msg_1'); + expect(suite.run().getMessage('field_1')).toBe('msg_1'); expect(suite.get().getMessage('field_1')).toBe('msg_1'); }); }); - describe('When the field has multiple warnings', () => { - it('Should return the first warning message', () => { + describe('when the field has multiple warnings', () => { + it('should return the first warning message', () => { const suite = vest.create(() => { vest.test('field_1', 'msg_1', () => { vest.warn(); @@ -262,15 +262,15 @@ describe('->getFailure (singular form)', () => { return false; }); }); - expect(suite().getMessage('field_1')).toBe('msg_1'); + expect(suite.run().getMessage('field_1')).toBe('msg_1'); expect(suite.get().getMessage('field_1')).toBe('msg_1'); }); }); - describe('When the field does not exist', () => { - it('Should return undefined', () => { + describe('when the field does not exist', () => { + it('should return undefined', () => { const suite = vest.create(() => {}); - expect(suite().getMessage('field_1')).toBeUndefined(); + expect(suite.run().getMessage('field_1')).toBeUndefined(); expect(suite.get().getMessage('field_1')).toBeUndefined(); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/getFailures.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/getFailures.test.ts index b6e75039d..5887b64fe 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/getFailures.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/getFailures.test.ts @@ -1,47 +1,47 @@ -import { Modes } from 'Modes'; import { describe, it, expect } from 'vitest'; import { dummyTest } from '../../../testUtils/testDummy'; +import { Modes } from 'Modes'; import * as vest from 'vest'; describe('->getFailures', () => { describe(`getErrors`, () => { describe('When no tests', () => { describe('When no parameters passed', () => { - it('Should return an empty object', () => { + it('should return an empty object', () => { const suite = vest.create(() => {}); - expect(suite().getErrors()).toEqual({}); + expect(suite.run().getErrors()).toEqual({}); expect(suite.get().getErrors()).toEqual({}); }); }); describe('When requesting a fieldName', () => { - it('Should return an empty array', () => { + it('should return an empty array', () => { const suite = vest.create(() => {}); - expect(suite().getErrors()).toEqual({}); + expect(suite.run().getErrors()).toEqual({}); expect(suite.get().getErrors('field_2')).toEqual([]); }); }); }); describe('When no errors', () => { describe('When no parameters passed', () => { - it('Should return an object no errors', () => { + it('should return an empty object (no errors)', () => { const suite = vest.create(() => { dummyTest.passing('f1'); dummyTest.passing('f2'); }); - expect(suite().getErrors()).toEqual({}); + expect(suite.run().getErrors()).toEqual({}); expect(suite.get().getErrors()).toEqual({}); }); }); describe('When requesting a fieldName', () => { - it('Should return an empty array', () => { + it('should return an empty array', () => { const suite = vest.create(() => { dummyTest.passing('field_1'); dummyTest.passing(); }); - expect(suite().getErrors('field_1')).toEqual([]); + expect(suite.run().getErrors('field_1')).toEqual([]); expect(suite.get().getErrors('field_1')).toEqual([]); }); }); @@ -49,7 +49,7 @@ describe('->getFailures', () => { describe('When there are errors', () => { describe('When no parameters passed', () => { - it('Should return an object with an array per field', () => { + it('should return an object mapping each field to its error messages', () => { const suite = vest.create(() => { vest.mode(Modes.ALL); dummyTest.failing('field_1', 'msg_1'); @@ -58,7 +58,7 @@ describe('->getFailures', () => { dummyTest.passing('field_1', 'msg_4'); dummyTest.failingWarning('field_1', 'msg_5'); }); - expect(suite().getErrors()).toEqual({ + expect(suite.run().getErrors()).toEqual({ field_1: ['msg_1'], field_2: ['msg_2', 'msg_3'], }); @@ -69,7 +69,7 @@ describe('->getFailures', () => { }); }); describe('When requesting a fieldName', () => { - it('Should return an empty array', () => { + it('should return an array with the field error messages', () => { const suite = vest.create(() => { dummyTest.failing('field_1', 'msg_1'); dummyTest.failing('field_2', 'msg_2'); @@ -77,7 +77,7 @@ describe('->getFailures', () => { dummyTest.passing('field_1', 'msg_4'); dummyTest.failingWarning('field_1', 'msg_5'); }); - expect(suite().getErrors('field_1')).toEqual(['msg_1']); + expect(suite.run().getErrors('field_1')).toEqual(['msg_1']); expect(suite.get().getErrors('field_1')).toEqual(['msg_1']); }); }); @@ -87,38 +87,38 @@ describe('->getFailures', () => { describe(`getWarnings`, () => { describe('When no testObjects', () => { describe('When no parameters passed', () => { - it('Should return an empty object', () => { + it('should return an empty object', () => { const suite = vest.create(() => {}); - expect(suite().getWarnings()).toEqual({}); + expect(suite.run().getWarnings()).toEqual({}); expect(suite.get().getWarnings()).toEqual({}); }); }); describe('When requesting a fieldName', () => { - it('Should return an empty array', () => { + it('should return an empty array', () => { const suite = vest.create(() => {}); - expect(suite().getWarnings('field_1')).toEqual([]); + expect(suite.run().getWarnings('field_1')).toEqual([]); expect(suite.get().getWarnings('field_1')).toEqual([]); }); }); }); describe('When no warnings', () => { describe('When no parameters passed', () => { - it('Should return an empty object', () => { + it('should return an empty object', () => { const suite = vest.create(() => { dummyTest.passing('x'); dummyTest.passing('y'); }); - expect(suite().getWarnings()).toEqual({}); + expect(suite.run().getWarnings()).toEqual({}); expect(suite.get().getWarnings()).toEqual({}); }); }); describe('When requesting a fieldName', () => { - it('Should return an empty array', () => { + it('should return an empty array', () => { const suite = vest.create(() => { dummyTest.passing('field_1'); dummyTest.passing(); }); - expect(suite().getWarnings('field_1')).toEqual([]); + expect(suite.run().getWarnings('field_1')).toEqual([]); expect(suite.get().getWarnings('field_1')).toEqual([]); }); }); @@ -126,7 +126,7 @@ describe('->getFailures', () => { describe('When there are warnings', () => { describe('When no parameters passed', () => { - it('Should return an object with an array per field', () => { + it('should return an object mapping each field to its warning messages', () => { const suite = vest.create(() => { dummyTest.failingWarning('field_1', 'msg_1'); dummyTest.failingWarning('field_2', 'msg_2'); @@ -134,7 +134,7 @@ describe('->getFailures', () => { dummyTest.passingWarning('field_1', 'msg_4'); dummyTest.failing('field_1', 'msg_5'); }); - expect(suite().getWarnings()).toEqual({ + expect(suite.run().getWarnings()).toEqual({ field_1: ['msg_1'], field_2: ['msg_2', 'msg_3'], }); @@ -145,7 +145,7 @@ describe('->getFailures', () => { }); }); describe('When requesting a fieldName', () => { - it('Should return an empty array', () => { + it('should return an array with the field warning messages', () => { const suite = vest.create(() => { dummyTest.failingWarning('field_1', 'msg_1'); dummyTest.failingWarning('field_2', 'msg_2'); @@ -153,7 +153,7 @@ describe('->getFailures', () => { dummyTest.passingWarning('field_1', 'msg_4'); dummyTest.failing('field_1', 'msg_5'); }); - expect(suite().getWarnings('field_1')).toEqual(['msg_1']); + expect(suite.run().getWarnings('field_1')).toEqual(['msg_1']); expect(suite.get().getWarnings('field_1')).toEqual(['msg_1']); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/getFailuresByGroup.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/getFailuresByGroup.test.ts index 7ef59c4eb..239a9c30f 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/getFailuresByGroup.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/getFailuresByGroup.test.ts @@ -1,23 +1,20 @@ -import { Modes } from 'Modes'; -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach } from 'vitest'; import { dummyTest } from '../../../testUtils/testDummy'; +import { Modes } from 'Modes'; +import { TTestSuite } from 'TVestMock'; import { create, group } from 'vest'; import * as vest from 'vest'; -const modes = ['SuiteRunResult', 'SuiteResult']; - -describe.each(modes)('produce method: %s', mode => { +describe('SuiteResult', () => { let suite: TTestSuite; function getRes(...args: any[]) { - const res = suite(...args); - return mode === 'SuiteRunResult' ? res : suite.get(); + return suite.run(...args); } - describe(`${mode}->getErrorsByGroup`, () => { + describe(`SuiteResult->getErrorsByGroup`, () => { describe('When no tests', () => { beforeEach(() => { suite = create(() => {}); @@ -116,7 +113,7 @@ describe.each(modes)('produce method: %s', mode => { }); }); }); - describe(`${mode}->getWarningsByGroup`, () => { + describe(`SuiteResult->getWarningsByGroup`, () => { describe('When no tests', () => { beforeEach(() => { suite = create(() => {}); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/hasFailures.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/hasFailures.test.ts index b485d3b29..892677339 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/hasFailures.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/hasFailures.test.ts @@ -12,7 +12,7 @@ describe('produce method: hasFailures', () => { describe('When no test objects', () => { it('should return false', () => { const suite = vest.create(() => {}); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(fieldName)).toBe(false); expect(suite.get().hasErrors(fieldName)).toBe(false); expect(res.hasErrors()).toBe(false); @@ -27,7 +27,7 @@ describe('produce method: hasFailures', () => { dummyTest.passing('field_1'); dummyTest.passing('field_2'); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(fieldName)).toBe(false); expect(suite.get().hasErrors(fieldName)).toBe(false); expect(res.hasErrors()).toBe(false); @@ -41,7 +41,7 @@ describe('produce method: hasFailures', () => { dummyTest.failingWarning(); dummyTest.passing(fieldName); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(fieldName)).toBe(false); expect(suite.get().hasErrors(fieldName)).toBe(false); expect(res.hasErrors()).toBe(false); @@ -50,12 +50,12 @@ describe('produce method: hasFailures', () => { }); describe('When field has an error', () => { - it('Should return true when some of the tests of the field are erroring', () => { + it('should return true when the field has at least one failing test', () => { const suite = vest.create(() => { dummyTest.passing(); dummyTest.failing(fieldName); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(fieldName)).toBe(true); expect(suite.get().hasErrors(fieldName)).toBe(true); expect(res.hasErrors()).toBe(true); @@ -66,7 +66,7 @@ describe('produce method: hasFailures', () => { const suite = vest.create(() => { dummyTest.failing(fieldName); }); - const res = suite(); + const res = suite.run(); expect(res.hasErrors(fieldName)).toBe(true); expect(suite.get().hasErrors(fieldName)).toBe(true); expect(res.hasErrors()).toBe(true); @@ -79,7 +79,7 @@ describe('produce method: hasFailures', () => { describe('When no test objects', () => { it('should return false', () => { const suite = vest.create(() => {}); - const res = suite(); + const res = suite.run(); expect(res.hasWarnings(fieldName)).toBe(false); expect(suite.get().hasWarnings(fieldName)).toBe(false); expect(res.hasWarnings()).toBe(false); @@ -93,7 +93,7 @@ describe('produce method: hasFailures', () => { dummyTest.passingWarning(fieldName); dummyTest.passing('field_1'); }); - const res = suite(); + const res = suite.run(); expect(res.hasWarnings(fieldName)).toBe(false); expect(suite.get().hasWarnings(fieldName)).toBe(false); expect(res.hasWarnings()).toBe(false); @@ -106,7 +106,7 @@ describe('produce method: hasFailures', () => { const suite = vest.create(() => { dummyTest.failing(fieldName); }); - const res = suite(); + const res = suite.run(); expect(res.hasWarnings(fieldName)).toBe(false); expect(suite.get().hasWarnings(fieldName)).toBe(false); expect(res.hasWarnings()).toBe(false); @@ -115,23 +115,23 @@ describe('produce method: hasFailures', () => { }); describe('When field is warning', () => { - it('Should return true when some of the tests of the field are warning', () => { + it('should return true when the field has at least one warning', () => { const suite = vest.create(() => { dummyTest.passingWarning(); dummyTest.failingWarning(fieldName); }); - const res = suite(); + const res = suite.run(); expect(res.hasWarnings(fieldName)).toBe(true); expect(suite.get().hasWarnings(fieldName)).toBe(true); expect(res.hasWarnings()).toBe(true); expect(suite.get().hasWarnings()).toBe(true); }); - it('should return false', () => { + it('should return true', () => { const suite = vest.create(() => { dummyTest.failingWarning(fieldName); }); - const res = suite(); + const res = suite.run(); expect(res.hasWarnings(fieldName)).toBe(true); expect(suite.get().hasWarnings(fieldName)).toBe(true); expect(res.hasWarnings()).toBe(true); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/hasFailuresByGroup.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/hasFailuresByGroup.test.ts index ffb1e1e60..687c77684 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/hasFailuresByGroup.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/hasFailuresByGroup.test.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { TTestSuite } from 'testUtils/TVestMock'; +import { TTestSuite } from 'TVestMock'; import { describe, it, expect } from 'vitest'; import { dummyTest } from '../../../testUtils/testDummy'; @@ -12,87 +12,87 @@ const groupName = faker.lorem.word(); let suite: TTestSuite; describe('hasErrorsByGroup', () => { describe('When no tests', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => undefined); - expect(suite().hasErrorsByGroup(groupName)).toBe(false); + expect(suite.run().hasErrorsByGroup(groupName)).toBe(false); }); }); describe('When no failing tests', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { dummyTest.passing(); }); - expect(suite().hasErrorsByGroup(groupName)).toBe(false); + expect(suite.run().hasErrorsByGroup(groupName)).toBe(false); }); }); describe('When there are failing tests without a group', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { dummyTest.failing(); }); - expect(suite().hasErrorsByGroup(groupName)).toBe(false); + expect(suite.run().hasErrorsByGroup(groupName)).toBe(false); }); }); describe('When failing tests are from a different group', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group('another_group', () => { dummyTest.failing('field_1', 'msg'); }); }); - expect(suite().hasErrorsByGroup(groupName)).toBe(false); + expect(suite.run().hasErrorsByGroup(groupName)).toBe(false); }); }); describe('When failing tests are from the same group but warning', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failingWarning('field_1', 'msg'); }); }); - expect(suite().hasErrorsByGroup(groupName)).toBe(false); + expect(suite.run().hasErrorsByGroup(groupName)).toBe(false); }); }); describe('When failing tests are from the same group', () => { - it('Should return true', () => { + it('should return true', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failing('field_1', 'msg'); }); }); - expect(suite().hasErrorsByGroup(groupName)).toBe(true); + expect(suite.run().hasErrorsByGroup(groupName)).toBe(true); }); }); describe('When fieldName is provided', () => { describe('When not matching', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failing('field_1', 'msg'); }); }); - expect(suite().hasErrorsByGroup(groupName, 'non_matching_field')).toBe( - false, - ); + expect( + suite.run().hasErrorsByGroup(groupName, 'non_matching_field'), + ).toBe(false); }); }); describe('When matching', () => { - it('Should return true', () => { + it('should return true', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failing(fieldName, 'msg'); }); }); - expect(suite().hasErrorsByGroup(groupName, fieldName)).toBe(true); + expect(suite.run().hasErrorsByGroup(groupName, fieldName)).toBe(true); }); }); }); @@ -100,87 +100,87 @@ describe('hasErrorsByGroup', () => { describe('hasWarningsByGroup', () => { describe('When no tests', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => undefined); - expect(suite().hasWarningsByGroup(groupName)).toBe(false); + expect(suite.run().hasWarningsByGroup(groupName)).toBe(false); }); }); describe('When no failing tests', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.passingWarning(fieldName, 'msg'); }); }); - expect(suite().hasWarningsByGroup(groupName)).toBe(false); + expect(suite.run().hasWarningsByGroup(groupName)).toBe(false); }); }); describe('When there are failing tests without a group', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { dummyTest.failingWarning(); }); - expect(suite().hasWarningsByGroup(groupName)).toBe(false); + expect(suite.run().hasWarningsByGroup(groupName)).toBe(false); }); }); describe('When failing tests are from a different group', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group('another_group', () => { dummyTest.failingWarning('field_1', 'msg'); }); }); - expect(suite().hasWarningsByGroup(groupName)).toBe(false); + expect(suite.run().hasWarningsByGroup(groupName)).toBe(false); }); }); describe('When failing tests are from the same group but erroring', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failing('field_1', 'msg'); }); }); - expect(suite().hasWarningsByGroup(groupName)).toBe(false); + expect(suite.run().hasWarningsByGroup(groupName)).toBe(false); }); }); describe('When failing tests are from the same group', () => { - it('Should return true', () => { + it('should return true', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failingWarning(fieldName, 'msg'); }); }); - expect(suite().hasWarningsByGroup(groupName)).toBe(true); + expect(suite.run().hasWarningsByGroup(groupName)).toBe(true); }); }); describe('When fieldName is provided', () => { describe('When not matching', () => { - it('Should return false', () => { + it('should return false', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failingWarning(fieldName, 'msg'); }); }); expect( - suite().hasWarningsByGroup(groupName, 'non_matching_field'), + suite.run().hasWarningsByGroup(groupName, 'non_matching_field'), ).toBe(false); }); }); describe('When matching', () => { - it('Should return true', () => { + it('should return true', () => { suite = vest.create(() => { vest.group(groupName, () => { dummyTest.failingWarning(fieldName, 'msg'); }); }); - expect(suite().hasWarningsByGroup(groupName, fieldName)).toBe(true); + expect(suite.run().hasWarningsByGroup(groupName, fieldName)).toBe(true); }); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/isPending.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/isPending.test.ts index 159993e83..a7d577d0b 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/isPending.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/isPending.test.ts @@ -9,7 +9,7 @@ describe('isPending()', () => { const suite = vest.create(() => { vest.test('f1', () => {}); }); - suite(); + suite.run(); expect(suite.isPending()).toBe(false); expect(suite.isPending('f1')).toBe(false); }); @@ -19,7 +19,7 @@ describe('isPending()', () => { vest.test('f1', () => {}); vest.test('f2', async () => {}); }); - suite(); + suite.run(); expect(suite.isPending()).toBe(true); expect(suite.isPending('f1')).toBe(false); }); @@ -29,7 +29,7 @@ describe('isPending()', () => { vest.test('f1', () => {}); vest.test('f2', async () => {}); }); - suite(); + suite.run(); expect(suite.isPending()).toBe(true); expect(suite.isPending('f2')).toBe(true); }); @@ -41,7 +41,7 @@ describe('isPending()', () => { vest.test('f1', () => {}); vest.test('f2', async () => {}); }); - suite(); + suite.run(); await wait(0); expect(suite.isPending()).toBe(false); expect(suite.isPending('f2')).toBe(false); @@ -55,7 +55,7 @@ describe('isPending()', () => { vest.test('f2', async () => {}); vest.test('f3', async () => {}); }); - suite(); + suite.run(); await wait(0); expect(suite.isPending()).toBe(false); expect(suite.isPending('f2')).toBe(false); @@ -70,7 +70,7 @@ describe('isPending()', () => { }); vest.test('f3', async () => {}); }); - suite(); + suite.run(); await wait(0); expect(suite.isPending()).toBe(true); expect(suite.isPending('f2')).toBe(true); @@ -85,7 +85,7 @@ describe('isPending()', () => { vest.test('f1', async () => {}); vest.test('f1', async () => {}); }); - suite(); + suite.run(); expect(suite.isPending()).toBe(true); expect(suite.isPending('f1')).toBe(true); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/isTested.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/isTested.test.ts index da7b88ca4..6094121fb 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/isTested.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/isTested.test.ts @@ -8,7 +8,7 @@ describe('isTested', () => { describe('When suite has no tests', () => { it('Should return false', () => { const suite = vest.create(() => {}); - suite(); + suite.run(); // @ts-ignore - invalid input expect(suite.isTested()).toBe(false); }); @@ -19,7 +19,7 @@ describe('isTested', () => { const suite = vest.create(() => { vest.test('f1', () => {}); }); - suite(); + suite.run(); // @ts-ignore - invalid input expect(suite.isTested()).toBe(false); }); @@ -29,7 +29,7 @@ describe('isTested', () => { describe('When suite has no tests', () => { it('Should return false', () => { const suite = vest.create(() => {}); - suite(); + suite.run(); expect(suite.isTested('f1')).toBe(false); }); }); @@ -40,7 +40,7 @@ describe('isTested', () => { const suite = vest.create(() => { vest.test('f1', () => {}); }); - suite(); + suite.run(); expect(suite.isTested('f2')).toBe(false); }); }); @@ -50,7 +50,7 @@ describe('isTested', () => { const suite = vest.create(() => { vest.test('f1', () => {}); }); - suite(); + suite.run(); expect(suite.isTested('f1')).toBe(true); }); }); @@ -63,7 +63,7 @@ describe('isTested', () => { await wait(100); }); }); - suite(); + suite.run(); expect(suite.isTested('f1')).toBe(true); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/isValid.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/isValid.test.ts index b3d1408d2..61acc4807 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/isValid.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/isValid.test.ts @@ -1,14 +1,12 @@ -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach } from 'vitest'; import wait from 'wait'; -import { TestPromise } from '../../../testUtils/testPromise'; - +import { TTestSuite } from 'TVestMock'; import { test, optional, create, skipWhen, warn, skip, only } from 'vest'; describe('isValid', () => { describe('Before any test ran', () => { - it('Should return false', () => { + it('should return false', () => { const suite = create(() => { test('field_1', () => false); }); @@ -31,19 +29,19 @@ describe('isValid', () => { }); }); - it('Should return false when an optional test has errors', () => { - expect(suite('field_2').isValid()).toBe(false); + it('should return false when an optional test has errors', () => { + expect(suite.run('field_2').isValid()).toBe(false); }); - it('Should return false when a required test has errors', () => { - expect(suite('field_1').isValid()).toBe(false); + it('should return false when a required test has errors', () => { + expect(suite.run('field_1').isValid()).toBe(false); }); - it('Should return false when the queried field is not optional and has errors', () => { - expect(suite('field_2').isValid('field_2')).toBe(false); + it('should return false when the queried field is not optional and has errors', () => { + expect(suite.run('field_2').isValid('field_2')).toBe(false); }); - it('Should return true when the queried field is optional and has errors', () => { - expect(suite('field_1').isValid('field_1')).toBe(true); + it('should return true when the queried field is optional and has errors', () => { + expect(suite.run('field_1').isValid('field_1')).toBe(true); }); }); @@ -58,9 +56,9 @@ describe('isValid', () => { }); }); }); - it('Should return true when a required test has warnings', () => { - expect(suite().isValid()).toBe(true); - expect(suite().isValid('field_1')).toBe(true); + it('should return true when a required test has warnings', () => { + expect(suite.run().isValid()).toBe(true); + expect(suite.run().isValid('field_1')).toBe(true); }); describe('When some of the tests for the required field are warnings', () => { @@ -73,8 +71,8 @@ describe('isValid', () => { test('field_1', () => true); }); }); - it('Should return true when a required test has warnings', () => { - expect(suite().isValid()).toBe(true); + it('should return true when a required test has warnings', () => { + expect(suite.run().isValid()).toBe(true); }); }); @@ -91,8 +89,8 @@ describe('isValid', () => { }); }); }); - it('Should return false even when the skipped field is warning', () => { - expect(suite().isValid()).toBe(false); + it('should return false even when the skipped field is warning', () => { + expect(suite.run().isValid()).toBe(false); }); }); }); @@ -114,11 +112,11 @@ describe('isValid', () => { }); }); }); - it('Should return false', () => { - expect(suite('field_1').isValid()).toBe(false); + it('should return false', () => { + expect(suite.run('field_1').isValid()).toBe(false); }); - it('Should return false', () => { - expect(suite(['field_2', 'field_3']).isValid()).toBe(false); + it('should return false', () => { + expect(suite.run(['field_2', 'field_3']).isValid()).toBe(false); }); }); @@ -135,15 +133,15 @@ describe('isValid', () => { }); describe('When a test is pending', () => { - it('Should return false', () => { - suite(); + it('should return false', () => { + suite.run(); expect(suite.get().isValid()).toBe(false); expect(suite.get().isValid('field_1')).toBe(false); }); }); describe('When the test is passing', () => { it('Should return true', async () => { - suite(); + suite.run(); await wait(300); expect(suite.get().isValid()).toBe(true); expect(suite.get().isValid('field_1')).toBe(true); @@ -168,14 +166,14 @@ describe('isValid', () => { }); it('Should return false as long as the test is pending', async () => { - suite(); + suite.run(); expect(suite.get().isValid()).toBe(false); await wait(300); expect(suite.get().isValid()).toBe(true); }); it('Should return false as long as the test is pending when querying a specific field', async () => { - suite(); + suite.run(); expect(suite.get().isValid('field_1')).toBe(false); await wait(300); expect(suite.get().isValid('field_1')).toBe(true); @@ -200,7 +198,7 @@ describe('isValid', () => { describe('When test is pending', () => { it('Should return `false` for a required field', () => { - const result = suite(); + const result = suite.run(); expect(result.isValid()).toBe(false); expect(result.isValid('field_1')).toBe(false); @@ -208,26 +206,21 @@ describe('isValid', () => { }); describe('When async test is passing', () => { - it('Should return `true`', () => { - return TestPromise(done => { - suite().done(result => { - expect(result.isValid()).toBe(true); - expect(result.isValid('field_1')).toBe(true); - expect(result.isValid('field_2')).toBe(true); - done(); - }); - }); + it('should return `true`', async () => { + { + await suite.run(); + expect(suite.isValid()).toBe(true); + expect(suite.isValid('field_1')).toBe(true); + expect(suite.isValid('field_2')).toBe(true); + } }); }); describe('When test is lagging', () => { - it('Should return `false`', () => { - return TestPromise(done => { - suite(); - const result = suite('field_2').done(done); + it('should return `false`', async () => { + const result = await suite.run('field_2'); - expect(result.isValid()).toBe(false); - }); + expect(result.isValid()).toBe(false); }); }); }); @@ -251,16 +244,16 @@ describe('isValid', () => { }); }); }); - it('Should return true', () => { - expect(suite().isValid()).toBe(true); - expect(suite().isValid('field_1')).toBe(true); - expect(suite().isValid('field_2')).toBe(true); - expect(suite().isValid('field_3')).toBe(true); + it('should return true', () => { + expect(suite.run().isValid()).toBe(true); + expect(suite.run().isValid('field_1')).toBe(true); + expect(suite.run().isValid('field_2')).toBe(true); + expect(suite.run().isValid('field_3')).toBe(true); }); }); describe('When a required field has some passing tests', () => { - it('Should return false', () => { + it('should return false', () => { expect( create(() => { test('field_1', () => true); @@ -269,30 +262,36 @@ describe('isValid', () => { return true; }); }); - })().isValid(), + }) + .run() + .isValid(), ).toBe(false); }); }); describe('When field name is specified', () => { - it('Should return false when field did not run yet', () => { + it('should return false when field did not run yet', () => { expect( create(() => { skip('field_1'); test('field_1', () => true); - })().isValid('field_1'), + }) + .run() + .isValid('field_1'), ).toBe(false); }); - it('Should return false when testing for a field that does not exist', () => { + it('should return false when testing for a field that does not exist', () => { expect( create(() => { test('field_1', () => {}); - })().isValid('field 2'), + }) + .run() + .isValid('field 2'), ).toBe(false); }); - it("Should return false when some of the field's tests ran", () => { + it("should return false when only some of the field's tests ran", () => { expect( create(() => { test('field_1', () => { @@ -303,59 +302,71 @@ describe('isValid', () => { return true; }); }); - })().isValid('field_1'), + }) + .run() + .isValid('field_1'), ).toBe(false); }); - it('Should return false when the field has errors', () => { + it('should return false when the field has errors', () => { expect( create(() => { test('field_1', () => { return false; }); - })().isValid('field_1'), + }) + .run() + .isValid('field_1'), ).toBe(false); }); - it('Should return true when all the tests are passing', () => { + it('should return true when all the tests are passing', () => { expect( create(() => { test('field_1', () => { return true; }); - })().isValid('field_1'), + }) + .run() + .isValid('field_1'), ).toBe(true); }); - it('Should return true when the field only has warnings', () => { + it('should return true when the field only has warnings', () => { expect( create(() => { test('field_1', () => { warn(); return false; }); - })().isValid('field_1'), + }) + .run() + .isValid('field_1'), ).toBe(true); }); - it('Should return true if field is optional and did not run', () => { + it('should return true if field is optional and did not run', () => { expect( create(() => { optional('field_1'); skipWhen(true, () => { test('field_1', () => false); }); - })().isValid('field_1'), + }) + .run() + .isValid('field_1'), ).toBe(true); }); }); describe('When querying a non existing field', () => { - it('Should return false', () => { + it('should return false', () => { expect( create(() => { test('field_1', () => true); - })().isValid('field_2'), + }) + .run() + .isValid('field_2'), ).toBe(false); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/isValidByGroup.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/isValidByGroup.test.ts index a988a024e..311e78e63 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/isValidByGroup.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/isValidByGroup.test.ts @@ -1,10 +1,9 @@ -import { Modes } from 'Modes'; -import { TTestSuite } from 'testUtils/TVestMock'; import { describe, it, expect, beforeEach } from 'vitest'; import wait from 'wait'; -import { TestPromise } from '../../../testUtils/testPromise'; +import { TTestSuite } from '../../../testUtils/TVestMock'; +import { Modes } from 'Modes'; import { test, optional, @@ -21,14 +20,14 @@ const GROUP_NAME = 'group_1'; describe('isValidByGroup', () => { describe('Before any test ran', () => { - it('Should return false', () => { + it('should treat an empty group as valid (no tests ran)', () => { const suite = create(() => { group(GROUP_NAME, () => { test('field_1', () => {}); }); }); - expect(suite.get().isValidByGroup(GROUP_NAME)).toBe(false); + expect(suite.get().isValidByGroup(GROUP_NAME)).toBe(true); }); }); @@ -48,27 +47,29 @@ describe('isValidByGroup', () => { }); }); - it('Should return false when an optional test has errors', () => { - expect(suite('field_2').isValidByGroup(GROUP_NAME)).toBe(false); - expect(suite('field_2').isValidByGroup(GROUP_NAME, 'field_1')).toBe( + it('should be invalid when both required and optional fields have errors', () => { + expect(suite.run('field_2').isValidByGroup(GROUP_NAME)).toBe(false); + expect(suite.run('field_2').isValidByGroup(GROUP_NAME, 'field_1')).toBe( false, ); }); - it('Should return false when a required test has errors', () => { - expect(suite('field_1').isValidByGroup(GROUP_NAME)).toBe(false); - expect(suite('field_1').isValidByGroup(GROUP_NAME, 'field_2')).toBe( + it('should be invalid when a required field has errors', () => { + expect(suite.run('field_1').isValidByGroup(GROUP_NAME)).toBe(false); + expect(suite.run('field_1').isValidByGroup(GROUP_NAME, 'field_2')).toBe( false, ); }); - it('Should return false when the queried field is not optional and has errors', () => { - expect(suite('field_2').isValidByGroup(GROUP_NAME, 'field_2')).toBe( + it('should report the field invalid when it has errors', () => { + expect(suite.run('field_2').isValidByGroup(GROUP_NAME, 'field_2')).toBe( false, ); }); - it('Should return true when the queried field is optional and has errors', () => { - expect(suite('field_1').isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); + it('should report an optional field as valid even if it has errors', () => { + expect(suite.run('field_1').isValidByGroup(GROUP_NAME, 'field_1')).toBe( + true, + ); }); }); @@ -85,9 +86,9 @@ describe('isValidByGroup', () => { }); }); }); - it('Should return true when a required test has warnings', () => { - expect(suite().isValidByGroup(GROUP_NAME)).toBe(true); - expect(suite().isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); + it('should remain valid when there are only warnings', () => { + expect(suite.run().isValidByGroup(GROUP_NAME)).toBe(true); + expect(suite.run().isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); }); describe('When some of the tests for the required field are warnings', () => { @@ -100,8 +101,8 @@ describe('isValidByGroup', () => { test('field_1', () => true); }); }); - it('Should return true when a required test has warnings', () => { - expect(suite().isValid()).toBe(true); + it('should be valid when a field has warnings but also passes', () => { + expect(suite.run().isValid()).toBe(true); }); }); @@ -118,8 +119,8 @@ describe('isValidByGroup', () => { }); }); }); - it('Should return false even when the skipped field is warning', () => { - expect(suite().isValid()).toBe(false); + it('should be invalid when a test is skipped (even if it was only a warning)', () => { + expect(suite.run().isValid()).toBe(false); }); }); }); @@ -143,11 +144,11 @@ describe('isValidByGroup', () => { }); }); }); - it('Should return false', () => { - expect(suite('field_1').isValidByGroup(GROUP_NAME)).toBe(false); + it('should be invalid when a required field is skipped', () => { + expect(suite.run('field_1').isValidByGroup(GROUP_NAME)).toBe(false); }); - it('Should return false', () => { - expect(suite(['field_2', 'field_3']).isValidByGroup(GROUP_NAME)).toBe( + it('should be invalid when some required tests did not run', () => { + expect(suite.run(['field_2', 'field_3']).isValidByGroup(GROUP_NAME)).toBe( false, ); }); @@ -169,15 +170,15 @@ describe('isValidByGroup', () => { }); describe('When test is pending', () => { - it('Should return false', () => { - suite(); + it('should be invalid while an async optional test is pending', () => { + suite.run(); expect(suite.get().isValidByGroup(GROUP_NAME)).toBe(false); expect(suite.get().isValidByGroup(GROUP_NAME, 'field_1')).toBe(false); }); }); describe('When test is passing', () => { - it('Should return true', async () => { - suite(); + it('should be valid after the async optional test completes', async () => { + suite.run(); await wait(300); expect(suite.get().isValidByGroup(GROUP_NAME)).toBe(true); expect(suite.get().isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); @@ -203,15 +204,15 @@ describe('isValidByGroup', () => { }); }); - it('Should return false as long as the test is pending', async () => { - suite(); + it('should be invalid while a warning async test is pending, then valid after it finishes', async () => { + suite.run(); expect(suite.get().isValidByGroup(GROUP_NAME)).toBe(false); await wait(300); expect(suite.get().isValidByGroup(GROUP_NAME)).toBe(true); }); - it('Should return false as long as the test is pending when querying a specific field', async () => { - suite(); + it('should report the field invalid while its warning async test is pending, then valid after it finishes', async () => { + suite.run(); expect(suite.get().isValidByGroup(GROUP_NAME, 'field_1')).toBe(false); await wait(300); expect(suite.get().isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); @@ -237,8 +238,8 @@ describe('isValidByGroup', () => { }); describe('When test is pending', () => { - it('Should return `false` for a required field', () => { - const result = suite(); + it('should report a required field invalid while its async test is pending', () => { + const result = suite.run(); expect(result.isValidByGroup(GROUP_NAME)).toBe(false); expect(result.isValidByGroup(GROUP_NAME, 'field_1')).toBe(false); @@ -246,31 +247,26 @@ describe('isValidByGroup', () => { }); describe('When async test is passing', () => { - it('Should return `true`', () => { - return TestPromise(done => { - suite().done(result => { - expect(result.isValidByGroup(GROUP_NAME)).toBe(true); - expect(result.isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); - expect(result.isValidByGroup(GROUP_NAME, 'field_2')).toBe(true); - done(); - }); - }); + it('should be valid after the required async test completes', async () => { + await suite.run(); + const result = suite.get(); + expect(result.isValidByGroup(GROUP_NAME)).toBe(true); + expect(result.isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); + expect(result.isValidByGroup(GROUP_NAME, 'field_2')).toBe(true); }); }); describe('When test is lagging', () => { - it('Should return `false`', () => { - return TestPromise(done => { - suite(); - const result = suite('field_2').done(done); - - expect(result.isValidByGroup(GROUP_NAME)).toBe(false); - }); + it('should be invalid if a previous async test is still running', async () => { + suite.run(); + const result = suite.run('field_2'); + expect(result.isValidByGroup(GROUP_NAME)).toBe(false); + await result; }); }); }); - describe('When a all required fields are passing', () => { + describe('When all required fields are passing', () => { let suite: TTestSuite; beforeEach(() => { @@ -291,16 +287,16 @@ describe('isValidByGroup', () => { }); }); }); - it('Should return true', () => { - expect(suite().isValidByGroup(GROUP_NAME)).toBe(true); - expect(suite().isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); - expect(suite().isValidByGroup(GROUP_NAME, 'field_2')).toBe(true); - expect(suite().isValidByGroup(GROUP_NAME, 'field_3')).toBe(true); + it('should be valid when all required fields pass', () => { + expect(suite.run().isValidByGroup(GROUP_NAME)).toBe(true); + expect(suite.run().isValidByGroup(GROUP_NAME, 'field_1')).toBe(true); + expect(suite.run().isValidByGroup(GROUP_NAME, 'field_2')).toBe(true); + expect(suite.run().isValidByGroup(GROUP_NAME, 'field_3')).toBe(true); }); }); describe('When a required field has some passing tests', () => { - it('Should return false', () => { + it('should be invalid when not all tests for a field ran', () => { expect( create(() => { group(GROUP_NAME, () => { @@ -311,34 +307,40 @@ describe('isValidByGroup', () => { }); }); }); - })().isValidByGroup(GROUP_NAME), + }) + .run() + .isValidByGroup(GROUP_NAME), ).toBe(false); }); }); describe('When field name is specified', () => { - it('Should return false when field did not run yet', () => { + it('should report a skipped field as invalid', () => { expect( create(() => { skip('field_1'); group(GROUP_NAME, () => { test('field_1', () => true); }); - })().isValidByGroup(GROUP_NAME, 'field_1'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), ).toBe(false); }); - it('Should return false when testing for a field that does not exist', () => { + it('should return false for a field that does not exist in the group', () => { expect( create(() => { group(GROUP_NAME, () => { test('field_1', () => {}); }); - })().isValidByGroup(GROUP_NAME, 'field 2'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field 2'), ).toBe(false); }); - it("Should return false when some of the field's tests ran", () => { + it("should be invalid when only some of the field's tests ran", () => { expect( create(() => { group(GROUP_NAME, () => { @@ -351,31 +353,37 @@ describe('isValidByGroup', () => { }); }); }); - })().isValidByGroup(GROUP_NAME, 'field_1'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), ).toBe(false); }); - it('Should return false when the field has errors', () => { + it('should be invalid when the field has failing tests', () => { expect( create(() => { group(GROUP_NAME, () => { test('field_1', () => false); }); - })().isValidByGroup(GROUP_NAME, 'field_1'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), ).toBe(false); }); - it('Should return true when all the tests are passing', () => { + it('should be valid when the field passes all its tests', () => { expect( create(() => { group(GROUP_NAME, () => { test('field_1', () => {}); }); - })().isValidByGroup(GROUP_NAME, 'field_1'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), ).toBe(true); }); - it('Should return true when the field only has warnings', () => { + it('should be valid when the field only has warnings', () => { expect( create(() => { group(GROUP_NAME, () => { @@ -384,11 +392,13 @@ describe('isValidByGroup', () => { return false; }); }); - })().isValidByGroup(GROUP_NAME, 'field_1'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), ).toBe(true); }); - it('Should return true if field is optional and did not run', () => { + it('should be valid when the optional field did not run', () => { expect( create(() => { optional('field_1'); @@ -397,19 +407,23 @@ describe('isValidByGroup', () => { test('field_1', () => false); }); }); - })().isValidByGroup(GROUP_NAME, 'field_1'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), ).toBe(true); }); }); describe('When querying a non existing field', () => { - it('Should return false', () => { + it('should return false for a field that does not exist in the group', () => { expect( create(() => { group(GROUP_NAME, () => { test('field_1', () => true); }); - })().isValidByGroup(GROUP_NAME, 'field_2'), + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_2'), ).toBe(false); }); }); @@ -420,14 +434,16 @@ describe('isValidByGroup', () => { test('field_1', () => true); }); }); - it('Should return false', () => { - expect(suite().isValidByGroup('does-not-exist')).toBe(false); - expect(suite().isValidByGroup('does-not-exist', 'field_1')).toBe(false); + it('should be valid for a non-existent group (no tests)', () => { + expect(suite.run().isValidByGroup('does-not-exist')).toBe(true); + expect(suite.run().isValidByGroup('does-not-exist', 'field_1')).toBe( + true, + ); }); }); describe('When queried field is omitted', () => { - it('Should return true', () => { + it('should be valid when an optional field is omitted by a custom rule', () => { expect( create(() => { optional({ @@ -436,9 +452,169 @@ describe('isValidByGroup', () => { group(GROUP_NAME, () => { test('field_1', () => false); }); - })().isValidByGroup(GROUP_NAME), + }) + .run() + .isValidByGroup(GROUP_NAME), + ).toBe(true); + }); + }); + + describe('When the only field in the group is optional', () => { + it('should be valid when the optional field is blank', () => { + expect( + create((data: any) => { + optional('field_1'); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run({ field_1: '' }) + .isValidByGroup(GROUP_NAME), + ).toBe(true); + }); + + it('should be invalid when the optional field has a value', () => { + expect( + create((data: any) => { + optional('field_1'); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run({ field_1: 'value' }) + .isValidByGroup(GROUP_NAME), + ).toBe(false); + }); + + it('should be valid when an optional field has a null value', () => { + expect( + create((data: any) => { + optional('field_1'); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run({ field_1: null }) + .isValidByGroup(GROUP_NAME, 'field_1'), + ).toBe(true); + }); + + it('should be valid when all optional fields are blank', () => { + expect( + create((data: any) => { + optional(['field_1', 'field_2', 'field_3']); + group(GROUP_NAME, () => { + test('field_1', () => false); + test('field_2', () => false); + test('field_3', () => false); + }); + }) + .run({ field_1: '', field_2: null, field_3: undefined }) + .isValidByGroup(GROUP_NAME), ).toBe(true); }); + + it('should be invalid when only some optional fields are blank', () => { + expect( + create((data: any) => { + optional(['field_1', 'field_2']); + group(GROUP_NAME, () => { + test('field_1', () => false); + test('field_2', () => false); + }); + }) + .run({ field_1: '', field_2: 'value' }) + .isValidByGroup(GROUP_NAME), + ).toBe(false); + }); + + describe('With functional optional API', () => { + it('should be valid when the custom optional rule returns true', () => { + expect( + create(() => { + optional({ field_1: () => true }); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run() + .isValidByGroup(GROUP_NAME), + ).toBe(true); + }); + + it('should be invalid when the custom optional rule returns false', () => { + expect( + create(() => { + optional({ field_1: () => false }); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run() + .isValidByGroup(GROUP_NAME), + ).toBe(false); + }); + + it('should be valid when optional is set to true', () => { + expect( + create(() => { + optional({ field_1: true }); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run() + .isValidByGroup(GROUP_NAME), + ).toBe(true); + }); + + it('should be invalid when optional is set to false', () => { + expect( + create(() => { + optional({ field_1: false }); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run() + .isValidByGroup(GROUP_NAME), + ).toBe(false); + }); + + it('should evaluate each field by its custom optional rule', () => { + const suite = create((shouldOptionalField2: boolean) => { + optional({ + field_1: () => true, + field_2: () => shouldOptionalField2, + field_3: false, + }); + group(GROUP_NAME, () => { + test('field_1', () => false); + test('field_2', () => false); + test('field_3', () => false); + }); + }); + + // field_1 is optional, field_2 is optional, but field_3 is not -> group invalid + expect(suite.run(true).isValidByGroup(GROUP_NAME)).toBe(false); + + // field_1 is optional, but field_2 and field_3 are not -> group invalid + expect(suite.run(false).isValidByGroup(GROUP_NAME)).toBe(false); + }); + + it('should be valid when checking a field made optional by a custom rule', () => { + expect( + create(() => { + optional({ field_1: () => true }); + group(GROUP_NAME, () => { + test('field_1', () => false); + }); + }) + .run() + .isValidByGroup(GROUP_NAME, 'field_1'), + ).toBe(true); + }); + }); }); describe('When querying a field that is in a different group', () => { @@ -451,9 +627,9 @@ describe('isValidByGroup', () => { }); }); - it('Should return false', () => { - expect(suite().isValidByGroup('group_1', 'field_2')).toBe(false); - expect(suite().isValidByGroup('group_2', 'field_1')).toBe(false); + it('should return false when checking for a field that belongs to a different group', () => { + expect(suite.run().isValidByGroup('group_1', 'field_2')).toBe(false); + expect(suite.run().isValidByGroup('group_2', 'field_1')).toBe(false); }); }); @@ -465,8 +641,8 @@ describe('isValidByGroup', () => { }); }); - it('Should return false', () => { - expect(suite().isValidByGroup('group_1', 'field_1')).toBe(false); + it('should return false when checking for a field that is outside the group', () => { + expect(suite.run().isValidByGroup('group_1', 'field_1')).toBe(false); }); }); @@ -479,8 +655,8 @@ describe('isValidByGroup', () => { }); }); - it('Should return the result of what is inside the group', () => { - expect(suite().isValidByGroup('group_1', 'field_1')).toBe(true); + it('should ignore the same field defined outside the group and use the in-group result', () => { + expect(suite.run().isValidByGroup('group_1', 'field_1')).toBe(true); }); }); }); diff --git a/packages/vest/src/suiteResult/selectors/__tests__/summaryFailures.test.ts b/packages/vest/src/suiteResult/selectors/__tests__/summaryFailures.test.ts index b7cc72bcb..ded81a5ae 100644 --- a/packages/vest/src/suiteResult/selectors/__tests__/summaryFailures.test.ts +++ b/packages/vest/src/suiteResult/selectors/__tests__/summaryFailures.test.ts @@ -1,4 +1,4 @@ -import { TTestSuite } from 'testUtils/TVestMock'; +import { TTestSuite } from 'TVestMock'; import { describe, test, expect, beforeEach } from 'vitest'; import * as vest from 'vest'; @@ -18,7 +18,7 @@ describe('summaryFailures', () => { }); }); - suite(); + suite.run(); }); test('Summary has an errors array', () => { expect(suite.get().errors).toBeInstanceOf(Array); @@ -46,7 +46,7 @@ describe('summaryFailures', () => { vest.test('username', 'username is too short', () => false); }); - suite(); + suite.run(); expect(suite.get().errors).toHaveLength(3); expect(suite.get().errors).toEqual([ @@ -78,7 +78,7 @@ describe('summaryFailures', () => { vest.test('confirm', 'passwords do not match', () => false); }); - suite(); + suite.run(); expect(suite.get().errors).toHaveLength(3); expect(suite.get().errors).toEqual([ @@ -116,7 +116,7 @@ describe('summaryFailures', () => { vest.test('email', 'email was not provided', () => false); }); - suite(); + suite.run(); }); test('Summary has a warnings array', () => { @@ -154,7 +154,7 @@ describe('summaryFailures', () => { }); }); - suite(); + suite.run(); expect(suite.get().warnings).toHaveLength(3); expect(suite.get().warnings).toEqual([ @@ -194,7 +194,7 @@ describe('summaryFailures', () => { }); }); - suite(); + suite.run(); expect(suite.get().warnings).toHaveLength(3); expect(suite.get().warnings).toEqual([ diff --git a/packages/vest/src/suiteResult/selectors/hasFailuresByTestObjects.ts b/packages/vest/src/suiteResult/selectors/hasFailuresByTestObjects.ts index d83df1e25..68bd0916b 100644 --- a/packages/vest/src/suiteResult/selectors/hasFailuresByTestObjects.ts +++ b/packages/vest/src/suiteResult/selectors/hasFailuresByTestObjects.ts @@ -1,13 +1,11 @@ -import { isEmpty } from 'vest-utils'; +import { VestRuntime } from 'vestjs-runtime'; import { TIsolateTest } from 'IsolateTest'; import { Severity } from 'Severity'; -import { TFieldName, TGroupName } from 'SuiteResultTypes'; -import { SuiteWalker } from 'SuiteWalker'; -import { TestWalker } from 'TestWalker'; +import { TFieldName } from 'SuiteResultTypes'; +import { isVestIsolate } from 'VestIsolateType'; import { VestTest } from 'VestTest'; import { nonMatchingFieldName } from 'matchingFieldName'; -import { nonMatchingGroupName } from 'matchingGroupName'; import { nonMatchingSeverityProfile } from 'nonMatchingSeverityProfile'; /** @@ -23,29 +21,15 @@ function hasFailuresByTestObjects( severityKey: Severity, fieldName?: TFieldName, ): boolean { - const allFailures = SuiteWalker.usePreAggs().failures; + const root = VestRuntime.useAvailableRoot(); - if (isEmpty(allFailures[severityKey])) { + if (!isVestIsolate(root)) { return false; } - if (fieldName) { - return !isEmpty(allFailures[severityKey][fieldName]); - } - - return true; -} - -export function hasGroupFailuresByTestObjects( - severityKey: Severity, - groupName: TGroupName, - fieldName?: TFieldName, -): boolean { - return TestWalker.someTests(testObject => { - if (nonMatchingGroupName(testObject, groupName)) { - return false; - } + const tests = root.data.tests; + return tests.some(testObject => { return hasFailuresByTestObject(testObject, severityKey, fieldName); }); } diff --git a/packages/vest/src/suiteResult/selectors/shouldAddValidProperty.ts b/packages/vest/src/suiteResult/selectors/shouldAddValidProperty.ts deleted file mode 100644 index f1f2016cc..000000000 --- a/packages/vest/src/suiteResult/selectors/shouldAddValidProperty.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useIsOptionalFieldApplied } from 'optional'; -import { Predicates } from 'vest-utils'; -import { VestRuntime } from 'vestjs-runtime'; - -import { SuiteOptionalFields, TIsolateSuite } from 'IsolateSuite'; -import { TIsolateTest } from 'IsolateTest'; -import { OptionalFieldTypes } from 'OptionalTypes'; -import { Severity } from 'Severity'; -import { TFieldName, TGroupName } from 'SuiteResultTypes'; -import { SuiteWalker } from 'SuiteWalker'; -import { TestWalker } from 'TestWalker'; -import { VestTest } from 'VestTest'; -import { - hasErrorsByTestObjects, - hasGroupFailuresByTestObjects, -} from 'hasFailuresByTestObjects'; -import { nonMatchingFieldName } from 'matchingFieldName'; -import { nonMatchingGroupName } from 'matchingGroupName'; - -export function useShouldAddValidProperty(fieldName?: TFieldName): boolean { - // Is the field optional, and the optional condition is applied - if (useIsOptionalFieldApplied(fieldName)) { - return true; - } - - // Are there no tests? - if (TestWalker.hasNoTests()) { - return false; - } - - // // Does the field have any tests with errors? - if (hasErrorsByTestObjects(fieldName)) { - return false; - } - - // Does the given field have any pending tests that are not optional? - if (useHasNonOptionalIncomplete(fieldName)) { - return false; - } - - // Does the field have no missing tests? - return useNoMissingTests(fieldName); -} - -export function useShouldAddValidPropertyInGroup( - groupName: TGroupName, - fieldName: TFieldName, -): boolean { - if (useIsOptionalFieldApplied(fieldName)) { - return true; - } - - if (hasGroupFailuresByTestObjects(Severity.ERRORS, groupName, fieldName)) { - return false; - } - - // Do the given group/field have any pending tests that are not optional? - if (useHasNonOptionalIncompleteByGroup(groupName, fieldName)) { - return false; - } - - return useNoMissingTestsByGroup(groupName, fieldName); -} - -// Does the given field have any pending tests that are not optional? -function useHasNonOptionalIncomplete(fieldName?: TFieldName) { - return SuiteWalker.useHasPending( - Predicates.all( - VestTest.is, - (testObject: TIsolateTest) => - !nonMatchingFieldName(VestTest.getData(testObject), fieldName), - () => !useIsOptionalFieldApplied(fieldName), - ), - ); -} - -// Do the given group/field have any pending tests that are not optional? -function useHasNonOptionalIncompleteByGroup( - groupName: TGroupName, - fieldName: TFieldName, -): boolean { - return SuiteWalker.useHasPending( - Predicates.all( - VestTest.is, - (testObject: TIsolateTest) => - !nonMatchingGroupName(testObject, groupName), - (testObject: TIsolateTest) => - !nonMatchingFieldName(VestTest.getData(testObject), fieldName), - () => !useIsOptionalFieldApplied(fieldName), - ), - ); -} - -// Did all of the tests for the provided field run/omit? -// This makes sure that the fields are not skipped or pending. -function useNoMissingTests(fieldName?: string): boolean { - return TestWalker.everyTest(testObject => { - return useNoMissingTestsLogic(testObject, fieldName); - }); -} - -// Does the group have no missing tests? -function useNoMissingTestsByGroup( - groupName: TGroupName, - fieldName?: TFieldName, -): boolean { - return TestWalker.everyTest(testObject => { - if (nonMatchingGroupName(testObject, groupName)) { - return true; - } - - return useNoMissingTestsLogic(testObject, fieldName); - }); -} - -function useNoMissingTestsLogic( - testObject: TIsolateTest, - fieldName?: TFieldName, -): boolean { - if (nonMatchingFieldName(VestTest.getData(testObject), fieldName)) { - return true; - } - - /** - * The reason we're checking for the optional field here and not in "omitOptionalFields" - * is because that unlike the bool/function check we do there, here it only depends on - * whether the field was tested already or not. - * - * We qualify the test as not missing only if it was already run, if it is omitted, - * or if it is marked as optional, even if the optional check did not apply yet - - * but the test did not reach its final state. - */ - - return ( - VestTest.isOmitted(testObject) || - VestTest.isTested(testObject) || - useOptionalTestAwaitsResolution(testObject) - ); -} - -function useOptionalTestAwaitsResolution(testObject: TIsolateTest): boolean { - // Does the test belong to an optional field, - // and the test itself is still in an indeterminate state? - - const root = VestRuntime.useAvailableRoot(); - - const { fieldName } = VestTest.getData(testObject); - - return ( - SuiteOptionalFields.getOptionalField(root, fieldName).type === - OptionalFieldTypes.AUTO && VestTest.awaitsResolution(testObject) - ); -} diff --git a/packages/vest/src/suiteResult/selectors/suiteSelectors.ts b/packages/vest/src/suiteResult/selectors/suiteSelectors.ts index d07b4cf55..af721d883 100644 --- a/packages/vest/src/suiteResult/selectors/suiteSelectors.ts +++ b/packages/vest/src/suiteResult/selectors/suiteSelectors.ts @@ -1,3 +1,4 @@ +import type { RuleInstance } from 'n4s'; import { Maybe, greaterThan, isPositive } from 'vest-utils'; import { Severity, SeverityCount } from 'Severity'; @@ -8,14 +9,17 @@ import { SuiteSummary, TFieldName, TGroupName, - TestsContainer, } from 'SuiteResultTypes'; import { SummaryFailure } from 'SummaryFailure'; import { gatherFailures } from 'collectFailures'; import matchingFieldName from 'matchingFieldName'; -export function bindSuiteSelectors( - get: () => SuiteResult, +export function bindSuiteSelectors< + F extends TFieldName, + G extends TGroupName, + S extends RuleInstance | undefined = undefined, +>( + get: () => SuiteResult, ): SuiteSelectors { return { getError: (...args: Parameters['getError']>) => @@ -87,33 +91,45 @@ export function suiteSelectors( return Boolean(fieldName ? summary.tests[fieldName]?.valid : summary.valid); } + // eslint-disable-next-line max-statements, complexity function isValidByGroup(groupName: G, fieldName?: F): boolean { const group = summary.groups[groupName]; + // If the group doesn't exist, it's vacuously valid (can't fail tests that don't exist) if (!group) { - return false; + return true; } + // If checking a specific field within the group if (fieldName) { - return isFieldValid(group, fieldName); - } - for (const fieldName in group) { - if (!isFieldValid(group, fieldName)) { + const fieldSummary = group[fieldName]; + // If field doesn't exist in group, it's invalid (test was never run) + if (!fieldSummary) { return false; } + return !!fieldSummary.valid; } - return true; - } + // Check all fields in the group + let hasAnyFields = false; + for (const fieldName of Object.keys(group) as F[]) { + hasAnyFields = true; + if (!group[fieldName]?.valid) { + return false; + } + } - function hasWarnings(fieldName?: F): boolean { - return hasFailures(summary, SeverityCount.WARN_COUNT, fieldName); + return hasAnyFields; } function hasErrors(fieldName?: F): boolean { return hasFailures(summary, SeverityCount.ERROR_COUNT, fieldName); } + function hasWarnings(fieldName?: F): boolean { + return hasFailures(summary, SeverityCount.WARN_COUNT, fieldName); + } + function isTested(fieldName: F): boolean { return isPositive(summary.tests[fieldName]?.testCount); } @@ -253,13 +269,6 @@ function getFailuresByGroup( ): GetFailuresResponse { return gatherFailures(summary.groups[groupName], severityKey, fieldName); } -// Checks if a field is valid within a container object - can be within a group or top level -function isFieldValid( - testContainer: TestsContainer, - fieldName: TFieldName, -): boolean { - return !!testContainer[fieldName]?.valid; -} // Checks if a there are any failures of a given severity within a group // If a fieldName is provided, it will only check for failures within that field diff --git a/packages/vest/src/suiteResult/selectors/useProduceSuiteSummary.ts b/packages/vest/src/suiteResult/selectors/useProduceSuiteSummary.ts index 67b0ac7a0..d0e194913 100644 --- a/packages/vest/src/suiteResult/selectors/useProduceSuiteSummary.ts +++ b/packages/vest/src/suiteResult/selectors/useProduceSuiteSummary.ts @@ -1,53 +1,120 @@ -import { Maybe, assign, defaultTo } from 'vest-utils'; +import { defaultTo, isEmpty, Maybe, assign } from 'vest-utils'; +import { VestRuntime } from 'vestjs-runtime'; +import { TIsolateSuite } from 'IsolateSuite'; import { TIsolateTest } from 'IsolateTest'; import { countKeyBySeverity, Severity } from 'Severity'; import { + CommonSummaryProperties, Groups, SingleTestSummary, SuiteSummary, SummaryBase, + Tests, TFieldName, TGroupName, - Tests, } from 'SuiteResultTypes'; import { SummaryFailure } from 'SummaryFailure'; -import { TestWalker } from 'TestWalker'; +import { isVestIsolate } from 'VestIsolateType'; import { VestTest } from 'VestTest'; import { - useShouldAddValidProperty, - useShouldAddValidPropertyInGroup, -} from 'shouldAddValidProperty'; + useSetValidProperty, + useSetValidPropertyImpl, +} from 'useSetValidProperty'; export function useProduceSuiteSummary< F extends TFieldName, G extends TGroupName, >(): SuiteSummary { - // @vx-allow use-use (TODO: fix this. the error is in the lint rule) - const summary = TestWalker.reduceTests< - SuiteSummary, - TIsolateTest - >((summary, testObject) => { - const fieldName = VestTest.getData(testObject).fieldName; - summary.tests[fieldName] = useAppendToTest(summary.tests, testObject); - summary.groups = useAppendToGroup(summary.groups, testObject); + const root = VestRuntime.useAvailableRoot(); + + const summary = new SuiteSummary(); + + if (isVestIsolate(root)) { + useProcessTests(root.data.tests, summary); + } + + if (summary.valid !== false) { + summary.valid = useSetValidProperty(); + } + + return summary; +} + +function useProcessTests( + tests: TIsolateTest[], + summary: SuiteSummary, +): SuiteSummary { + if (isEmpty(tests)) { + // early bail for empty test arrays + summary.valid = false; + return summary; + } + + tests.reduce((summary, testObject) => { + const { fieldName } = VestTest.getData(testObject); + + summary.tests[fieldName] = appendToTest(summary.tests, testObject); + summary.groups = appendToGroup(summary.groups, testObject); + + if (summary.tests[fieldName].valid !== false) { + summary.tests[fieldName].valid = useSetValidProperty(fieldName); + } if (VestTest.isOmitted(testObject)) { return summary; } + if (summary.tests[fieldName].valid === false) { summary.valid = false; } - return addSummaryStats(testObject, summary); - }, new SuiteSummary()); - summary.valid = summary.valid === false ? false : useShouldAddValidProperty(); + return addSummaryStats(testObject, summary); + }, summary); return summary; } +function appendToGroup( + groups: Groups, + testObject: TIsolateTest, +): Groups { + const { fieldName } = VestTest.getData(testObject); + const groupName = VestTest.getGroupName(testObject); + + if (!groupName) { + return groups; + } + + groups[groupName] = groups[groupName] || {}; + const group = groups[groupName]; + + group[fieldName] = appendTestSummaryObject( + group[fieldName], + testObject, + ); + + // Always re-evaluate validity to account for optional fields + group[fieldName].valid = useSetValidPropertyImpl(fieldName, groupName); + + return groups; +} + +function appendToTest( + tests: Tests, + testObject: TIsolateTest, +): SingleTestSummary { + const fieldName = VestTest.getData(testObject).fieldName; + + const test = appendTestSummaryObject( + tests[fieldName], + testObject, + ); + return test; +} + function addSummaryStats( - testObject: TIsolateTest, + testObject: TIsolateTest, summary: SuiteSummary, ): SuiteSummary { if (VestTest.isWarning(testObject)) { @@ -69,106 +136,67 @@ function addSummaryStats( return summary; } -function useAppendToTest( - tests: Tests, - testObject: TIsolateTest, -): SingleTestSummary { - const fieldName = VestTest.getData(testObject).fieldName; - - const test = appendTestObject(tests[fieldName], testObject); - // If `valid` is false to begin with, keep it that way. Otherwise, assess. - test.valid = - test.valid === false ? false : useShouldAddValidProperty(fieldName); - - return test; -} - -/** - * Appends to a group object if within a group - */ -function useAppendToGroup( - groups: Groups, +function appendTestSummaryObject( + summaryKey: Maybe, testObject: TIsolateTest, -): Groups { - const { groupName, fieldName } = VestTest.getData(testObject); - - if (!groupName) { - return groups; - } +): S { + const nextSummaryKey = createNewSummaryKey(summaryKey); - groups[groupName] = groups[groupName] || {}; - const group = groups[groupName]; - group[fieldName] = appendTestObject(group[fieldName], testObject); + if (VestTest.isNonActionable(testObject)) return nextSummaryKey; - group[fieldName].valid = - group[fieldName].valid === false - ? false - : useShouldAddValidPropertyInGroup(groupName, fieldName); + return updateSummaryWithTestResults(nextSummaryKey, testObject); +} - return groups; +function createNewSummaryKey( + summaryKey: Maybe, +): S { + return defaultTo(summaryKey ? { ...summaryKey } : null, baseTestStats); } -/** - * Appends the test to a results object. - */ -// eslint-disable-next-line max-statements, complexity -function appendTestObject( - summaryKey: Maybe, +function updateSummaryWithTestResults( + nextSummaryKey: S, testObject: TIsolateTest, -): SingleTestSummary { +): S { const { message } = VestTest.getData(testObject); - // Let's first create a new object, so we don't mutate the original. - const nextSummaryKey = defaultTo( - summaryKey ? { ...summaryKey } : null, - baseTestStats, - ); - - // If the test is not actionable, we don't need to append it to the summary. - if (VestTest.isNonActionable(testObject)) return nextSummaryKey; - - // Increment the pending count if the test is pending. if (VestTest.isPending(testObject)) { nextSummaryKey.pendingCount++; } - // Increment the error count if the test is failing. if (VestTest.isFailing(testObject)) { - incrementFailures(Severity.ERRORS); + incrementFailures(nextSummaryKey, Severity.ERRORS, message); } else if (VestTest.isWarning(testObject)) { - // Increment the warning count if the test is warning. - incrementFailures(Severity.WARNINGS); + incrementFailures(nextSummaryKey, Severity.WARNINGS, message); } - // Increment the test count. if (shouldCountTestRun(testObject)) { nextSummaryKey.testCount++; } return nextSummaryKey; +} - // Helper function to increment the failure count. - function incrementFailures(severity: Severity) { - const countKey = countKeyBySeverity(severity); - nextSummaryKey[countKey]++; - if (message) { - nextSummaryKey[severity] = (nextSummaryKey[severity] || []).concat( - message, - ); - } +function incrementFailures( + summaryKey: S, + severity: Severity, + message?: string, +): void { + const countKey = countKeyBySeverity(severity); + summaryKey[countKey]++; + if (message) { + summaryKey[severity] = (summaryKey[severity] || []).concat(message); } } -function baseTestStats() { +function baseTestStats(): S { return assign(new SummaryBase(), { errors: [], - valid: true, warnings: [], - }); + }) as unknown as S; } -function shouldCountTestRun( - testObject: TIsolateTest, +function shouldCountTestRun( + testObject: TIsolateTest, ): boolean { return VestTest.isTested(testObject) || VestTest.isPending(testObject); } diff --git a/packages/vest/src/suiteResult/selectors/useSetValidProperty.ts b/packages/vest/src/suiteResult/selectors/useSetValidProperty.ts new file mode 100644 index 000000000..f0950b826 --- /dev/null +++ b/packages/vest/src/suiteResult/selectors/useSetValidProperty.ts @@ -0,0 +1,454 @@ +import { useIsOptionalFieldApplied } from 'optional'; +import { Predicates } from 'vest-utils'; +import { VestRuntime } from 'vestjs-runtime'; + +import { SuiteOptionalFields, TIsolateSuite } from 'IsolateSuite'; +import { TIsolateTest } from 'IsolateTest'; +import { OptionalFieldTypes } from 'OptionalTypes'; +import { TFieldName, TGroupName } from 'SuiteResultTypes'; +import { SuiteWalker } from 'SuiteWalker'; +import { TestWalker } from 'TestWalker'; +import { isVestIsolate } from 'VestIsolateType'; +import { VestTest } from 'VestTest'; +import { hasErrorsByTestObjects } from 'hasFailuresByTestObjects'; +import { nonMatchingFieldName } from 'matchingFieldName'; + +/** + * Determines if a field (or field within a group) should be marked as "valid". + * + * This module answers the question: "Is this field ready to be submitted?" + * + * ## What makes a field valid? + * + * A field is valid when ALL of these conditions are met: + * 1. The field has no errors (failures) + * 2. All tests for the field have completed (not pending) + * 3. All tests for the field have run (not skipped or missing) + * + * ## Special cases: + * + * - **Optional fields**: If a field is marked as optional AND the optional condition + * is met, it's automatically valid regardless of test results. The optional condition + * can be: + * - AUTO: Tests never ran, OR the field value is blank (empty string, null, undefined) + * - CUSTOM: A boolean or function provided to `optional()` evaluates to true + * + * - **Empty groups**: If checking a group that has no tests for a field, it's + * considered valid (you can't fail tests that don't exist). + * + * - **Empty fields**: If checking a field at the top level that has no tests, + * it's considered INVALID (incomplete validation). + * + * ## Usage: + * + * ```typescript + * // Check if a field is valid at the suite level + * const isEmailValid = useSetValidProperty('email'); + * + * // Check if a field is valid within a specific group + * const isEmailValidInGroup = useSetValidPropertyImpl('email', 'contactInfo'); + * ``` + */ + +/** + * Checks if a field is valid at the top level (across all tests in the suite). + * + * @param fieldName - The name of the field to check. If undefined, checks entire suite. + * @returns `true` if the field is valid, `false` otherwise. + * + * @example + * ```typescript + * // Check if the 'username' field is valid + * const isValid = useSetValidProperty('username'); + * // Returns true only if: + * // - All 'username' tests passed (no errors) + * // - All 'username' tests completed (not pending) + * // - All 'username' tests ran (not skipped) + * ``` + */ +export function useSetValidProperty(fieldName?: TFieldName): boolean { + return useSetValidPropertyImpl(fieldName); +} + +/** + * Shared implementation for validity checking with optional group scoping. + * + * This is the core logic that determines validity. It can check: + * - A field across the entire suite (when groupName is undefined) + * - A field within a specific group (when groupName is provided) + * + * @param fieldName - The field to check. If undefined, checks all fields. + * @param groupName - Optional group name to scope the check to tests within that group only. + * @returns `true` if valid, `false` otherwise. + * + * @example + * ```typescript + * // Check if 'email' is valid across entire suite + * useSetValidPropertyImpl('email'); // groupName = undefined + * + * // Check if 'email' is valid within 'shippingAddress' group + * useSetValidPropertyImpl('email', 'shippingAddress'); + * ``` + * + * ## How it works (step by step): + * + * 1. **Check if optional**: If the field is optional and the optional condition + * is applied, immediately return `true`. Optional fields are valid when: + * - AUTO type: Tests never ran OR field value is blank ('', null, undefined) + * - CUSTOM type: The boolean/function provided to `optional()` returns true + * + * 2. **Check for errors**: If any test for this field has errors, return `false`. + * A field with errors cannot be valid. + * + * 3. **Check for pending tests**: If any non-optional test for this field is + * still running (pending), return `false`. We can't say a field is valid + * until all its tests complete. + * + * 4. **Check for missing tests**: If any test for this field was skipped or + * didn't run, return `false`. We need all tests to run to confirm validity. + * + * 5. **All checks passed**: Return `true`. The field is valid! + */ +export function useSetValidPropertyImpl( + fieldName?: TFieldName, + groupName?: TGroupName, +): boolean { + // Step 1: Is the field optional, and the optional condition is applied? + // Examples: + // - AUTO: User marked 'secondaryEmail' as optional, and it's blank ('', null, undefined) + // - AUTO: User marked 'pet_color' as optional, and tests were skipped via only() + // - CUSTOM: optional({ age: () => !suite.get().hasErrors('birthdate') }) + if (useIsOptionalFieldApplied(fieldName)) { + return true; + } + + // Step 2: Does the field have any tests with errors? + // Example: Password test failed because it's too short + if (useHasErrors(fieldName, groupName)) { + return false; + } + + // Step 3: Does the given field have any pending tests that are not optional? + // Example: Email validation is still checking with the server + if (useHasNonOptionalIncomplete(fieldName, groupName)) { + return false; + } + + // Step 4: Did all required tests for the field run? + // Example: Username uniqueness check was skipped + return useNoMissingTests(fieldName, groupName); +} + +/** + * Helper function to check if a field has errors. + * + * Decides whether to check errors globally or within a specific group. + * + * @param fieldName - The field to check for errors + * @param groupName - Optional group to scope the check + * @returns `true` if the field has errors, `false` otherwise + * + * @example + * ```typescript + * // Check if 'email' has errors anywhere in the suite + * useHasErrors('email'); // Checks all tests for 'email' + * + * // Check if 'email' has errors only in 'billingInfo' group + * useHasErrors('email', 'billingInfo'); // Only checks 'email' tests in that group + * ``` + */ +function useHasErrors( + fieldName: TFieldName | undefined, + groupName?: TGroupName, +): boolean { + if (groupName) { + return hasErrorsByTestObjectsInGroup(fieldName, groupName); + } + return hasErrorsByTestObjects(fieldName); +} + +/** + * Checks if a specific field has errors within a specific group. + * + * This function looks through all test objects and finds ones that: + * - Belong to the specified group + * - Test the specified field + * - Have failed (have errors) + * + * @param fieldName - The field to check + * @param groupName - The group to check within + * @returns `true` if any test for this field in this group has failed + * + * @example + * ```typescript + * // Imagine you have: + * group('contactInfo', () => { + * test('email', 'Invalid email', () => false); // This test fails + * test('phone', () => true); + * }); + * + * hasErrorsByTestObjectsInGroup('email', 'contactInfo'); // true (test failed) + * hasErrorsByTestObjectsInGroup('phone', 'contactInfo'); // false (test passed) + * hasErrorsByTestObjectsInGroup('email', 'otherGroup'); // false (not in that group) + * ``` + */ +function hasErrorsByTestObjectsInGroup( + fieldName: TFieldName | undefined, + groupName: TGroupName, +): boolean { + const root = VestRuntime.useAvailableRoot(); + + if (!isVestIsolate(root)) { + return false; + } + + const tests = root.data.tests; + + return tests.some(testObject => { + // Skip tests that aren't in our target group + if (VestTest.getGroupName(testObject) !== groupName) { + return false; + } + + // Skip tests that didn't fail + if (!VestTest.hasFailures(testObject)) { + return false; + } + + // Skip tests that aren't for our target field + if (nonMatchingFieldName(VestTest.getData(testObject), fieldName)) { + return false; + } + + // This test is in our group, for our field, and it failed! + return VestTest.isFailing(testObject); + }); +} + +/** + * Checks if a field has any pending (incomplete) tests that are not optional. + * + * A test is "pending" when it started running but hasn't finished yet. + * This commonly happens with async tests. + * + * @param fieldName - The field to check + * @param groupName - Optional group to scope the check + * @returns `true` if there are pending non-optional tests + * + * @example + * ```typescript + * // Imagine you have: + * test('email', async () => { + * await checkEmailWithServer(email); // This takes 2 seconds + * return result; + * }); + * + * // Right after calling suite.run(): + * useHasNonOptionalIncomplete('email'); // true (test still running) + * + * // After 2 seconds: + * useHasNonOptionalIncomplete('email'); // false (test completed) + * ``` + * + * ## Why ignore optional fields? + * + * If a field is optional and the condition is applied, we don't care if its + * tests are pending. Optional fields are valid by definition when: + * - The field is blank (AUTO type) + * - The custom condition returns true (CUSTOM type) + * - The tests never ran (AUTO type) + */ +function useHasNonOptionalIncomplete( + fieldName?: TFieldName, + groupName?: TGroupName, +) { + return SuiteWalker.useHasPending( + Predicates.all( + VestTest.is, + // If groupName specified, only check tests in that group + (testObject: TIsolateTest) => + !groupName || VestTest.getGroupName(testObject) === groupName, + // Only check tests for our target field + (testObject: TIsolateTest) => + !nonMatchingFieldName(VestTest.getData(testObject), fieldName), + // Ignore optional fields (they're valid even when pending) + () => !useIsOptionalFieldApplied(fieldName), + ), + ); +} + +/** + * Checks if all required tests for a field have run. + * + * A test is "missing" if it was defined but: + * - Skipped (using skip() or skipWhen()) + * - Never executed (mode filters prevented it) + * - Still pending (hasn't finished yet) + * + * @param fieldName - The field to check + * @param groupName - Optional group to scope the check + * @returns `true` if all tests ran, `false` if any are missing + * + * @example + * ```typescript + * // Imagine you have: + * test('username', () => true); // Runs + * skipWhen(someCondition, () => { + * test('username', () => true); // Skipped + * }); + * + * // If someCondition is true: + * useNoMissingTests('username'); // false (one test was skipped) + * + * // If someCondition is false: + * useNoMissingTests('username'); // true (all tests ran) + * ``` + * + * ## Empty field handling: + * + * What if no tests exist for a field? + * - **In a group**: Returns `true` (empty group is valid - can't fail tests that don't exist) + * - **At top level**: Returns `false` (no tests means incomplete validation) + * + * This distinction is important: + * - `group('emptyGroup', () => {})` - valid (intentionally empty) + * - `create(() => {})` - invalid (you forgot to add tests) + */ +function useNoMissingTests( + fieldName?: string, + groupName?: TGroupName, +): boolean { + let hasAnyTestsForField = false; + + const result = TestWalker.everyTest(testObject => { + // If checking a group, skip tests not in that group + if (groupName && VestTest.getGroupName(testObject) !== groupName) { + return true; + } + + // Skip tests not for our target field + if (nonMatchingFieldName(VestTest.getData(testObject), fieldName)) { + return true; + } + + // Found a test for our field! + hasAnyTestsForField = true; + + return useNoMissingTestsLogic(testObject, fieldName); + }); + + // No tests exist for this field - handle based on context + if (!hasAnyTestsForField) { + // Groups: empty is valid (vacuously true - you can't fail tests that don't exist) + // Top-level: empty is invalid (you probably forgot to add tests) + return !!groupName; + } + + return result; +} + +/** + * Checks if a single test should be considered "not missing". + * + * A test is "not missing" if it: + * 1. Already ran and completed (tested) + * 2. Was intentionally omitted (e.g., optional field is blank: '', null, or undefined) + * 3. Is an AUTO optional async test that's still running but hasn't finished + * + * @param testObject - The test to check + * @param fieldName - The field being validated + * @returns `true` if the test is not missing, `false` if it's missing + * + * @example + * ```typescript + * // Test ran and completed + * test('email', () => true); + * useNoMissingTestsLogic(testObject); // true + * + * // Test was skipped + * skip('email'); + * test('email', () => true); + * useNoMissingTestsLogic(testObject); // false + * + * // Test was omitted (optional field is blank) + * optional('email'); + * test('email', () => false); + * useNoMissingTestsLogic(testObject); // true (intentionally omitted) + * ``` + */ +function useNoMissingTestsLogic( + testObject: TIsolateTest, + fieldName?: TFieldName, +): boolean { + if (nonMatchingFieldName(VestTest.getData(testObject), fieldName)) { + return true; + } + + /** + * Why check for optional fields here? + * + * Optional fields have a special rule: if they're marked as AUTO optional + * (e.g., "optional when blank") and the test hasn't finished yet, we still + * consider it "not missing". + * + * This is different from the omitOptionalFields check, which uses a boolean + * condition. Here, we only care about whether the test has been tested already. + * + * A test is "not missing" if: + * 1. It already ran (isTested) - we have results + * 2. It was intentionally omitted (isOmitted) - we don't need results + * 3. It's an optional AUTO field that's still running - we'll accept its absence + */ + + return ( + VestTest.isOmitted(testObject) || + VestTest.isTested(testObject) || + useOptionalTestAwaitsResolution(testObject) + ); +} + +/** + * Checks if a test belongs to an AUTO optional field that's still running. + * + * AUTO optional fields are fields marked as optional without a custom condition: + * ```typescript + * optional('email'); // AUTO type + * optional(['email', 'phone']); // Multiple AUTO fields + * ``` + * + * AUTO optional behavior (per documentation): + * - If tests never ran (e.g., skipped via only()), field is optional + * - If field value is blank ('', null, undefined) in data object, field is optional + * + * When a field is AUTO optional and blank, its tests are typically omitted. + * But if the test is async and started before we knew the field would be blank, + * we give it a pass - we don't consider it "missing" even if it hasn't finished. + * + * @param testObject - The test to check + * @returns `true` if this is an AUTO optional field with a pending test + * + * @example + * ```typescript + * optional('email'); // AUTO optional + * + * test('email', async () => { + * // User clears the email field while this is running + * await validateEmail(email); + * return result; + * }); + * + * // While test is still running: + * useOptionalTestAwaitsResolution(testObject); // true + * // We don't consider this test "missing" even though it's not done + * ``` + */ +function useOptionalTestAwaitsResolution(testObject: TIsolateTest): boolean { + const root = VestRuntime.useAvailableRoot(); + + const { fieldName } = VestTest.getData(testObject); + + return ( + SuiteOptionalFields.getOptionalField(root, fieldName).type === + OptionalFieldTypes.AUTO && VestTest.awaitsResolution(testObject) + ); +} diff --git a/packages/vest/src/suiteResult/selectors/useShouldAddValidPropertyInGroup.ts b/packages/vest/src/suiteResult/selectors/useShouldAddValidPropertyInGroup.ts new file mode 100644 index 000000000..ebcfc5675 --- /dev/null +++ b/packages/vest/src/suiteResult/selectors/useShouldAddValidPropertyInGroup.ts @@ -0,0 +1,10 @@ +import { TFieldName, TGroupName } from 'SuiteResultTypes'; +import { useSetValidPropertyImpl } from 'useSetValidProperty'; + +export function useShouldAddValidPropertyInGroup( + groupName: TGroupName, + fieldName: TFieldName, +): boolean { + // Use shared implementation with groupName to scope all checks to this group + return useSetValidPropertyImpl(fieldName, groupName); +} diff --git a/packages/vest/src/suiteResult/suiteResult.ts b/packages/vest/src/suiteResult/suiteResult.ts index 897675254..89924bc43 100644 --- a/packages/vest/src/suiteResult/suiteResult.ts +++ b/packages/vest/src/suiteResult/suiteResult.ts @@ -1,35 +1,61 @@ -import { assign, Maybe } from 'vest-utils'; +import type { RuleInstance } from 'n4s'; +import { assign, freezeAssign, Maybe } from 'vest-utils'; +import { VestRuntime } from 'vestjs-runtime'; +import { TIsolateSuite } from 'IsolateSuite'; import { useSuiteName, useSuiteResultCache } from 'Runtime'; import { SuiteResult, SuiteSummary, TFieldName, TGroupName, + SuiteSchemaTypes, } from 'SuiteResultTypes'; import { suiteSelectors } from 'suiteSelectors'; import { useProduceSuiteSummary } from 'useProduceSuiteSummary'; +type SuiteSchemaData | undefined> = + SuiteSchemaTypes extends { data: infer D } ? D : never; + export function useCreateSuiteResult< F extends TFieldName, G extends TGroupName, ->(): SuiteResult { - return useSuiteResultCache(() => { + S extends RuleInstance | undefined = undefined, +>(schema?: S): SuiteResult { + return useSuiteResultCache(() => { // @vx-allow use-use const summary = useProduceSuiteSummary(); // @vx-allow use-use const suiteName = useSuiteName(); - return Object.freeze(constructSuiteResultObject(summary, suiteName)); + return freezeAssign( + constructSuiteResultObject(summary, suiteName, schema), + { + dump: VestRuntime.persist(VestRuntime.useAvailableRoot), + }, + ) as SuiteResult; }); } export function constructSuiteResultObject< F extends TFieldName, G extends TGroupName, ->(summary: SuiteSummary, suiteName?: Maybe): SuiteResult { + S extends RuleInstance | undefined = undefined, +>( + summary: SuiteSummary, + suiteName?: Maybe, + schema?: S, +): SuiteResult { + const types = schema + ? ({ + data: undefined as unknown as SuiteSchemaData, + schema, + } as SuiteSchemaTypes) + : (undefined as SuiteSchemaTypes); + return assign(summary, suiteSelectors(summary), { suiteName, - }) as SuiteResult; + types, + }) as SuiteResult; } diff --git a/packages/vest/src/suiteResult/suiteRunResult.ts b/packages/vest/src/suiteResult/suiteRunResult.ts deleted file mode 100644 index fd6f7357d..000000000 --- a/packages/vest/src/suiteResult/suiteRunResult.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { freezeAssign } from 'vest-utils'; -import { VestRuntime } from 'vestjs-runtime'; - -import { - SuiteResult, - SuiteRunResult, - TFieldName, - TGroupName, -} from 'SuiteResultTypes'; -import { SuiteWalker } from 'SuiteWalker'; -import { useDeferDoneCallback } from 'deferDoneCallback'; -import { shouldSkipDoneRegistration } from 'shouldSkipDoneRegistration'; -import { useCreateSuiteResult } from 'suiteResult'; - -export function useSuiteRunResult< - F extends TFieldName, - G extends TGroupName, ->(): SuiteRunResult { - return freezeAssign>( - { - done: VestRuntime.persist(done) as Done, - }, - useCreateSuiteResult(), - ); -} - -/** - * Registers done callbacks. - * @register {Object} Vest output object. - */ -// @vx-allow use-use -function done( - ...args: any[] -): SuiteRunResult { - const [callback, fieldName] = args.reverse() as [ - (res: SuiteResult) => void, - F, - ]; - const output = useSuiteRunResult(); - if (shouldSkipDoneRegistration(callback, fieldName, output)) { - return output; - } - const useDoneCallback = () => callback(useCreateSuiteResult()); - if (!SuiteWalker.useHasRemainingWithTestNameMatching(fieldName)) { - useDoneCallback(); - return output; - } - useDeferDoneCallback(useDoneCallback, fieldName); - return output; -} - -export interface Done { - (...args: [cb: (res: SuiteResult) => void]): SuiteRunResult; - ( - ...args: [fieldName: F, cb: (res: SuiteResult) => void] - ): SuiteRunResult; -} diff --git a/packages/vest/src/testUtils/TVestMock.ts b/packages/vest/src/testUtils/TVestMock.ts index 91c0edf4e..470a9762a 100644 --- a/packages/vest/src/testUtils/TVestMock.ts +++ b/packages/vest/src/testUtils/TVestMock.ts @@ -1,7 +1,5 @@ import { TFieldName, TGroupName } from 'SuiteResultTypes'; import * as vest from 'vest'; -export type TVestMock = typeof vest; - -export type TTestSuiteCallback = (..._args: any[]) => void; +type TTestSuiteCallback = (..._args: any[]) => void; export type TTestSuite = vest.Suite; diff --git a/packages/vest/src/testUtils/__tests__/partition.test.ts b/packages/vest/src/testUtils/__tests__/partition.test.ts index 3da3b447c..e02e5fbe5 100644 --- a/packages/vest/src/testUtils/__tests__/partition.test.ts +++ b/packages/vest/src/testUtils/__tests__/partition.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import partition from '../partition'; describe('partition', () => { - it('Should correctly partition array', () => { + it('should correctly partition an array', () => { expect(partition([300, 200, 10, 50, 0, -500], v => v <= 100)) .toMatchInlineSnapshot(` [ diff --git a/packages/vest/src/testUtils/suiteDummy.ts b/packages/vest/src/testUtils/suiteDummy.ts index 8a0f83b2b..07f6c2639 100644 --- a/packages/vest/src/testUtils/suiteDummy.ts +++ b/packages/vest/src/testUtils/suiteDummy.ts @@ -44,7 +44,7 @@ export function passingWithUntestedOptional( asArray(required).forEach(fieldName => { dummyTest.passing(fieldName); }); - })(); + }).run(); } export function passingWithOptional( @@ -61,7 +61,7 @@ export function passingWithOptional( asArray(required).forEach(fieldName => { dummyTest.passing(fieldName); }); - })(); + }).run(); } export function failingOptional( @@ -78,7 +78,7 @@ export function failingOptional( asArray(required).forEach(fieldName => { dummyTest.passing(fieldName); }); - })(); + }).run(); } export function untested(fields?: OneOrMoreOf) { @@ -92,7 +92,7 @@ function createSuiteRunResult( fieldNames: Maybe, callback: (_fieldName?: string) => void, ) { - return createSuite(fieldNames, callback)(); + return createSuite(fieldNames, callback).run(); } function createSuite( diff --git a/packages/vest/src/testUtils/testDummy.ts b/packages/vest/src/testUtils/testDummy.ts index 2b57759a0..666a46d4a 100644 --- a/packages/vest/src/testUtils/testDummy.ts +++ b/packages/vest/src/testUtils/testDummy.ts @@ -1,139 +1,115 @@ import { faker } from '@faker-js/faker'; import { vi } from 'vitest'; +import wait from 'wait'; import { test as vestTest, warn } from 'vest'; /** * Generates dummy vest tests. */ -// eslint-disable-next-line max-lines-per-function -const testDummy = () => { - const failing = ( - name: string = faker.lorem.word(), - message: string = faker.lorem.words(), - ) => { - const to = vestTest( - name, - message, - vi.fn(() => { - throw new Error(); - }), - ); +const createFailing = ( + name: string = faker.lorem.word(), + message: string = faker.lorem.words(), +) => + vestTest( + name, + message, + vi.fn(() => { + throw new Error(); + }), + ); - return to; - }; +const createFailingWarning = ( + name = faker.lorem.word(), + message = faker.lorem.words(), +) => + vestTest( + name, + message, + vi.fn(() => { + warn(); + throw new Error(); + }), + ); - const failingWarning = ( - name = faker.lorem.word(), - message = faker.lorem.words(), - ) => { - const to = vestTest( - name, - message, - vi.fn(() => { - warn(); - throw new Error(); - }), - ); +const createPassing = ( + name = faker.lorem.word(), + message = faker.lorem.words(), +) => vestTest(name, message, vi.fn()); - return to; - }; +const createPassingWarning = ( + name = faker.lorem.word(), + message = faker.lorem.words(), +) => + vestTest( + name, + message, + vi.fn(() => { + warn(); + }), + ); - const passing = ( - name = faker.lorem.word(), - message = faker.lorem.words(), - ) => { - const to = vestTest(name, message, vi.fn()); +const createFailingAsync = ( + name = faker.lorem.word(), + { message = faker.lorem.words(), time = 0 } = {}, +) => + vestTest( + name, + message, + vi.fn(async () => { + await wait(time); + throw new Error(); + }), + ); - return to; - }; +const createFailingWarningAsync = ( + name = faker.lorem.word(), + { message = faker.lorem.words(), time = 0 } = {}, +) => + vestTest( + name, + message, + vi.fn(async () => { + warn(); + await wait(time); + throw new Error(); + }), + ); - const passingWarning = ( - name = faker.lorem.word(), - message = faker.lorem.words(), - ) => { - const to = vestTest( - name, - message, - vi.fn(() => { - warn(); - }), - ); - return to; - }; +const createPassingAsync = ( + name = faker.lorem.word(), + { message = faker.lorem.words(), time = 0 } = {}, +) => + vestTest( + name, + message, + vi.fn(async () => { + await wait(time); + }), + ); - const failingAsync = ( - name = faker.lorem.word(), - { message = faker.lorem.words(), time = 0 } = {}, - ) => - vestTest( - name, - message, - vi.fn( - () => - new Promise((_, reject) => { - setTimeout(reject, time); - }), - ), - ); +const createPassingWarningAsync = ( + name = faker.lorem.word(), + { message = faker.lorem.words(), time = 0 } = {}, +) => + vestTest( + name, + message, + vi.fn(async () => { + warn(); + await wait(time); + }), + ); - const failingWarningAsync = ( - name = faker.lorem.word(), - { message = faker.lorem.words(), time = 0 } = {}, - ) => - vestTest( - name, - message, - vi.fn(() => { - warn(); - return new Promise((_, reject) => { - setTimeout(reject, time); - }); - }), - ); - - const passingAsync = ( - name = faker.lorem.word(), - { message = faker.lorem.words(), time = 0 } = {}, - ) => - vestTest( - name, - message, - vi.fn( - () => - new Promise(resolve => { - setTimeout(resolve, time); - }), - ), - ); - - const passingWarningAsync = ( - name = faker.lorem.word(), - { message = faker.lorem.words(), time = 0 } = {}, - ) => - vestTest( - name, - message, - vi.fn(() => { - warn(); - return new Promise(resolve => { - setTimeout(resolve, time); - }); - }), - ); - - return { - failing, - failingAsync, - failingWarning, - failingWarningAsync, - passing, - passingAsync, - passingWarning, - passingWarningAsync, - }; -}; +const testDummy = () => ({ + failing: createFailing, + failingAsync: createFailingAsync, + failingWarning: createFailingWarning, + failingWarningAsync: createFailingWarningAsync, + passing: createPassing, + passingAsync: createPassingAsync, + passingWarning: createPassingWarning, + passingWarningAsync: createPassingWarningAsync, +}); export const dummyTest = testDummy(); - -export type TDummyTest = typeof dummyTest; diff --git a/packages/vest/src/vest.ts b/packages/vest/src/vest.ts index 0c83f41a1..f29306c41 100644 --- a/packages/vest/src/vest.ts +++ b/packages/vest/src/vest.ts @@ -2,14 +2,10 @@ import { enforce } from 'n4s'; import { optional } from 'optional'; import { Modes } from 'Modes'; -import type { - SuiteResult, - SuiteRunResult, - SuiteSummary, -} from 'SuiteResultTypes'; +import type { SuiteResult, SuiteSummary } from 'SuiteResultTypes'; import type { Suite } from 'SuiteTypes'; import { registerReconciler } from 'VestReconciler'; -import { createSuite, staticSuite, StaticSuite } from 'createSuite'; +import { createSuite } from 'createSuite'; import { each } from 'each'; import { skip, only } from 'focused'; import { group } from 'group'; @@ -36,9 +32,8 @@ export { suiteSelectors, each, mode, - staticSuite, Modes, registerReconciler, }; -export type { SuiteResult, SuiteRunResult, SuiteSummary, Suite, StaticSuite }; +export type { SuiteResult, SuiteSummary, Suite }; diff --git a/packages/vest/tsconfig.json b/packages/vest/tsconfig.json index 7c311562d..39b343831 100644 --- a/packages/vest/tsconfig.json +++ b/packages/vest/tsconfig.json @@ -14,27 +14,25 @@ "suiteDummy": ["./src/testUtils/suiteDummy.ts"], "partition": ["./src/testUtils/partition.ts"], "TVestMock": ["./src/testUtils/TVestMock.ts"], - "suiteRunResult": ["./src/suiteResult/suiteRunResult.ts"], "suiteResult": ["./src/suiteResult/suiteResult.ts"], "SummaryFailure": ["./src/suiteResult/SummaryFailure.ts"], "SuiteResultTypes": ["./src/suiteResult/SuiteResultTypes.ts"], "Severity": ["./src/suiteResult/Severity.ts"], + "useShouldAddValidPropertyInGroup": [ + "./src/suiteResult/selectors/useShouldAddValidPropertyInGroup.ts" + ], + "useSetValidProperty": [ + "./src/suiteResult/selectors/useSetValidProperty.ts" + ], "useProduceSuiteSummary": [ "./src/suiteResult/selectors/useProduceSuiteSummary.ts" ], "suiteSelectors": ["./src/suiteResult/selectors/suiteSelectors.ts"], - "shouldAddValidProperty": [ - "./src/suiteResult/selectors/shouldAddValidProperty.ts" - ], "hasFailuresByTestObjects": [ "./src/suiteResult/selectors/hasFailuresByTestObjects.ts" ], "collectFailures": ["./src/suiteResult/selectors/collectFailures.ts"], "LazyDraft": ["./src/suiteResult/selectors/LazyDraft.ts"], - "shouldSkipDoneRegistration": [ - "./src/suiteResult/done/shouldSkipDoneRegistration.ts" - ], - "deferDoneCallback": ["./src/suiteResult/done/deferDoneCallback.ts"], "runCallbacks": ["./src/suite/runCallbacks.ts"], "getTypedMethods": ["./src/suite/getTypedMethods.ts"], "createSuite": ["./src/suite/createSuite.ts"], @@ -43,6 +41,7 @@ "validateSuiteParams": [ "./src/suite/validateParams/validateSuiteParams.ts" ], + "deferDoneCallback": ["./src/suite/after/deferDoneCallback.ts"], "skipWhen": ["./src/isolates/skipWhen.ts"], "omitWhen": ["./src/isolates/omitWhen.ts"], "group": ["./src/isolates/group.ts"], @@ -58,21 +57,20 @@ "useHasOnliedTests": ["./src/hooks/focused/useHasOnliedTests.ts"], "focused": ["./src/hooks/focused/focused.ts"], "FocusedKeys": ["./src/hooks/focused/FocusedKeys.ts"], - "promisify": ["./src/exports/promisify.ts"], "parser": ["./src/exports/parser.ts"], - "enforce@schema": ["./src/exports/enforce@schema.ts"], + "memo": ["./src/exports/memo.ts"], "enforce@isURL": ["./src/exports/enforce@isURL.ts"], "enforce@email": ["./src/exports/enforce@email.ts"], "enforce@date": ["./src/exports/enforce@date.ts"], - "enforce@compounds": ["./src/exports/enforce@compounds.ts"], - "enforce@compose": ["./src/exports/enforce@compose.ts"], "debounce": ["./src/exports/debounce.ts"], "classnames": ["./src/exports/classnames.ts"], "SuiteSerializer": ["./src/exports/SuiteSerializer.ts"], + "schemaTypeEnforcement.examples": [ + "./src/examples/schemaTypeEnforcement.examples.ts" + ], "ErrorStrings": ["./src/errors/ErrorStrings.ts"], "Runtime": ["./src/core/Runtime.ts"], "test": ["./src/core/test/test.ts"], - "test.memo": ["./src/core/test/test.memo.ts"], "TestTypes": ["./src/core/test/TestTypes.ts"], "verifyTestRun": [ "./src/core/test/testLevelFlowControl/verifyTestRun.ts" @@ -84,8 +82,8 @@ "nonMatchingSeverityProfile": [ "./src/core/test/helpers/nonMatchingSeverityProfile.ts" ], - "matchingGroupName": ["./src/core/test/helpers/matchingGroupName.ts"], "matchingFieldName": ["./src/core/test/helpers/matchingFieldName.ts"], + "registerTests": ["./src/core/isolate/registerTests.ts"], "VestReconciler": ["./src/core/isolate/VestReconciler.ts"], "VestIsolateType": ["./src/core/isolate/VestIsolateType.ts"], "VestIsolate": ["./src/core/isolate/VestIsolate.ts"], diff --git a/packages/vestjs-runtime/src/Isolate/Isolate.ts b/packages/vestjs-runtime/src/Isolate/Isolate.ts index 245d182f3..bb4179c22 100644 --- a/packages/vestjs-runtime/src/Isolate/Isolate.ts +++ b/packages/vestjs-runtime/src/Isolate/Isolate.ts @@ -55,9 +55,15 @@ export class Isolate { VestRuntime.useSetNextIsolateChild(nextIsolateChild); } - const output = shouldRunNew - ? useRunAsNew(localHistoryNode, newCreatedNode, callback) - : nextIsolateChild.output; + let output; + + if (shouldRunNew) { + output = useRunAsNew(localHistoryNode, newCreatedNode, callback); + } else { + const emit = useEmit(); + output = nextIsolateChild.output; + emit(RuntimeEvents.ISOLATE_RECONCILED, nextIsolateChild); + } IsolateMutator.saveOutput(nextIsolateChild, output); @@ -138,7 +144,7 @@ function baseIsolate( [IsolateKeys.Keys]: null, [IsolateKeys.Parent]: null, [IsolateKeys.Type]: type, - [IsolateKeys.Data]: data as IsolateData, + [IsolateKeys.Data]: data, ...(status && { [IsolateKeys.Status]: status }), children: null, key, @@ -146,8 +152,7 @@ function baseIsolate( }; } -type IsolateData = Record; -type IsolatePayload = IsolateData & IsolateFeatures; +type IsolatePayload

> = P & IsolateFeatures; type IsolateFeatures = { [IsolateKeys.AllowReorder]?: boolean; [IsolateKeys.Status]?: string; diff --git a/packages/vestjs-runtime/src/IsolateWalker.ts b/packages/vestjs-runtime/src/IsolateWalker.ts index 907fd3440..547ddaa59 100644 --- a/packages/vestjs-runtime/src/IsolateWalker.ts +++ b/packages/vestjs-runtime/src/IsolateWalker.ts @@ -1,4 +1,4 @@ -import { CB, Nullable, isNullish, optionalFunctionValue } from 'vest-utils'; +import { CB, Nullable, isNullish, dynamicValue } from 'vest-utils'; import { type TIsolate } from 'Isolate'; import { IsolateMutator } from 'IsolateMutator'; @@ -38,7 +38,7 @@ export function walk( } // If visitOnly is not provided or the predicate is satisfied, call the callback function. - if (isNullish(visitOnly) || optionalFunctionValue(visitOnly, startNode)) { + if (isNullish(visitOnly) || dynamicValue(visitOnly, startNode)) { callback(startNode, breakout); } @@ -206,14 +206,14 @@ export function pluck( // Returns the closest ancestor Isolate object of the given //startNode that satisfies the given predicate function. -export function closest( +export function closest( startNode: TIsolate, predicate: (node: TIsolate) => boolean, -): Nullable { +): Nullable { let current: Nullable = startNode; do { if (predicate(current)) { - return current; + return current as I; } current = current.parent; } while (current); diff --git a/packages/vestjs-runtime/src/Reconciler.ts b/packages/vestjs-runtime/src/Reconciler.ts index 79d91298e..05c59cf67 100644 --- a/packages/vestjs-runtime/src/Reconciler.ts +++ b/packages/vestjs-runtime/src/Reconciler.ts @@ -4,7 +4,7 @@ import { Nullable, invariant, isNullish, - optionalFunctionValue, + dynamicValue, } from 'vest-utils'; import { type TIsolate } from 'Isolate'; @@ -77,10 +77,7 @@ export class Reconciler { const prevNodeByKey = VestRuntime.useHistoryKey(node.key); let nextNode = node; - if ( - !isNullish(prevNodeByKey) && - !optionalFunctionValue(revoke, prevNodeByKey) - ) { + if (!isNullish(prevNodeByKey) && !dynamicValue(revoke, prevNodeByKey)) { nextNode = prevNodeByKey; } diff --git a/packages/vestjs-runtime/src/RuntimeEvents.ts b/packages/vestjs-runtime/src/RuntimeEvents.ts index 8de49c837..ffc9e14b0 100644 --- a/packages/vestjs-runtime/src/RuntimeEvents.ts +++ b/packages/vestjs-runtime/src/RuntimeEvents.ts @@ -3,4 +3,5 @@ export const RuntimeEvents = { ISOLATE_DONE: 'ISOLATE_DONE', ISOLATE_ENTER: 'ISOLATE_ENTER', ISOLATE_PENDING: 'ISOLATE_PENDING', + ISOLATE_RECONCILED: 'ISOLATE_RECONCILED', }; diff --git a/packages/vestjs-runtime/src/VestRuntime.ts b/packages/vestjs-runtime/src/VestRuntime.ts index bab120672..9079875af 100644 --- a/packages/vestjs-runtime/src/VestRuntime.ts +++ b/packages/vestjs-runtime/src/VestRuntime.ts @@ -7,7 +7,7 @@ import { assign, TinyState, text, - optionalFunctionValue, + dynamicValue, tinyState, BusType, bus, @@ -81,7 +81,7 @@ export function createRef( return Object.freeze({ Bus: bus.createBus(), Reconciler, - appData: optionalFunctionValue(setter), + appData: dynamicValue(setter), historyRoot: tinyState.createTinyState>(null), }); } diff --git a/packages/vestjs-runtime/src/exports/IsolateSerializer.ts b/packages/vestjs-runtime/src/exports/IsolateSerializer.ts index daa211dec..02238926a 100644 --- a/packages/vestjs-runtime/src/exports/IsolateSerializer.ts +++ b/packages/vestjs-runtime/src/exports/IsolateSerializer.ts @@ -14,52 +14,15 @@ import { ExcludedFromDump, IsolateKeys } from 'IsolateKeys'; import { IsolateMutator } from 'IsolateMutator'; export class IsolateSerializer { - // eslint-disable-next-line max-statements, complexity, max-lines-per-function static deserialize(node: Record | TIsolate | string): TIsolate { - // Validate the root object - const root = ( - isStringValue(node) ? JSON.parse(node) : ({ ...node } as TIsolate) - ) as [any, any]; - - const expanded = expandObject(...root); - - IsolateSerializer.validateIsolate(expanded); - + const expanded = expandNode(node); const queue = [expanded]; - // Iterate over the queue until it's empty while (queue.length) { - // Get the next item from the queue const current = queue.shift(); - - if (!current) { - continue; - } - - const children = current.children; - - // If there are no children, nothing to do. - if (!children) { - continue; + if (current) { + processChildren(current, queue); } - - // Copy the children and set their parent to the current node. - current.children = children.map(child => { - const nextChild = { ...child }; - - IsolateMutator.setParent(nextChild, current); - queue.push(nextChild); - - // If the child has a key, add it to the parent's keys. - const key = nextChild.key; - - if (key) { - current.keys = current.keys ?? {}; - current.keys[key] = nextChild; - } - - return nextChild; - }); } return expanded; @@ -92,3 +55,36 @@ export class IsolateSerializer { ); } } + +function processChildren(current: TIsolate, queue: TIsolate[]): void { + const children = current.children; + + if (!children) { + return; + } + + current.children = children.map(child => { + const nextChild = { ...child }; + + IsolateMutator.setParent(nextChild, current); + queue.push(nextChild); + + if (nextChild.key) { + current.keys = current.keys ?? {}; + current.keys[nextChild.key] = nextChild; + } + + return nextChild; + }); +} + +function expandNode(node: Record | TIsolate | string): TIsolate { + const root = ( + isStringValue(node) ? JSON.parse(node) : ({ ...node } as TIsolate) + ) as [any, any]; + + const expanded = expandObject(...root); + IsolateSerializer.validateIsolate(expanded); + + return expanded; +} diff --git a/packages/vestjs-runtime/src/vestjs-runtime.ts b/packages/vestjs-runtime/src/vestjs-runtime.ts index 8a662a69d..5aaba379a 100644 --- a/packages/vestjs-runtime/src/vestjs-runtime.ts +++ b/packages/vestjs-runtime/src/vestjs-runtime.ts @@ -1,3 +1,4 @@ +export { IsolateKeys } from 'IsolateKeys'; export { RuntimeEvents } from 'RuntimeEvents'; export { IsolateKey, TIsolate, Isolate } from 'Isolate'; export { Reconciler, IRecociler } from 'Reconciler'; diff --git a/vx/eslint-plugin-vest-internal/lib/rules/use-use.js b/vx/eslint-plugin-vest-internal/lib/rules/use-use.js index 476693318..5c266a60c 100644 --- a/vx/eslint-plugin-vest-internal/lib/rules/use-use.js +++ b/vx/eslint-plugin-vest-internal/lib/rules/use-use.js @@ -41,6 +41,9 @@ module.exports = { }; function matcher(type) { + if (type === VAR_DEC) { + return `${type}${ID_NAME_MATCHER} > ArrowFunctionExpression ${CALL_EXPRESSION_MATCHER}`; + } return `${type}${ID_NAME_MATCHER} ${CALL_EXPRESSION_MATCHER}`; } @@ -75,5 +78,5 @@ const suggest = "Rename function to start with 'use'"; const message = "Function {{ identifier }} does not start with 'use' but contains a call to function that starts with 'use'"; -const CALL_EXPRESSION_MATCHER = `CallExpression:matches([callee.name=${USE_MATCHER}])`; +const CALL_EXPRESSION_MATCHER = `CallExpression:matches([callee.name=/^use[A-Z]/])`; const ID_NAME_MATCHER = `:not([id.name=${USE_MATCHER}])`; diff --git a/website/docs/api_reference.md b/website/docs/api_reference.md index 3fecc5595..bb958cd42 100644 --- a/website/docs/api_reference.md +++ b/website/docs/api_reference.md @@ -56,9 +56,9 @@ Below is a list of all the API functions exposed by Vest. ## Vest's main export API -- [create](./writing_your_suite/vests_suite.md#basic-suite-structure) - Creates a new Vest suite. Returns a function that runs your validations. +- [create](./writing_your_suite/vests_suite.md#basic-suite-structure) - Creates a new Vest suite. Returns an object with suite helpers and `run`. Accepts an optional enforce schema for TypeScript inference. - - [suite.get](./writing_your_suite/vests_suite.md#using-suiteget) - Returns the current validation state of the suite. + - [suite.get](./writing_your_suite/vests_suite.md#using-suiteget) - Returns the current validation state of the suite, including `types` metadata when a schema is provided. - [suite.remove](./writing_your_suite/vests_suite.md#removing-a-single-field-from-the-suite-state) - Removes a single field from the suite. - [suite.reset](./writing_your_suite/vests_suite.md#cleaning-up-our-validation-state) - Resets the suite to its initial state. - [suite.resetField](./writing_your_suite/vests_suite.md#cleaning-up-our-validation-state) - Resets a single field to an untested state. diff --git a/website/docs/enforce/builtin-enforce-plugins/schema_rules.md b/website/docs/enforce/builtin-enforce-plugins/schema_rules.md index 221a48c47..8bc86931f 100644 --- a/website/docs/enforce/builtin-enforce-plugins/schema_rules.md +++ b/website/docs/enforce/builtin-enforce-plugins/schema_rules.md @@ -22,9 +22,26 @@ These rules will then become available in `enforce`: - [enforce.shape() - Lean schema validation.](#enforceshape---lean-schema-validation) - [enforce.optional() - nullable values](#enforceoptional---nullable-values) - [partial() - allows supplying a subset of keys](#partial---allows-supplying-a-subset-of-keys) - - [enforce.loose() - loose shape matching](#enforceloose---loose-shape-matching) + - [enforce.loose() - loose shape matching](#enforceloose---loose-shape-matching) - [enforce.isArrayOf() - array shape matching](#enforceisarrayof---array-shape-matching) +### Using Schema Rules with Vest Suites + +Schemas created with these rules can be passed to [`create`](../../writing_your_suite/vests_suite.md) for automatic TypeScript inference: + +```ts +import { create, enforce } from 'vest'; + +const suite = create((data) => { + // data is typed as { email: string; age?: number } +}, enforce.partial({ + email: enforce.isString(), + age: enforce.optional(enforce.isNumber()), +})); + +suite.get().types.schema; // => the schema instance provided above +``` + ## enforce.shape() - Lean schema validation. `enforce.shape()` validates the structure of an object. diff --git a/website/docs/get_started.md b/website/docs/get_started.md index 509d627d4..60437e84e 100644 --- a/website/docs/get_started.md +++ b/website/docs/get_started.md @@ -52,7 +52,7 @@ const formData = { password: '', }; -const validationResult = suite(formData); +const validationResult = suite.run(formData); if (validationResult.isValid()) { // Submit the form diff --git a/website/docs/server_side_validations.md b/website/docs/server_side_validations.md index 2a6515e20..46277290d 100644 --- a/website/docs/server_side_validations.md +++ b/website/docs/server_side_validations.md @@ -61,7 +61,7 @@ function serversideCheck(data) { }); }); - suite(); + suite.run(); suite.reset(); } ``` diff --git a/website/docs/typescript_support.md b/website/docs/typescript_support.md index ea4357725..6459a4529 100644 --- a/website/docs/typescript_support.md +++ b/website/docs/typescript_support.md @@ -31,12 +31,34 @@ const suite = create(data => { // ... }); -const res = suite(); +const res = suite.run(); res.getErrors('username'); res.getErrors('full_name'); // 🚨 Throws a compilation error ``` +### Schema-Based Type Inference + +Instead of annotating the callback manually, you can provide a schema created with `enforce.shape`, `enforce.loose`, or `enforce.partial`. Vest will infer the data type from the schema and enforce it throughout the suite: + +```ts +import { create, enforce } from 'vest'; + +const suite = create((data, currentField) => { + // data is typed as { username: string; password: string } +}, enforce.shape({ + username: enforce.isString(), + password: enforce.isString(), +})); + +suite.run({ username: 'vest', password: 'secret' }); +// suite.run({ username: 'vest' }); // 🚨 missing password + +const result = suite.get(); +result.types.schema; // the schema instance passed to `create` +type SuiteData = typeof result.types.data; // { username: string; password: string } +``` + The following methods are typed: - `getError` @@ -108,11 +130,14 @@ Vest exports the following types so you can use them to annotate your functions The immediate output of a suite invocation - `suite()`, including the `done()` function. - `SuiteResult`
- Non-actionable suite result, meaning - the same as SuiteRunResult, but without the `done()` function. The return type of `suite.get()`. + Non-actionable suite result, meaning - the same as SuiteResult, but without the `done()` function. The return type of `suite.get()`. - `SuiteSummary`
The static suite summary, all test results defined in the result object. +- `SuiteSchemaTypes`
+ Metadata exposed on `suite.get().types` when a schema is provided. Includes the schema instance and the inferred data type. + - `IsolateTest`
Rperesents a Vest test. diff --git a/website/docs/upgrade_guide.md b/website/docs/upgrade_guide.md index 1508edad6..019c3f390 100644 --- a/website/docs/upgrade_guide.md +++ b/website/docs/upgrade_guide.md @@ -127,10 +127,14 @@ In previous versions, as a user of Vest you had to set up your own state-reset m - function ServerValidation() { - suite.reset(); -- suite(); +- suite.run(); - } ``` +## Suite names are no longer accepted + +The optional suite name parameter has been removed. Calls such as `create('form', () => {})` should be updated to `create(() => {})`. The `suiteName` property on the result now defaults to `undefined`. + ## First-Class-Citizen typescript support All of Vest's methods are now typed and make use of generics to enforce correct usage throughout your suite. [Read More on TypeScript support](./typescript_support.md). diff --git a/website/docs/utilities/classnames.md b/website/docs/utilities/classnames.md index 746846799..f3a651e45 100644 --- a/website/docs/utilities/classnames.md +++ b/website/docs/utilities/classnames.md @@ -31,7 +31,7 @@ The way it works is simple. You call `classnames` with your result object, and t import classnames from 'vest/classnames'; import suite from './suite'; -const res = suite(data); +const res = suite.run(data); const cn = classnames(res, { untested: 'is-untested', // will only be applied if the provided field did not run yet diff --git a/website/docs/writing_tests/advanced_test_features/grouping_tests.md b/website/docs/writing_tests/advanced_test_features/grouping_tests.md index 623a2fbc6..b9324671d 100644 --- a/website/docs/writing_tests/advanced_test_features/grouping_tests.md +++ b/website/docs/writing_tests/advanced_test_features/grouping_tests.md @@ -22,7 +22,7 @@ There are two ways to use `group()`: ```js import { create, test, group, enforce } from 'vest'; -create('suite_name', data => { +create(data => { group('group_name', () => { test('field_name', 'error_message', () => { enforce(data.field_name).equals('value'); @@ -36,7 +36,7 @@ create('suite_name', data => { ```js import { create, test, group, enforce } from 'vest'; -create('suite_name', data => { +create(data => { group(() => { test('field_name', 'error_message', () => { enforce(data.field_name).equals('value'); diff --git a/website/docs/writing_your_suite/accessing_the_result.md b/website/docs/writing_your_suite/accessing_the_result.md index 7410b0405..01c8ce51c 100644 --- a/website/docs/writing_your_suite/accessing_the_result.md +++ b/website/docs/writing_your_suite/accessing_the_result.md @@ -62,6 +62,23 @@ A result object would look somewhat like this: } ``` +# Schema Metadata + +When your suite is created with an enforce schema, the result also contains a `types` object with runtime references that help with TypeScript inference: + +```ts +const suite = create((data) => { + // ... +}, enforce.shape({ + email: enforce.isString(), + password: enforce.isString(), +})); + +const result = suite.get(); +result.types.schema; // the schema instance passed to `create` +type SuiteData = typeof result.types.data; // { email: string; password: string } +``` + # Suite Result Methods Along with this data, our result object also contains a few other methods that can be used to interact with the data. All these methods can be accessed in the following ways: @@ -73,7 +90,7 @@ Along with this data, our result object also contains a few other methods that c All the following examples are valid and equivalent: ```js -const result = suite(formData); +const result = suite.run(formData); // 1 - Directly via the result object result.hasErrors(); diff --git a/website/docs/writing_your_suite/dirty_checking.md b/website/docs/writing_your_suite/dirty_checking.md index 1347ffc2d..db6686d3f 100644 --- a/website/docs/writing_your_suite/dirty_checking.md +++ b/website/docs/writing_your_suite/dirty_checking.md @@ -31,7 +31,7 @@ Instead of dirty checking, Vest provides the `isTested` method. This method can The following code will only display validation errors for the username field if it has been tested: ```js -const result = suite({ username: '' }); +const result = suite.run({ username: '' }); if (result.isTested('username')) { // Display validation errors for the username field diff --git a/website/docs/writing_your_suite/execution_modes.md b/website/docs/writing_your_suite/execution_modes.md index 8820dfbbb..200b7eaf1 100644 --- a/website/docs/writing_your_suite/execution_modes.md +++ b/website/docs/writing_your_suite/execution_modes.md @@ -44,7 +44,7 @@ To set the mode to `All`, you can use the `mode()` function within the `create() ```js import { create, test, mode, Modes } from 'vest'; -const suite = create('suite_name', () => { +const suite = create(() => { mode(Modes.ALL); // set the mode to All test('field_name', 'error_message_1', () => { @@ -70,7 +70,7 @@ To set the mode to `One`, you can use the `mode()` function within the `create() ```js import { create, test, mode, Modes } from 'vest'; -const suite = create('suite_name', () => { +const suite = create(() => { mode(Modes.ONE); // set the mode to One // 🚨 If this test fails, all next tests will be skipped diff --git a/website/docs/writing_your_suite/optional_fields.md b/website/docs/writing_your_suite/optional_fields.md index a174e9274..c5fb348a7 100644 --- a/website/docs/writing_your_suite/optional_fields.md +++ b/website/docs/writing_your_suite/optional_fields.md @@ -52,8 +52,8 @@ const suite = create((data, currentField) => { }); }); -suite({ name: 'Indie' }, 'pet_name').isValid(); // ✅ Since pet_color and pet_age are optional, the suite may still be valid -suite({ age: 'Five' }, 'pet_age').isValid(); // 🚨 When erroring, optional fields still make the suite invalid +suite.run({ name: 'Indie' }, 'pet_name').isValid(); // ✅ Since pet_color and pet_age are optional, the suite may still be valid +suite.run({ age: 'Five' }, 'pet_age').isValid(); // 🚨 When erroring, optional fields still make the suite invalid ``` ## If the field is empty in the data object @@ -77,7 +77,7 @@ const suite = create(data => { }); }); -const result = suite({ +const result = suite.run({ username: 'John', age: '', // age is empty }); diff --git a/website/docs/writing_your_suite/vests_suite.md b/website/docs/writing_your_suite/vests_suite.md index 9c5b54bd3..765d7d1f8 100644 --- a/website/docs/writing_your_suite/vests_suite.md +++ b/website/docs/writing_your_suite/vests_suite.md @@ -38,6 +38,32 @@ const suite = create((data = {}) => { You pass a callback function to the `create` function, which takes the form data as its first argument, and any other arguments you might want to pass. You can then define your validations inside this function. +### Optional: Add a Schema for Type Inference + +When using TypeScript, you can provide an optional schema created with [`enforce`](../enforce/builtin-enforce-plugins/schema_rules.md) to get full type inference for your suite data: + +```ts +import { create, enforce } from 'vest'; + +const userSchema = enforce.shape({ + email: enforce.isString(), + password: enforce.isString(), +}); + +const suite = create((data, currentField) => { + // `data` is typed as { email: string; password: string } +}, userSchema); +``` + +The inferred type is also exposed at runtime through `suite.get().types`, which contains the schema instance for reference: + +```ts +const result = suite.run(formData); + +result.types?.schema; // === userSchema +type UserData = typeof result.types?.data; // { email: string; password: string } +``` + ## Running the Suite You can run your suite by calling it with the form data and any additional arguments you want to pass: diff --git a/website/versioned_docs/version-4.x/upgrade_guide.md b/website/versioned_docs/version-4.x/upgrade_guide.md index 7ff3a5829..37907fef0 100644 --- a/website/versioned_docs/version-4.x/upgrade_guide.md +++ b/website/versioned_docs/version-4.x/upgrade_guide.md @@ -221,5 +221,5 @@ const suite = data => /* ... */ })(); -const result = suite({ username: 'example' }); +const result = suite.run({ username: 'example' }); ``` diff --git a/website/versioned_docs/version-4.x/utilities/classnames.md b/website/versioned_docs/version-4.x/utilities/classnames.md index 9a5c9d3da..e89c2b429 100644 --- a/website/versioned_docs/version-4.x/utilities/classnames.md +++ b/website/versioned_docs/version-4.x/utilities/classnames.md @@ -31,7 +31,7 @@ The way it works is simple. You call `classnames` with your result object, and t import classnames from 'vest/classnames'; import suite from './suite'; -const res = suite(data); +const res = suite.run(data); const cn = classnames(res, { untested: 'is-untested', // will only be applied if the provided field did not run yet diff --git a/website/versioned_docs/version-4.x/writing_your_suite/optional_fields.md b/website/versioned_docs/version-4.x/writing_your_suite/optional_fields.md index 112e080c1..8d975d0cd 100644 --- a/website/versioned_docs/version-4.x/writing_your_suite/optional_fields.md +++ b/website/versioned_docs/version-4.x/writing_your_suite/optional_fields.md @@ -42,10 +42,12 @@ const suite = create((data, currentField) => { }); }); -suite({ name: 'Indie' }, /* -> only validate pet_name */ 'pet_name').isValid(); +suite + .run({ name: 'Indie' }, /* -> only validate pet_name */ 'pet_name') + .isValid(); // ✅ Since pet_color and pet_age are optional, the suite may still be valid -suite({ age: 'Five' }, /* -> only validate pet_age */ 'pet_age').isValid(); +suite.run({ age: 'Five' }, /* -> only validate pet_age */ 'pet_age').isValid(); // 🚨 When erroring, optional fields still make the suite invalid ```