diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 1944c89..3fd749d 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -1,6 +1,12 @@ name: Flutter analyze and tests on: + push: + branches: + - master + - main + tags-ignore: + - 'v*' pull_request: branches: - '*' @@ -8,22 +14,25 @@ on: - 'v*' jobs: - on-pull-request: + analyze-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: subosito/flutter-action@v1 + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 with: channel: 'stable' + cache: true - - name: Download pub dependencies + - name: Install dependencies run: flutter pub get - name: Check formatting - run: dart format --set-exit-if-changed . + run: dart format --output=none --set-exit-if-changed . - name: Run analyzer - run: flutter analyze + run: flutter analyze --fatal-infos - name: Run tests run: flutter test + diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a35eb..265b4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.0.0 + +* Dart 3 migration and SDK constraint update (`>=3.0.0 <4.0.0`). +* Added fluent assertion chaining. +* Added soft assertions (`assertSoftly`). +* Added assertions for: + * Maps + * Exceptions and asynchronous results + * DateTimes and Durations + * Deep structural equivalency + * Numeric ranges and approximations + * String length, numeric checks, and multi-string matching + * Iterable sorting, counts, and predicates + ## 0.5.1 * Downgraded test package to 1.25.7. diff --git a/README.md b/README.md index fc5087a..56e563d 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,440 @@ -The [Fluent Assertions](https://fluentassertions.com/) library written in Dart. +# fluent_assertions -It uses Dart's [Extension Functions](https://dart.dev/guides/language/extension-methods) to provide a fluent wrapper around test assertions. +[![pub package](https://img.shields.io/badge/pub-v0.5.1-blue.svg)](https://pub.dev/packages/fluent_assertions) +[![license](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -## Usage +A comprehensive, idiomatic Dart port of the popular [Fluent Assertions](https://fluentassertions.com/) library. -#### Basic Assertions +`fluent_assertions` leverages Dart's Extension Methods to turn test assertions into natural, readable sentences. Under the hood, all assertions map directly to the official Dart **`package:test/expect.dart` (Matchers)** API, guaranteeing 100% compatibility with standard Dart and Flutter test runners, IDE test explorers, and CI/CD pipelines. -These assertions are available for all objects. +--- -##### Equality +## Installation -```dart -'Hello'.shouldBeEqualTo('Hello'); -``` -```dart -'Hello'.shouldNotBeEqualTo('Word'); +Add `fluent_assertions` as a dev dependency in your `pubspec.yaml`: + +```yaml +dev_dependencies: + test: ^1.25.0 + fluent_assertions: ^0.5.1 ``` -##### Reference equality +Then import the library in your test files: ```dart -final me = Person(name: 'Karol'); -final someoneElse = Person(name: 'Karol'); -me.shouldBe(me); -me.shouldNotBe(someoneElse); +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; ``` -##### Subtypes +--- -```dart -const num numberOfGirlsIAttract = 0; -numberOfGirlsIAttract.shouldBeInstanceOf(); -numberOfGirlsIAttract.shouldNotBeInstanceOf(); -``` +## Features & Usage Guide -##### Nullability +### 1. Basic Assertions -```dart -const hello = 'Hello'; -hello.shouldNotBeNull(); -``` +Available on all objects (`T?`). ```dart -const hello = null; -hello.shouldBeNull(); -``` +// Equality +'Hello'.shouldBeEqualTo('Hello'); +'Hello'.shouldNotBeEqualTo('World'); -##### Booleans +// Identity (referential equality) +final person = Person(name: 'Karol'); +person.shouldBe(person); +person.shouldNotBe(Person(name: 'Karol')); -```dart -const hasChildren = true; -hasChildren.shouldBeTrue(); -hasChildren.shouldNotBeFalse(); -``` -```dart -const hasChildren = false; -hasChildren.shouldBeFalse(); -hasChildren.shouldNotBeTrue(); -``` +// Nullability & Type Narrowing +final String? maybeName = 'Karol'; +final name = maybeName.shouldNotBeNull(); // Returns non-nullable String +name.shouldHaveLength(5); -#### Numerical Assertions +const String? nullValue = null; +nullValue.shouldBeNull(); -```dart -2.shouldBeGreaterOrEqualTo(2); -``` +// Subtypes +const num score = 10; +score.shouldBeInstanceOf(); +score.shouldNotBeInstanceOf(); -```dart -2.shouldBeGreaterThan(1); -``` +// Runtime Class / Type +1.shouldHaveTheSameClassAs(2); +1.shouldNotHaveTheSameClassAs('1'); -```dart -2.shouldBeLessOrEqualTo(2); -``` +// Membership in collection +2.shouldBeIn([1, 2, 3]); +5.shouldNotBeIn([1, 2, 3]); -```dart -1.shouldBeLessThan(2); -``` +// Booleans +true.shouldBeTrue(); +true.shouldNotBeFalse(); +false.shouldBeFalse(); +false.shouldNotBeTrue(); -```dart -1.shouldBePositive(); -1.shouldNotBeNegative(); +// Character digits +'5'.shouldBeDigit(); +'a'.shouldNotBeDigit(); ``` -```dart -(-1).shouldBeNegative(); -(-1).shouldNotBePositive(); -``` +--- + +### 2. Numerical Assertions + +Available on `num`, `int`, and `double`. ```dart +// Comparisons +10.shouldBeGreaterThan(5); +10.shouldNotBeGreaterThan(15); +10.shouldBeGreaterOrEqualTo(10); +5.shouldBeLessThan(10); +5.shouldNotBeLessThan(2); +5.shouldBeLessOrEqualTo(5); + +// Sign and zero +42.shouldBePositive(); +(-1).shouldBeNegative(); 0.shouldBeZero(); -1.shouldNotBeZero(); -``` +10.shouldNotBeZero(); -```dart -0.999.shouldBeNear(1, delta: 0.01); -``` +// Ranges +15.shouldBeInRange(lowerBound: 10, upperBound: 20); +5.shouldNotBeInRange(lowerBound: 10, upperBound: 20); -```dart -0.shouldNotBeInRange(lowerBound: 1, upperBound: 6); -1.shouldBeInRange(lowerBound: 1, upperBound: 6); +// Close-to / Delta tolerance (NaN parity: NaN is considered near NaN) +3.14159.shouldBeNear(3.14, delta: 0.01); +10.shouldNotBeNear(20, delta: 1); +double.nan.shouldBeNear(double.nan); ``` -#### String Assertions +--- -```dart -'Flutter'.shouldBeEqualToIgnoringCase('FLUTTER'); -'Flutter'.shouldNotBeEqualToIgnoringCase('Xamarin'); -``` +### 3. String Assertions -```dart -'Flutter rules'.shouldStartWith('Flutter'); -'Flutter rules'.shouldNotStartWith('Xamarin'); -``` +Available on `String` and `String?`. ```dart -'Flutter rules'.shouldStartWithIgnoringCase('FLUTTER'); -'Flutter rules'.shouldNotStartWithIgnoringCase('Xamarin'); -``` +// Prefix and Suffix +'Flutter'.shouldStartWith('Flu'); +'Flutter'.shouldStartWithIgnoringCase('flu'); +'Flutter'.shouldNotStartWith('Dart'); -```dart -'I love Flutter'.shouldEndWith('Flutter'); -'I love Flutter'.shouldNotEndWith('Xamarin'); -``` +'Flutter'.shouldEndWith('ter'); +'Flutter'.shouldEndWithIgnoringCase('TER'); +'Flutter'.shouldNotEndWith('app'); -```dart -'I love Flutter'.shouldEndWithIgnoringCase('flutter'); -'I love Flutter'.shouldNotEndWithIgnoringCase('xamarin'); -``` +// Containment +'Hello World'.shouldContain('World'); +'Hello World'.shouldContainIgnoringCase('world'); +'Hello World'.shouldNotContain('Dart'); -```dart -'I love Flutter'.shouldContain('love'); -'I love Flutter'.shouldNotContain('hate'); -``` +// Multi-substring checks +'The quick brown fox'.shouldContainAll(['quick', 'fox']); +'The quick brown fox'.shouldContainAllIgnoringCase(['QUICK', 'FOX']); +'The quick brown fox'.shouldContainAllInOrder(['quick', 'brown', 'fox']); +'The quick brown fox'.shouldContainAny(['dog', 'fox']); +'The quick brown fox'.shouldContainNone(['cat', 'bear']); -```dart -'I love Flutter'.shouldContainIgnoringCase('LOVE'); -'I love Flutter'.shouldNotContainIgnoringCase('HATE'); -``` +// Patterns & Regex +'user123'.shouldMatch(RegExp(r'^[a-z]+[0-9]+$')); +'user123'.shouldNotMatch(RegExp(r'^[0-9]+$')); -```dart -'I love Flutter'.shouldContainAll(['Flutter', 'love']); -'I love Flutter'.shouldContainAllIgnoringCase(['flutter', 'love']); +// Blank & Empty +''.shouldBeEmpty(); +'hello'.shouldNotBeEmpty(); +' '.shouldBeBlank(); +'hello'.shouldNotBeBlank(); + +// Length & Numeric +'hello'.shouldHaveLength(5); +'123.45'.shouldBeNumeric(); +'abc'.shouldNotBeNumeric(); + +// Nullable Strings (String?) +const String? emptyStr = ''; +emptyStr.shouldBeNullOrEmpty(); +'hello'.shouldNotBeNullOrEmpty(); + +const String? blankStr = ' '; +blankStr.shouldBeNullOrBlank(); +'hello'.shouldNotBeNullOrBlank(); ``` +--- + +### 4. Collection & Iterable Assertions + +Available on `Iterable`. + ```dart -'I love Flutter'.shouldContainAllInOrder(['love', 'Flutter']); -'I love Flutter'.shouldContainAllInOrderIgnoringCase(['Love', 'Flutter']); +final list = [1, 2, 3, 4]; + +// Size & Emptiness +list.shouldHaveSize(4); +list.shouldHaveCount(4); // Alias +[42].shouldHaveSingleItem(); +list.shouldNotBeEmpty(); +[].shouldBeEmpty(); + +// Element Containment +list.shouldContain(2); +list.shouldNotContain(99); +list.shouldContainAll([1, 4]); +list.shouldNotContainAll([1, 99]); +list.shouldContainAllInOrder([1, 2, 4]); +list.shouldContainAny([3, 99]); +list.shouldContainNone([88, 99]); + +// Unordered Equality (frequency-counted) +[1, 2, 3].shouldContainSame([3, 1, 2]); +[1, 2, 3].shouldEqualUnordered([3, 1, 2]); // Alias +[1, 2, 3].shouldNotContainSame([1, 2, 4]); + +// Sorting +[1, 2, 3, 4].shouldBeSorted(); +[4, 3, 2, 1].shouldBeSortedAccordingTo((a, b) => b.compareTo(a)); +[1, 3, 2].shouldNotBeSorted(); + +// Predicates +list.shouldContainAnyThat((x) => x > 3); +list.shouldContainSome((x) => x > 3); // Alias +list.shouldContainAllThat((x) => x > 0); +list.shouldMatchAllWith((x) => x > 0); // Alias +list.shouldContainNoneThat((x) => x < 0); + +// Element Type Assertions +final mixed = [1, 2, 3]; +mixed.shouldAllBeInstanceOf(); +mixed.shouldNoneBeInstanceOf(); + +// Case-insensitive String Iterables +['Alice', 'Bob'].shouldContainIgnoringCase('alice'); +['Alice', 'Bob'].shouldContainAllIgnoringCase(['alice', 'BOB']); ``` +--- + +### 5. Map Assertions + +Available on `Map`. + ```dart -'12345'.shouldMatch(r'\d'); -'Hello'.shouldNotMatch(r'\d'); +final user = {'id': 1, 'username': 'karol', 'active': true}; + +// Keys and Values +user.shouldHaveKey('username'); +user.shouldNotHaveKey('password'); +user.shouldHaveValue('karol'); +user.shouldNotHaveValue('admin'); + +// Key-Value Pairs +user.shouldContain('id', 1); +user.shouldNotContain('id', 99); +user.shouldContainEntry(const MapEntry('active', true)); + +// Full Unordered Map Equality +user.shouldContainSame({'username': 'karol', 'id': 1, 'active': true}); +user.shouldNotContainSame({'id': 1}); + +// Size & Emptiness +user.shouldHaveSize(3); +user.shouldNotBeEmpty(); +{}.shouldBeEmpty(); ``` +--- + +### 6. Exception Assertions + +Supports both synchronous and asynchronous functions. + +#### Synchronous Functions + ```dart -''.shouldBeEmpty() -'name'.shouldNotBeEmpty() +// Using invoking helper +invoking(() => throw FormatException('Invalid JSON')) + .shouldThrow() + .withMessage('Invalid JSON') + .withMessageContaining('JSON') + .withMessageMatching(RegExp(r'JSON$')); + +// Asserting no exception +(() => 2 + 2).shouldNotThrow(); +(() => 2 + 2).shouldNotThrowAny(); ``` +#### Exception Causes + ```dart -''.shouldBeNullOrEmpty() -'name'.shouldNotBeNullOrEmpty() +invoking(() => throw CustomException('Network error', SocketException('timeout'))) + .shouldThrow() + .withCause(); ``` +#### Asynchronous Functions & Futures + ```dart -' '.shouldBeBlank() -'name'.shouldNotBeBlank() +// On async functions +Future fetchUser() async { + throw const FormatException('user not found'); +} + +final result = await fetchUser.shouldThrow(); +result.withMessage('user not found'); + +// On Future instances directly +final future = Future.error(ArgumentError('bad id')); +await future.shouldThrow(); ``` +--- + +### 7. Temporal Assertions (`DateTime` & `Duration`) + +Port of Kluent's `java.time` assertions. + ```dart -' '.shouldBeNullOrBlank() -'name'.shouldNotBeNullOrBlank() +final eventTime = DateTime(2025, 6, 15, 10, 30); + +// Relative positioning +eventTime.shouldBeAfter(DateTime(2025, 1, 1)); +eventTime.shouldNotBeAfter(DateTime(2026, 1, 1)); +eventTime.shouldBeBefore(DateTime(2025, 12, 31)); + +// Same moment (handles different timezones properly) +final utc = DateTime.utc(2025, 6, 15, 10, 30); +utc.shouldBeAtSameMomentAs(utc.toLocal()); + +// Ranges +eventTime.shouldBeBetween(DateTime(2025, 1, 1), DateTime(2025, 12, 31)); + +// Calendar breakdown +eventTime.shouldBeInYear(2025); +eventTime.shouldBeInMonth(6); +eventTime.shouldBeOnDay(15); +eventTime.shouldBeOnDate(DateTime(2025, 6, 15)); // Ignores hours/minutes/seconds + +// Tolerance +eventTime.shouldBeNear( + DateTime(2025, 6, 15, 10, 35), + delta: const Duration(minutes: 10), +); + +// Duration Assertions +const timeout = Duration(seconds: 30); +timeout.shouldBeLongerThan(const Duration(seconds: 15)); +timeout.shouldBeShorterThan(const Duration(seconds: 60)); +timeout.shouldBeEqualTo(const Duration(milliseconds: 30000)); +timeout.shouldBePositive(); +timeout.shouldNotBeNegative(); +timeout.shouldNotBeZero(); ``` -#### Iterable Assertions +--- + +### 8. Deep Equivalency Assertions + +Recursively compares complex nested maps, lists, sets, date-times, and objects. ```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldContain('Flutter'); -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainIgnoringCase('flutter'); +final responseA = { + 'status': 200, + 'data': [ + {'id': 1, 'tags': {'a', 'b'}}, + {'id': 2, 'tags': {'c'}}, + ], +}; + +final responseB = { + 'status': 200, + 'data': [ + {'id': 1, 'tags': {'b', 'a'}}, // Sets compared order-insensitively + {'id': 2, 'tags': {'c'}}, + ], +}; + +responseA.shouldBeEquivalentTo(responseB); ``` +--- + +### 9. Soft Assertions (`assertSoftly`) + +`assertSoftly` executes multiple assertions within a block, collecting all failures instead of terminating at the first failure. If any assertions fail, a single consolidated failure report is thrown. + ```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldNotContain('Vue'); -['Flutter', 'React Native', 'Jetpack Compose'].shouldNotContainIgnoringCase('vue'); +assertSoftly(() { + adult.name.shouldBeEqualTo('Karol'); + adult.age.shouldBeGreaterThan(25); + adult.isAdult.shouldBeTrue(); +}); ``` -```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAny(['Flutter', 'Vue']); -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAnyIgnoringCase(['react native']); +If multiple assertions fail: ``` +The following 2 assertions failed: +1) Expected: <50> + Actual: <30> +2) Expected: 'Alice' + Actual: 'Karol' +``` + +For async blocks, use `assertSoftlyAsync`: ```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainNone(['Angular', 'Vue']) -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainNoneIgnoringCase(['angular', 'vue']); +await assertSoftlyAsync(() async { + await fetchDetails(); + data.shouldNotBeNull(); + data.shouldHaveSize(3); +}); ``` +--- + +### 10. Fluid Chaining + +All assertion methods return the receiver (`this`), allowing multiple assertions to be fluently chained in a single statement: + ```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAll(['Jetpack Compose', 'React Native']); -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAllIgnoringCase(['jetpack compose', 'react native']); +42 + .shouldBePositive() + .shouldBeGreaterThan(10) + .shouldBeLessThan(50) + .shouldBeInRange(lowerBound: 40, upperBound: 45) + .shouldNotBeZero(); + +'Flutter' + .shouldNotBeEmpty() + .shouldStartWith('Flu') + .shouldEndWith('ter') + .shouldContain('utt') + .shouldHaveLength(7); + +[1, 2, 3] + .shouldNotBeEmpty() + .shouldHaveSize(3) + .shouldContain(2) + .shouldBeSorted(); ``` -```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAllInOrder(['Flutter', 'React Native']); -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAllInOrderIgnoringCase(['flutter', 'react native']); +--- + +## Running Tests + +To run the complete test suite: + +```bash +dart test ``` -```dart -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAllThat((framework) => framework.startsWith(RegExp('[A-Z]'))); -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainAnyThat((framework) => framework.contains('React')); -['Flutter', 'React Native', 'Jetpack Compose'].shouldContainNoneThat((framework) => framework.isEmpty); +To run the example file: + +```bash +dart test example/fluent_assertions_example.dart ``` -## Features and bugs +--- -Please file feature requests and bugs at the [issue tracker](https://github.com/klisiewicz/fluent-assertions/issues). +## License -## Author -- [Karol Lisiewicz](https://github.com/klisiewicz) +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/analysis_options.yaml b/analysis_options.yaml index 17d6dbf..ea2c9e9 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1 @@ -include: package:lint/analysis_options_package.yaml \ No newline at end of file +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/example/fluent_assertions_example.dart b/example/fluent_assertions_example.dart index 55d1f16..7581005 100644 --- a/example/fluent_assertions_example.dart +++ b/example/fluent_assertions_example.dart @@ -5,54 +5,135 @@ void main() { const adult = Person(name: 'Karol', age: 30); const teenager = Person(name: 'Karyna', age: 17); - test('different persons should not be equal', () { - adult.shouldNotBeEqualTo(teenager); + group('Basic & Nullability', () { + test('equality and identity', () { + adult.shouldNotBeEqualTo(teenager); + adult.shouldBe(adult); + }); + + test('nullability promotion and chaining', () { + final name = adult.name.shouldNotBeNull(); + name.shouldHaveLength(5).shouldStartWith('K').shouldEndWith('l'); + }); + + test('types and booleans', () { + adult.age.shouldBeInstanceOf(); + adult.isAdult.shouldBeTrue(); + teenager.isAdult.shouldBeFalse(); + }); }); - test('person name should not be null', () { - adult.name.shouldNotBeNull(); + group('Numerical Assertions', () { + test('ranges, comparisons, and delta near', () { + teenager.age + .shouldBePositive() + .shouldBeGreaterThan(16) + .shouldBeLessThan(18) + .shouldBeInRange(lowerBound: 0, upperBound: 17); + + 3.14159.shouldBeNear(3.14, delta: 0.01); + double.nan.shouldBeNear(double.nan); + }); }); - test('person age should be integer', () { - adult.age.shouldBeInstanceOf(); + group('String Assertions', () { + test('patterns, blank checks, and numeric verification', () { + adult.name + .shouldNotBeNull() + .shouldNotBeBlank() + .shouldMatch(RegExp(r'^[a-zA-Z]+$')) + .shouldContainIgnoringCase('kar'); + + '42'.shouldBeNumeric(); + 'abc'.shouldNotBeNumeric(); + }); }); - test('a person over 18 should be adult', () { - adult.isAdult.shouldBeTrue(); + group('Collections & Maps', () { + test('iterable assertions and sorting', () { + final scores = [10, 20, 30, 40]; + scores + .shouldNotBeEmpty() + .shouldHaveSize(4) + .shouldContain(20) + .shouldBeSorted() + .shouldContainSame([40, 30, 20, 10]); + + scores.shouldContainSome((score) => score > 35); + }); + + test('map assertions', () { + final user = {'id': 1, 'role': 'admin'}; + user + .shouldHaveKey('role') + .shouldHaveValue('admin') + .shouldContain('id', 1) + .shouldContainSame({'role': 'admin', 'id': 1}); + }); }); - test('an adult person should be over 18', () { - adult.age.shouldBeGreaterOrEqualTo(18); - }); + group('Exceptions', () { + test('synchronous and asynchronous exception assertions', () async { + invoking(() => throw ArgumentError('invalid age')) + .shouldThrow() + .withMessageContaining('invalid age'); - test('a teenager can driver a car', () { - teenager.age.shouldBeGreaterThan(16); - }); + Future asyncTask() async { + await Future.delayed(const Duration(milliseconds: 10)); + throw const FormatException('failed to parse json'); + } - test('a teenager should be below 18', () { - teenager.age.shouldBeLessThan(18); + final result = await asyncTask.shouldThrow(); + result.withMessage('failed to parse json'); + }); }); - test('a teenager should be below 18', () { - teenager.age.shouldBeInRange(lowerBound: 0, upperBound: 17); + group('Temporal (DateTime & Duration)', () { + test('dates and durations', () { + final eventTime = DateTime(2025, 6, 15, 10, 30); + eventTime + .shouldBeInYear(2025) + .shouldBeInMonth(6) + .shouldBeOnDay(15) + .shouldBeAfter(DateTime(2025, 1, 1)); + + const timeout = Duration(seconds: 30); + timeout + .shouldBeLongerThan(const Duration(seconds: 10)) + .shouldBePositive(); + }); }); - test('a person age should be positive', () { - teenager.age.shouldBePositive(); + group('Deep Equivalency', () { + test('deeply compares complex nested structures', () { + final payloadA = { + 'meta': {'page': 1}, + 'items': [1, 2, 3], + }; + final payloadB = { + 'meta': {'page': 1}, + 'items': [1, 2, 3], + }; + + payloadA.shouldBeEquivalentTo(payloadB); + }); }); - test('a person name should not be null or blank', () { - adult.name.shouldNotBeNullOrBlank(); - }); - - test('a person name should contain only letters', () { - adult.name.shouldMatch(RegExp('[a-zA-Z]')); + group('Soft Assertions', () { + test('assertSoftly aggregates checks cleanly', () { + assertSoftly(() { + adult.name.shouldBeEqualTo('Karol'); + adult.age.shouldBeGreaterThan(20); + adult.isAdult.shouldBeTrue(); + }); + }); }); } class Person { - final String name; + final String? name; final int age; + bool get isAdult => age >= 18; const Person({ diff --git a/lib/fluent_assertions.dart b/lib/fluent_assertions.dart index f387b98..70c4de3 100644 --- a/lib/fluent_assertions.dart +++ b/lib/fluent_assertions.dart @@ -1,9 +1,16 @@ -/// The Fluent Assertions library written in Dart. +/// Fluent assertions library for Dart inspired by Kluent. /// -/// It uses Dart's Extension Functions to provide a fluent wrapper around test assertions. -library fluent_assertions; +/// It uses Dart's extension methods to provide a fluent, readable wrapper +/// built on top of the official `package:test/expect.dart` matchers framework. +library; export 'src/basic/basic_assertions.dart'; +export 'src/equivalency/equivalency_assertions.dart'; +export 'src/exceptions/exception_assertions.dart'; +export 'src/exceptions/exception_result.dart'; export 'src/iterable/iterable_assertions.dart'; +export 'src/map/map_assertions.dart'; export 'src/numerical/numerical_assertions.dart'; +export 'src/softly/softly.dart'; export 'src/string/string_assertions.dart'; +export 'src/temporal/temporal_assertions.dart'; diff --git a/lib/src/basic/basic_assertions.dart b/lib/src/basic/basic_assertions.dart index 501adc1..c13df53 100644 --- a/lib/src/basic/basic_assertions.dart +++ b/lib/src/basic/basic_assertions.dart @@ -1,43 +1,157 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; import 'package:test/test.dart'; +/// Extension methods for general assertions on any object or null. extension BaseAssertions on T? { /// Asserts that the value is structurally equal to [expected]. - void shouldBeEqualTo(T expected) => expect(this, equals(expected)); + @Deprecated('Use `shouldBeEqualTo` instead.') + T? shouldEqual(T expected) => shouldBeEqualTo(expected); + + /// Asserts that the value is structurally equal to [expected]. + T? shouldBeEqualTo(T expected) { + checkExpect(this, equals(expected)); + return this; + } + + /// Asserts that the value is structurally different from [expected]. + @Deprecated('Use `shouldNotBeEqualTo` instead.') + T? shouldNotEqual(T expected) => shouldNotBeEqualTo(expected); /// Asserts that the value is structurally different from [expected]. - void shouldNotBeEqualTo(T expected) => expect(this, isNot(equals(expected))); + T? shouldNotBeEqualTo(T expected) { + checkExpect(this, isNot(equals(expected))); + return this; + } /// Asserts that the value is the same instance as [expected], using [identical]. - void shouldBe(T expected) => expect(this, same(expected)); + T? shouldBe(T expected) { + checkExpect(this, same(expected)); + return this; + } /// Asserts that the value is not the same instance as [expected], using [identical]. - void shouldNotBe(T expected) => expect(this, isNot(same(expected))); + T? shouldNotBe(T expected) { + checkExpect(this, isNot(same(expected))); + return this; + } /// Asserts that the value is null. - void shouldBeNull() => expect(this, isNull); + T? shouldBeNull() { + checkExpect(this, isNull); + return this; + } - /// Asserts that the value is non-null. - void shouldNotBeNull() => expect(this, isNotNull); + /// Asserts that the value is non-null and returns the non-nullable value. + T shouldNotBeNull() { + expect( + this, + isNotNull, + reason: 'Expected value to not be null, but was null.', + ); + return this!; + } /// Asserts that the value is of type [E]. /// [E] - Expected type. - void shouldBeInstanceOf() => expect(this, isA()); + T? shouldBeInstanceOf() { + checkExpect(this, isA()); + return this; + } /// Asserts that the value is not of type [E]. /// [E] - Expected type. - void shouldNotBeInstanceOf() => expect(this, isNot(isA())); + T? shouldNotBeInstanceOf() { + checkExpect(this, isNot(isA())); + return this; + } + + /// Asserts that the value has the same runtime class/type as [other]. + T? shouldHaveTheSameClassAs(Object other) { + checkExpect( + this?.runtimeType, + equals(other.runtimeType), + reason: 'Expected $this to have the same class as $other.', + ); + return this; + } + + /// Asserts that the value does not have the same runtime class/type as [other]. + T? shouldNotHaveTheSameClassAs(Object other) { + checkExpect( + this?.runtimeType, + isNot(equals(other.runtimeType)), + reason: 'Expected $this to not have the same class as $other.', + ); + return this; + } + + /// Asserts that this element is present in [container]. + T? shouldBeIn(Iterable container) { + checkExpect( + container, + contains(this), + reason: 'Expected $this to be in $container, but it was not.', + ); + return this; + } + + /// Asserts that this element is not present in [container]. + T? shouldNotBeIn(Iterable container) { + checkExpect( + container, + isNot(contains(this)), + reason: 'Expected $this to not be in $container, but it was.', + ); + return this; + } } +/// Extension methods for boolean assertions. extension BoolAssertions on bool { /// Asserts that the value is true. - void shouldBeTrue() => expect(this, isTrue); + bool shouldBeTrue() { + checkExpect(this, isTrue); + return this; + } - /// Asserts that the value is true. - void shouldNotBeFalse() => expect(this, isNot(isFalse)); + /// Asserts that the value is not false (i.e. is true). + bool shouldNotBeFalse() { + checkExpect(this, isNot(isFalse)); + return this; + } /// Asserts that the value is false. - void shouldBeFalse() => expect(this, isFalse); + bool shouldBeFalse() { + checkExpect(this, isFalse); + return this; + } - /// Asserts that the value is false. - void shouldNotBeTrue() => expect(this, isNot(isTrue)); + /// Asserts that the value is not true (i.e. is false). + bool shouldNotBeTrue() { + checkExpect(this, isNot(isTrue)); + return this; + } +} + +/// Extension methods for checking single character digits. +extension CharDigitAssertions on String { + /// Asserts that the character is a digit ('0'-'9'). + String shouldBeDigit() { + checkExpect( + length == 1 && RegExp(r'^[0-9]$').hasMatch(this), + isTrue, + reason: 'Expected "$this" to be a digit, but it was not.', + ); + return this; + } + + /// Asserts that the character is not a digit. + String shouldNotBeDigit() { + checkExpect( + length == 1 && RegExp(r'^[0-9]$').hasMatch(this), + isFalse, + reason: 'Expected "$this" to not be a digit, but it was.', + ); + return this; + } } diff --git a/lib/src/equivalency/equivalency_assertions.dart b/lib/src/equivalency/equivalency_assertions.dart new file mode 100644 index 0000000..01c5699 --- /dev/null +++ b/lib/src/equivalency/equivalency_assertions.dart @@ -0,0 +1,68 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; +import 'package:test/test.dart'; + +/// Extension methods for deep structural equivalency. +extension EquivalencyAssertions on T { + /// Asserts that [this] is deeply equivalent to [expected]. + T shouldBeEquivalentTo(dynamic expected) { + checkExpect( + _areEquivalent(this, expected), + isTrue, + reason: 'Expected $this to be equivalent to $expected, but was not.', + ); + return this; + } + + /// Asserts that [this] is not deeply equivalent to [expected]. + T shouldNotBeEquivalentTo(dynamic expected) { + checkExpect( + _areEquivalent(this, expected), + isFalse, + reason: 'Expected $this to not be equivalent to $expected, but was.', + ); + return this; + } +} + +bool _areEquivalent(dynamic a, dynamic b) { + if (identical(a, b)) return true; + if (a == null || b == null) return a == b; + + if (a is DateTime && b is DateTime) { + return a.isAtSameMomentAs(b); + } + + if (a is num && b is num) { + if (a.isNaN && b.isNaN) return true; + return a == b; + } + + if (a is Set && b is Set) { + if (a.length != b.length) return false; + for (final itemA in a) { + if (!b.any((itemB) => _areEquivalent(itemA, itemB))) return false; + } + return true; + } + + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key)) return false; + if (!_areEquivalent(entry.value, b[entry.key])) return false; + } + return true; + } + + if (a is Iterable && b is Iterable) { + final aList = a.toList(); + final bList = b.toList(); + if (aList.length != bList.length) return false; + for (var i = 0; i < aList.length; i++) { + if (!_areEquivalent(aList[i], bList[i])) return false; + } + return true; + } + + return a == b; +} diff --git a/lib/src/exceptions/exception_assertions.dart b/lib/src/exceptions/exception_assertions.dart new file mode 100644 index 0000000..33f712c --- /dev/null +++ b/lib/src/exceptions/exception_assertions.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'package:fluent_assertions/src/exceptions/exception_result.dart'; +import 'package:fluent_assertions/src/internal/check_expect.dart'; +import 'package:test/test.dart'; + +/// Helper function to create a fluent assertion block on an action. +/// +/// Example: +/// ```dart +/// invoking(() => doSomething()).shouldThrow(); +/// ``` +R Function() invoking(R Function() action) => action; + +/// Extension on synchronous zero-argument functions for exception assertions. +extension SyncFunctionAssertions on R Function() { + /// Asserts that executing this function throws an exception of type [E]. + /// + /// Returns an [ExceptionResult] which can be used to assert properties of the + /// thrown exception, such as its message or cause. + ExceptionResult shouldThrow() { + try { + this(); + } on E catch (e, st) { + return ExceptionResult(e, st); + } catch (other) { + expect( + other, + isA(), + reason: + 'Expected function to throw $E, but threw ${other.runtimeType} ($other).', + ); + } + expect( + null, + isA(), + reason: 'Expected function to throw $E, but returned normally.', + ); + throw StateError('Unreachable'); + } + + /// Asserts that executing this function does not throw an exception of type [E]. + void shouldNotThrow() { + try { + this(); + } on E catch (e) { + checkExpect( + e, + isNot(isA()), + reason: 'Expected function to not throw $E, but threw $e.', + ); + } catch (_) { + // Different exception thrown, passes. + } + } + + /// Asserts that executing this function completes without throwing any exception. + void shouldNotThrowAny() { + try { + this(); + } catch (e) { + checkExpect( + e, + isNull, + reason: + 'Expected function to not throw any exception, but threw ${e.runtimeType} ($e).', + ); + } + } +} + +/// Extension on asynchronous functions for exception assertions. +extension AsyncFunctionAssertions on Future Function() { + /// Asserts that the asynchronous function throws an exception of type [E]. + Future> shouldThrow() async { + try { + await this(); + } on E catch (e, st) { + return AsyncExceptionResult(e, st); + } catch (other) { + expect( + other, + isA(), + reason: + 'Expected async function to throw $E, but threw ${other.runtimeType} ($other).', + ); + } + expect( + null, + isA(), + reason: 'Expected async function to throw $E, but returned normally.', + ); + throw StateError('Unreachable'); + } + + /// Asserts that the asynchronous function does not throw an exception of type [E]. + Future shouldNotThrow() async { + try { + await this(); + } on E catch (e) { + checkExpect( + e, + isNot(isA()), + reason: 'Expected async function to not throw $E, but threw $e.', + ); + } catch (_) { + // Different exception thrown, passes. + } + } + + /// Asserts that the asynchronous function completes without throwing any exception. + Future shouldNotThrowAny() async { + try { + await this(); + } catch (e) { + checkExpect( + e, + isNull, + reason: + 'Expected async function to not throw any exception, but threw ${e.runtimeType} ($e).', + ); + } + } +} + +/// Extension on [Future] values for exception assertions. +extension FutureExtensionAssertions on Future { + /// Asserts that this [Future] completes with an exception of type [E]. + Future> shouldThrow() => + (() => this).shouldThrow(); + + /// Asserts that this [Future] does not complete with an exception of type [E]. + Future shouldNotThrow() => + (() => this).shouldNotThrow(); + + /// Asserts that this [Future] completes without throwing any exception. + Future shouldNotThrowAny() => (() => this).shouldNotThrowAny(); +} diff --git a/lib/src/exceptions/exception_result.dart b/lib/src/exceptions/exception_result.dart new file mode 100644 index 0000000..1684287 --- /dev/null +++ b/lib/src/exceptions/exception_result.dart @@ -0,0 +1,147 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; +import 'package:test/test.dart'; + +/// Holds the caught exception of type [E] and provides fluent assertions +/// for its message and cause. +class ExceptionResult { + /// The caught exception. + final E exception; + + /// The stack trace captured when the exception was thrown. + final StackTrace stackTrace; + + /// Creates a new [ExceptionResult] with the caught [exception] and [stackTrace]. + ExceptionResult(this.exception, this.stackTrace); + + /// Asserts that the exception's message equals [expectedMessage]. + ExceptionResult withMessage(String expectedMessage) { + final msg = _extractMessage(exception); + checkExpect( + msg, + equals(expectedMessage), + reason: 'Expected exception message "$expectedMessage", but was "$msg".', + ); + return this; + } + + /// Asserts that the exception's message matches [pattern]. + ExceptionResult withMessageMatching(Pattern pattern) { + final msg = _extractMessage(exception); + checkExpect( + msg, + matches(pattern), + reason: 'Expected exception message to match $pattern, but was "$msg".', + ); + return this; + } + + /// Asserts that the exception's message contains [substring]. + ExceptionResult withMessageContaining(String substring) { + final msg = _extractMessage(exception); + checkExpect( + msg.contains(substring), + isTrue, + reason: + 'Expected exception message to contain "$substring", but was "$msg".', + ); + return this; + } + + /// Asserts that the exception has a cause of type [C]. + ExceptionResult withCause() { + final cause = _extractCause(exception); + checkExpect( + cause, + isA(), + reason: + 'Expected exception cause to be of type $C, but was ${cause?.runtimeType} ($cause).', + ); + return this; + } + + /// Asserts that the exception's cause matches the given [predicate]. + ExceptionResult withCauseMatching(bool Function(Object? cause) predicate) { + final cause = _extractCause(exception); + checkExpect( + predicate(cause), + isTrue, + reason: 'Expected exception cause to match predicate, but was $cause.', + ); + return this; + } +} + +/// Async counterpart to [ExceptionResult] for asynchronous assertions. +class AsyncExceptionResult { + /// The caught exception. + final E exception; + + /// The stack trace captured when the exception was thrown. + final StackTrace stackTrace; + + /// Creates a new [AsyncExceptionResult]. + AsyncExceptionResult(this.exception, this.stackTrace); + + /// Asserts that the exception's message equals [expectedMessage]. + AsyncExceptionResult withMessage(String expectedMessage) { + final msg = _extractMessage(exception); + checkExpect( + msg, + equals(expectedMessage), + reason: 'Expected exception message "$expectedMessage", but was "$msg".', + ); + return this; + } + + /// Asserts that the exception's message matches [pattern]. + AsyncExceptionResult withMessageMatching(Pattern pattern) { + final msg = _extractMessage(exception); + checkExpect( + msg, + matches(pattern), + reason: 'Expected exception message to match $pattern, but was "$msg".', + ); + return this; + } + + /// Asserts that the exception's message contains [substring]. + AsyncExceptionResult withMessageContaining(String substring) { + final msg = _extractMessage(exception); + checkExpect( + msg.contains(substring), + isTrue, + reason: + 'Expected exception message to contain "$substring", but was "$msg".', + ); + return this; + } + + /// Asserts that the exception has a cause of type [C]. + AsyncExceptionResult withCause() { + final cause = _extractCause(exception); + checkExpect( + cause, + isA(), + reason: + 'Expected exception cause to be of type $C, but was ${cause?.runtimeType} ($cause).', + ); + return this; + } +} + +String _extractMessage(Object exception) { + try { + final dynamic dyn = exception; + final dynamic msg = dyn.message; + if (msg != null) return msg.toString(); + } catch (_) {} + return exception.toString(); +} + +Object? _extractCause(Object exception) { + try { + final dynamic dyn = exception; + return dyn.cause ?? dyn.inner ?? dyn.error; + } catch (_) {} + return null; +} diff --git a/lib/src/internal/check_expect.dart b/lib/src/internal/check_expect.dart new file mode 100644 index 0000000..8cd263c --- /dev/null +++ b/lib/src/internal/check_expect.dart @@ -0,0 +1,31 @@ +import 'dart:async'; +import 'package:test/test.dart'; + +/// Context to accumulate failures when running inside an [assertSoftly] block. +class SoftAssertionContext { + final List failures = []; +} + +/// Zone key used to retrieve the active [SoftAssertionContext]. +const Symbol softAssertionZoneKey = #_fluentSoftAssertions; + +/// Delegates assertion verification to [expect], intercepting [TestFailure] +/// if executed within a soft assertion zone. +void checkExpect( + dynamic actual, + dynamic matcher, { + String? reason, + dynamic skip, +}) { + final softContext = + Zone.current[softAssertionZoneKey] as SoftAssertionContext?; + if (softContext != null) { + try { + expect(actual, matcher, reason: reason, skip: skip); + } on TestFailure catch (failure) { + softContext.failures.add(failure); + } + } else { + expect(actual, matcher, reason: reason, skip: skip); + } +} diff --git a/lib/src/iterable/iterable_assertions.dart b/lib/src/iterable/iterable_assertions.dart index 44ca950..d386152 100644 --- a/lib/src/iterable/iterable_assertions.dart +++ b/lib/src/iterable/iterable_assertions.dart @@ -1,231 +1,371 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; import 'package:fluent_assertions/src/iterable/iterable_matchers.dart'; import 'package:test/test.dart'; +bool _haveSameElements(Iterable a, Iterable b) { + if (a.length != b.length) return false; + final bList = b.toList(); + for (final item in a) { + final index = bList.indexOf(item); + if (index == -1) return false; + bList.removeAt(index); + } + return bList.isEmpty; +} + +/// Extension methods on [Iterable] for type-checking elements. extension IterableObjectAssertions on Iterable { /// Asserts that every value in [Iterable] is of type [E]. - /// [E] - Expected type. - void shouldAllBeInstanceOf() { - expect( + Iterable shouldAllBeInstanceOf() { + checkExpect( this, everyElement((e) => e is E), reason: 'Expected all elements to be instances of $E, but some were not.', ); + return this; } - /// Asserts that any value in [Iterable] is of type [E]. - /// [E] - Expected type. - void shouldAnyBeInstanceOf() { - expect( + /// Asserts that at least one value in [Iterable] is of type [E]. + Iterable shouldAnyBeInstanceOf() { + checkExpect( this, anyElement((e) => e is E), reason: 'Expected at least one element to be an instance of $E, but none were.', ); + return this; } - /// Asserts that every value in [Iterable] is not of type [E]. - /// [E] - Expected type. - void shouldNoneBeInstanceOf() { - expect( + /// Asserts that no value in [Iterable] is of type [E]. + Iterable shouldNoneBeInstanceOf() { + checkExpect( this, everyElement((e) => e is! E), reason: 'Expected no elements to be instances of $E, but some were.', ); + return this; } } +/// General assertion methods for [Iterable]. extension IterableAssertions on Iterable { - /// Asserts that [Iterable] has [expectedSize] elements - void shouldHaveSize(int expectedSize) { - expect( + /// Asserts that [Iterable] has [expectedSize] elements. + Iterable shouldHaveSize(int expectedSize) { + checkExpect( this, hasLength(expectedSize), reason: 'Expected iterable to have $expectedSize elements, but found $length.', ); + return this; } + /// Alias for [shouldHaveSize]. + Iterable shouldHaveCount(int expectedCount) => + shouldHaveSize(expectedCount); + /// Asserts that [Iterable] is empty. - void shouldBeEmpty() { - expect( + Iterable shouldBeEmpty() { + checkExpect( this, isEmpty, reason: 'Expected iterable to be empty, but it was not.', ); + return this; } /// Asserts that [Iterable] is not empty. - void shouldNotBeEmpty() { - expect( + Iterable shouldNotBeEmpty() { + checkExpect( this, isNot(isEmpty), reason: 'Expected iterable to not be empty, but it was.', ); + return this; } - /// Asserts that [Iterable] has 1 element. - void shouldHaveSingleItem() { - expect( + /// Asserts that [Iterable] has exactly 1 element. + Iterable shouldHaveSingleItem() { + checkExpect( this, hasLength(1), reason: 'Expected iterable to have exactly one item, but has $length.', ); + return this; } /// Asserts that [Iterable] contains [expected]. - void shouldContain(T? expected) { - expect( + Iterable shouldContain(T? expected) { + checkExpect( this, contains(expected), reason: 'Expected iterable to contain $expected, but it did not.', ); + return this; } /// Asserts that [Iterable] does not contain [expected]. - void shouldNotContain(T? expected) { - expect( + Iterable shouldNotContain(T? expected) { + checkExpect( this, isNot(contains(expected)), reason: 'Expected iterable to not contain $expected, but it did.', ); + return this; } - /// Asserts that [Iterable] contains any of [expected]. - void shouldContainAny(Iterable expected) { - expect( + /// Asserts that [Iterable] contains any element from [expected]. + Iterable shouldContainAny(Iterable expected) { + checkExpect( this, containsAny(expected.map(equals).toList()), reason: 'Expected at least one element from $expected to be present in $this.', ); + return this; } - /// Asserts that [Iterable] contains none of [expected]. - void shouldContainNone(Iterable expected) { - expect( + /// Asserts that [Iterable] contains none of the elements in [expected]. + Iterable shouldContainNone(Iterable expected) { + checkExpect( this, isNot(containsAny(expected.map(equals).toList())), reason: 'Expected iterable to contain none of $expected, but some were found.', ); + return this; } - /// Asserts that [Iterable] contains an element matching every value in - /// [expected] in any order, and may contain additional values. - void shouldContainAll(Iterable expected) { - expect( + /// Asserts that [Iterable] contains every element in [expected], in any order. + Iterable shouldContainAll(Iterable expected) { + checkExpect( this, containsAll(expected), reason: 'Expected iterable to contain all of $expected, but some were missing.', ); + return this; + } + + /// Asserts that [Iterable] does not contain all elements in [expected]. + Iterable shouldNotContainAll(Iterable expected) { + checkExpect( + this, + isNot(containsAll(expected)), + reason: 'Expected iterable to not contain all of $expected, but it did.', + ); + return this; + } + + /// Asserts that [Iterable] contains the same elements with the same frequencies + /// as [expected], regardless of order. + Iterable shouldContainSame(Iterable expected) { + checkExpect( + _haveSameElements(this, expected), + isTrue, + reason: + 'Expected iterable to contain the same elements as $expected (regardless of order), but was $this.', + ); + return this; + } + + /// Alias for [shouldContainSame]. + Iterable shouldEqualUnordered(Iterable expected) => + shouldContainSame(expected); + + /// Asserts that [Iterable] does not contain the same elements as [expected]. + Iterable shouldNotContainSame(Iterable expected) { + checkExpect( + _haveSameElements(this, expected), + isFalse, + reason: + 'Expected iterable to not contain the same elements as $expected, but it did.', + ); + return this; } + /// Alias for [shouldNotContainSame]. + Iterable shouldNotEqualUnordered(Iterable expected) => + shouldNotContainSame(expected); + /// Asserts that [Iterable] contains an element matching every value in /// [expected] in the same order, but may contain additional values /// interleaved throughout. - void shouldContainAllInOrder(Iterable expected) { - expect( + Iterable shouldContainAllInOrder(Iterable expected) { + checkExpect( this, containsAllInOrder(expected), reason: 'Expected iterable to contain all of $expected in order, but it did not.', ); + return this; } - /// Asserts that [Iterable] contains an element matching the given [predicate]. - void shouldContainAnyThat(bool Function(T argument) predicate) { - expect( + /// Asserts that [Iterable] contains at least one element matching [predicate]. + Iterable shouldContainAnyThat(bool Function(T argument) predicate) { + checkExpect( this, anyElement(predicate), reason: 'Expected iterable to contain at least one element matching the predicate, but none did.', ); + return this; } + /// Alias for [shouldContainAnyThat]. + Iterable shouldContainSome(bool Function(T argument) predicate) => + shouldContainAnyThat(predicate); + + /// Alias for [shouldContainAnyThat]. + Iterable shouldMatchAtLeastOneOf(bool Function(T argument) predicate) => + shouldContainAnyThat(predicate); + /// Asserts that all [Iterable] elements match [predicate]. - void shouldContainAllThat(bool Function(T argument) predicate) { - expect( + Iterable shouldContainAllThat(bool Function(T argument) predicate) { + checkExpect( this, everyElement(predicate), reason: 'Expected every element in iterable to match the predicate, but some did not.', ); + return this; } - /// Asserts that none [Iterable] elements match [predicate]. - void shouldContainNoneThat(bool Function(T argument) predicate) { - expect( + /// Alias for [shouldContainAllThat]. + Iterable shouldMatchAllWith(bool Function(T argument) predicate) => + shouldContainAllThat(predicate); + + /// Asserts that no [Iterable] elements match [predicate]. + Iterable shouldContainNoneThat(bool Function(T argument) predicate) { + checkExpect( this, everyElement(isNot(predicate)), reason: 'Expected no elements in iterable to match the predicate, but some did.', ); + return this; + } + + /// Asserts that the iterable is sorted in ascending order according to [comparator], + /// or natural [Comparable] order if no comparator is provided. + Iterable shouldBeSorted([Comparator? comparator]) { + final list = toList(); + var isSorted = true; + for (var i = 0; i < list.length - 1; i++) { + final a = list[i]; + final b = list[i + 1]; + final comp = comparator != null + ? comparator(a, b) + : (a as Comparable).compareTo(b); + if (comp > 0) { + isSorted = false; + break; + } + } + checkExpect( + isSorted, + isTrue, + reason: 'Expected iterable to be sorted, but it was not: $this', + ); + return this; + } + + /// Asserts that the iterable is sorted according to [comparator]. + Iterable shouldBeSortedAccordingTo(Comparator comparator) => + shouldBeSorted(comparator); + + /// Asserts that the iterable is not sorted in ascending order. + Iterable shouldNotBeSorted([Comparator? comparator]) { + final list = toList(); + var isSorted = true; + for (var i = 0; i < list.length - 1; i++) { + final a = list[i]; + final b = list[i + 1]; + final comp = comparator != null + ? comparator(a, b) + : (a as Comparable).compareTo(b); + if (comp > 0) { + isSorted = false; + break; + } + } + checkExpect( + isSorted, + isFalse, + reason: 'Expected iterable to not be sorted, but it was: $this', + ); + return this; } } +/// Extension methods for [Iterable] case-insensitive assertions. extension IterableStringAssertions on Iterable { /// Asserts that [Iterable] contains [expected] case-insensitively. - void shouldContainIgnoringCase(String expected) { - expect( + Iterable shouldContainIgnoringCase(String expected) { + checkExpect( this, containsAll([equalsIgnoringCase(expected)]), reason: 'Expected iterable to contain "$expected" ignoring case, but it did not.', ); + return this; } - /// Asserts that [Iterable] contains [expected] case-insensitively. - void shouldNotContainIgnoringCase(String expected) { - expect( + /// Asserts that [Iterable] does not contain [expected] case-insensitively. + Iterable shouldNotContainIgnoringCase(String expected) { + checkExpect( this, isNot(containsAll([equalsIgnoringCase(expected)])), reason: 'Expected iterable to not contain "$expected" ignoring case, but it did.', ); + return this; } /// Asserts that [Iterable] contains any of [expected] case-insensitively. - void shouldContainAnyIgnoringCase(Iterable expected) { - expect( + Iterable shouldContainAnyIgnoringCase(Iterable expected) { + checkExpect( this, containsAny(expected.map((e) => equalsIgnoringCase(e)).toList()), reason: 'Expected iterable to contain any of $expected ignoring case, but none were found.', ); + return this; } /// Asserts that [Iterable] contains none of [expected] case-insensitively. - void shouldContainNoneIgnoringCase(Iterable expected) { - expect( + Iterable shouldContainNoneIgnoringCase(Iterable expected) { + checkExpect( this, isNot(containsAny(expected.map((e) => equalsIgnoringCase(e)).toList())), reason: 'Expected iterable to contain none of $expected ignoring case, but some were found.', ); + return this; } /// Asserts that [Iterable] contains an element matching every value in - /// [expected] in any order, case-insensitively, and may contain additional - /// values. - void shouldContainAllIgnoringCase(Iterable expected) { - expect( + /// [expected] in any order, case-insensitively. + Iterable shouldContainAllIgnoringCase(Iterable expected) { + checkExpect( this, containsAll(expected.map((e) => equalsIgnoringCase(e))), reason: 'Expected iterable to contain all of $expected ignoring case, but some were missing.', ); + return this; } /// Asserts that [Iterable] contains an element matching every value in - /// [expected] in the same order and case-insensitively but may contain - /// additional values interleaved throughout. - void shouldContainAllInOrderIgnoringCase(Iterable expected) { - expect( + /// [expected] in the same order and case-insensitively. + Iterable shouldContainAllInOrderIgnoringCase( + Iterable expected) { + checkExpect( this, containsAllInOrder(expected.map((e) => equalsIgnoringCase(e))), reason: 'Expected iterable to contain all of $expected in order ignoring case, but it did not.', ); + return this; } } diff --git a/lib/src/map/map_assertions.dart b/lib/src/map/map_assertions.dart new file mode 100644 index 0000000..fe0080f --- /dev/null +++ b/lib/src/map/map_assertions.dart @@ -0,0 +1,147 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; +import 'package:test/test.dart'; + +/// Extension methods for assertions on [Map]. +extension MapAssertions on Map { + /// Asserts that the map contains the specified [key]. + Map shouldHaveKey(K key) { + checkExpect( + containsKey(key), + isTrue, + reason: 'Expected map to contain key $key, but keys were $keys.', + ); + return this; + } + + /// Asserts that the map does not contain the specified [key]. + Map shouldNotHaveKey(K key) { + checkExpect( + containsKey(key), + isFalse, + reason: 'Expected map to not contain key $key, but it did.', + ); + return this; + } + + /// Asserts that the map contains the specified [value]. + Map shouldHaveValue(V value) { + checkExpect( + this.containsValue(value), + isTrue, + reason: 'Expected map to contain value $value, but values were $values.', + ); + return this; + } + + /// Asserts that the map does not contain the specified [value]. + Map shouldNotHaveValue(V value) { + checkExpect( + this.containsValue(value), + isFalse, + reason: 'Expected map to not contain value $value, but it did.', + ); + return this; + } + + /// Asserts that the map contains the key-value pair ([key], [value]). + Map shouldContain(K key, V value) { + checkExpect( + containsKey(key) && this[key] == value, + isTrue, + reason: + 'Expected map to contain entry ($key: $value), but was ${containsKey(key) ? "($key: ${this[key]})" : "missing key"}', + ); + return this; + } + + /// Asserts that the map does not contain the key-value pair ([key], [value]). + Map shouldNotContain(K key, V value) { + checkExpect( + containsKey(key) && this[key] == value, + isFalse, + reason: 'Expected map to not contain entry ($key: $value), but it did.', + ); + return this; + } + + /// Asserts that the map contains [entry]. + Map shouldContainEntry(MapEntry entry) => + shouldContain(entry.key, entry.value); + + /// Asserts that the map does not contain [entry]. + Map shouldNotContainEntry(MapEntry entry) => + shouldNotContain(entry.key, entry.value); + + /// Asserts that the map contains the exact same key-value pairs as [expected]. + Map shouldContainSame(Map expected) { + var same = length == expected.length; + if (same) { + for (final entry in expected.entries) { + if (!containsKey(entry.key) || this[entry.key] != entry.value) { + same = false; + break; + } + } + } + checkExpect( + same, + isTrue, + reason: + 'Expected map to contain the same entries as $expected, but was $this.', + ); + return this; + } + + /// Asserts that the map does not contain the exact same key-value pairs as [expected]. + Map shouldNotContainSame(Map expected) { + var same = length == expected.length; + if (same) { + for (final entry in expected.entries) { + if (!containsKey(entry.key) || this[entry.key] != entry.value) { + same = false; + break; + } + } + } + checkExpect( + same, + isFalse, + reason: + 'Expected map to not contain the same entries as $expected, but it did.', + ); + return this; + } + + /// Asserts that the map has [expectedSize] entries. + Map shouldHaveSize(int expectedSize) { + checkExpect( + length, + equals(expectedSize), + reason: 'Expected map to have $expectedSize entries, but had $length.', + ); + return this; + } + + /// Alias for [shouldHaveSize]. + Map shouldHaveCount(int expectedCount) => shouldHaveSize(expectedCount); + + /// Asserts that the map is empty. + Map shouldBeEmpty() { + checkExpect( + this.isEmpty, + isTrue, + reason: 'Expected map to be empty, but was not.', + ); + return this; + } + + /// Asserts that the map is not empty. + Map shouldNotBeEmpty() { + checkExpect( + this.isNotEmpty, + isTrue, + reason: 'Expected map to not be empty, but was empty.', + ); + return this; + } +} diff --git a/lib/src/numerical/numerical_assertions.dart b/lib/src/numerical/numerical_assertions.dart index 6932772..e60d004 100644 --- a/lib/src/numerical/numerical_assertions.dart +++ b/lib/src/numerical/numerical_assertions.dart @@ -1,67 +1,144 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; import 'package:test/test.dart'; -extension NumericalAssertions on num { +/// Extension methods for numerical assertions on [num], [int], and [double]. +extension NumericalAssertions on T { /// Asserts that the value is greater or equal to [expected]. - void shouldBeGreaterOrEqualTo(num expected) => - expect(this, greaterThanOrEqualTo(expected)); + T shouldBeGreaterOrEqualTo(num expected) { + checkExpect(this, greaterThanOrEqualTo(expected)); + return this; + } /// Asserts that the value is not greater or equal to [expected]. - void shouldNotBeGreaterOrEqualTo(num expected) => shouldBeLessThan(expected); + T shouldNotBeGreaterOrEqualTo(num expected) { + shouldBeLessThan(expected); + return this; + } /// Asserts that the value is greater than [expected]. - void shouldBeGreaterThan(num expected) => expect(this, greaterThan(expected)); + T shouldBeGreaterThan(num expected) { + checkExpect(this, greaterThan(expected)); + return this; + } /// Asserts that the value is not greater than [expected]. - void shouldNotBeGreaterThan(num expected) => shouldBeLessOrEqualTo(expected); + T shouldNotBeGreaterThan(num expected) { + shouldBeLessOrEqualTo(expected); + return this; + } /// Asserts that the value is less or equal to [expected]. - void shouldBeLessOrEqualTo(num expected) => - expect(this, lessThanOrEqualTo(expected)); + T shouldBeLessOrEqualTo(num expected) { + checkExpect(this, lessThanOrEqualTo(expected)); + return this; + } /// Asserts that the value is not less or equal to [expected]. - void shouldNotBeLessOrEqualTo(num expected) => shouldBeGreaterThan(expected); + T shouldNotBeLessOrEqualTo(num expected) { + shouldBeGreaterThan(expected); + return this; + } /// Asserts that the value is less than [expected]. - void shouldBeLessThan(num expected) => expect(this, lessThan(expected)); + T shouldBeLessThan(num expected) { + checkExpect(this, lessThan(expected)); + return this; + } /// Asserts that the value is not less than [expected]. - void shouldNotBeLessThan(num expected) => shouldBeGreaterOrEqualTo(expected); + T shouldNotBeLessThan(num expected) { + shouldBeGreaterOrEqualTo(expected); + return this; + } /// Asserts that the value is greater than zero. - void shouldBePositive() => expect(this, isPositive); + T shouldBePositive() { + checkExpect(this, isPositive); + return this; + } - /// Asserts that the value is less or equal zero. - void shouldNotBePositive() => expect(this, isNonPositive); + /// Asserts that the value is less or equal to zero. + T shouldNotBePositive() { + checkExpect(this, isNonPositive); + return this; + } /// Asserts that the value is less than zero. - void shouldBeNegative() => expect(this, isNegative); + T shouldBeNegative() { + checkExpect(this, isNegative); + return this; + } - /// Asserts that the value is greater or equal zero. - void shouldNotBeNegative() => expect(this, isNonNegative); + /// Asserts that the value is greater or equal to zero. + T shouldNotBeNegative() { + checkExpect(this, isNonNegative); + return this; + } /// Asserts that the value is zero. - void shouldBeZero() => expect(this, isZero); + T shouldBeZero() { + checkExpect(this, isZero); + return this; + } /// Asserts that the value is not zero. - void shouldNotBeZero() => expect(this, isNonZero); - - /// Asserts that the value is within [delta] of some [expected] value. - void shouldBeNear(num expected, {num delta = 0}) => - expect(this, closeTo(expected, delta)); + T shouldNotBeZero() { + checkExpect(this, isNonZero); + return this; + } + + /// Asserts that the value is within [delta] of [expected]. + /// + /// Conforming with Kluent parity, [double.nan] is considered near itself. + T shouldBeNear(num expected, {num delta = 0}) { + if (this.isNaN && expected.isNaN) { + return this; + } + if (this.isNaN || expected.isNaN) { + checkExpect( + false, + isTrue, + reason: 'Expected $this to be near $expected +/- $delta.', + ); + return this; + } + checkExpect(this, closeTo(expected, delta)); + return this; + } + + /// Asserts that the value is not within [delta] of [expected]. + T shouldNotBeNear(num expected, {num delta = 0}) { + if (this.isNaN && expected.isNaN) { + checkExpect( + false, + isTrue, + reason: 'Expected NaN not to be near NaN +/- $delta.', + ); + return this; + } + if (this.isNaN || expected.isNaN) { + return this; + } + checkExpect(this, isNot(closeTo(expected, delta))); + return this; + } /// Asserts that the value is greater than or equal to [lowerBound] /// and less than or equal to [upperBound]. - void shouldBeInRange({ + T shouldBeInRange({ num lowerBound = double.negativeInfinity, num upperBound = double.infinity, - }) => - expect(this, inInclusiveRange(lowerBound, upperBound)); + }) { + checkExpect(this, inInclusiveRange(lowerBound, upperBound)); + return this; + } - /// Asserts that the value is greater than [upperBound] or - /// less than [lowerBound]. - void shouldNotBeInRange({ + /// Asserts that the value is outside the range [[lowerBound], [upperBound]]. + T shouldNotBeInRange({ num lowerBound = double.negativeInfinity, num upperBound = double.infinity, - }) => - expect(this, isNot(inInclusiveRange(lowerBound, upperBound))); + }) { + checkExpect(this, isNot(inInclusiveRange(lowerBound, upperBound))); + return this; + } } diff --git a/lib/src/softly/softly.dart b/lib/src/softly/softly.dart new file mode 100644 index 0000000..5f9e7dd --- /dev/null +++ b/lib/src/softly/softly.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'package:fluent_assertions/src/internal/check_expect.dart'; +import 'package:test/test.dart'; + +/// Runs a synchronous block of assertions softly. +/// +/// Any assertion failures occurring during [action] are collected rather than +/// terminating execution immediately. Once [action] completes, if any assertions +/// failed, a consolidated [TestFailure] listing all failures is thrown. +R assertSoftly(R Function() action) { + final context = SoftAssertionContext(); + final result = runZoned( + () { + try { + return action(); + } catch (e) { + if (e is TestFailure) { + context.failures.add(e); + return null as R; + } + rethrow; + } + }, + zoneValues: {softAssertionZoneKey: context}, + ); + + if (context.failures.isNotEmpty) { + if (context.failures.length == 1) { + throw context.failures.first; + } + final buffer = StringBuffer(); + buffer.writeln( + 'The following ${context.failures.length} assertions failed:', + ); + for (var i = 0; i < context.failures.length; i++) { + buffer.writeln('${i + 1}) ${context.failures[i].message}'); + } + throw TestFailure(buffer.toString().trimRight()); + } + + return result; +} + +/// Runs an asynchronous block of assertions softly. +/// +/// Any assertion failures occurring during [action] are collected rather than +/// terminating execution immediately. Once [action] completes, if any assertions +/// failed, a consolidated [TestFailure] listing all failures is thrown. +Future assertSoftlyAsync(Future Function() action) async { + final context = SoftAssertionContext(); + final result = await runZoned( + () async { + try { + return await action(); + } catch (e) { + if (e is TestFailure) { + context.failures.add(e); + return null as R; + } + rethrow; + } + }, + zoneValues: {softAssertionZoneKey: context}, + ); + + if (context.failures.isNotEmpty) { + if (context.failures.length == 1) { + throw context.failures.first; + } + final buffer = StringBuffer(); + buffer.writeln( + 'The following ${context.failures.length} assertions failed:', + ); + for (var i = 0; i < context.failures.length; i++) { + buffer.writeln('${i + 1}) ${context.failures[i].message}'); + } + throw TestFailure(buffer.toString().trimRight()); + } + + return result; +} diff --git a/lib/src/string/string_assertions.dart b/lib/src/string/string_assertions.dart index 83ee82b..5512a4e 100644 --- a/lib/src/string/string_assertions.dart +++ b/lib/src/string/string_assertions.dart @@ -1,261 +1,392 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; import 'package:test/test.dart'; +/// Extension methods for assertions on [String]. extension StringAssertions on String { /// Asserts that the value is equal to [expected] ignoring case sensitivity. - void shouldBeEqualToIgnoringCase(String expected) { - expect(this, equalsIgnoringCase(expected)); + String shouldBeEqualToIgnoringCase(String expected) { + checkExpect(this, equalsIgnoringCase(expected)); + return this; } - /// Asserts that the value is not equal to [expected] ignoring - /// case sensitivity. - void shouldNotBeEqualToIgnoringCase(String expected) { - expect(this, isNot(equalsIgnoringCase(expected))); + /// Asserts that the value is not equal to [expected] ignoring case sensitivity. + String shouldNotBeEqualToIgnoringCase(String expected) { + checkExpect(this, isNot(equalsIgnoringCase(expected))); + return this; } /// Asserts that the value starts with [expected] with case sensitivity. - void shouldStartWith(String expected) { - expect( + String shouldStartWith(String expected) { + checkExpect( this, startsWith(expected), - reason: 'Expected the String $this to start with $expected', + reason: 'Expected the String "$this" to start with "$expected"', ); + return this; } - /// Asserts that the value doesn't starts with [expected] - /// with case sensitivity. - void shouldNotStartWith(String expected) { - expect( + /// Asserts that the value does not start with [expected] with case sensitivity. + String shouldNotStartWith(String expected) { + checkExpect( this, isNot(startsWith(expected)), - reason: 'Expected the String $this to not start with $expected', + reason: 'Expected the String "$this" to not start with "$expected"', ); + return this; } /// Asserts that the value starts with [expected] ignoring case sensitivity. - void shouldStartWithIgnoringCase(String expected) { - expect( + String shouldStartWithIgnoringCase(String expected) { + checkExpect( toLowerCase(), startsWith(expected.toLowerCase()), - reason: 'Expected the String $this to start with $expected', + reason: 'Expected the String "$this" to start with "$expected"', ); + return this; } - /// Asserts that the value doesn't starts with [expected] - /// ignoring case sensitivity. - void shouldNotStartWithIgnoringCase(String expected) { - expect( + /// Asserts that the value does not start with [expected] ignoring case sensitivity. + String shouldNotStartWithIgnoringCase(String expected) { + checkExpect( toLowerCase(), isNot(startsWith(expected.toLowerCase())), - reason: 'Expected the String $this to not start with $expected', + reason: 'Expected the String "$this" to not start with "$expected"', ); + return this; } /// Asserts that the value ends with [expected] with case sensitivity. - void shouldEndWith(String expected) { - expect( + String shouldEndWith(String expected) { + checkExpect( this, endsWith(expected), - reason: 'Expected the String $this to end with $expected', + reason: 'Expected the String "$this" to end with "$expected"', ); + return this; } - /// Asserts that the value doesn't end with [expected] with case sensitivity. - void shouldNotEndWith(String expected) { - expect( + /// Asserts that the value does not end with [expected] with case sensitivity. + String shouldNotEndWith(String expected) { + checkExpect( this, isNot(endsWith(expected)), - reason: 'Expected the String $this to not to end with $expected', + reason: 'Expected the String "$this" to not end with "$expected"', ); + return this; } /// Asserts that the value ends with [expected] ignoring case sensitivity. - void shouldEndWithIgnoringCase(String expected) { - expect( + String shouldEndWithIgnoringCase(String expected) { + checkExpect( toLowerCase(), endsWith(expected.toLowerCase()), - reason: 'Expected the String $this to end with $expected', + reason: 'Expected the String "$this" to end with "$expected"', ); + return this; } - /// Asserts that the value doesn't end with [expected] - /// ignoring case sensitivity. - void shouldNotEndWithIgnoringCase(String expected) { - expect( + /// Asserts that the value does not end with [expected] ignoring case sensitivity. + String shouldNotEndWithIgnoringCase(String expected) { + checkExpect( toLowerCase(), isNot(endsWith(expected.toLowerCase())), - reason: 'Expected the String $this to not to end with $expected', + reason: 'Expected the String "$this" to not end with "$expected"', ); + return this; } /// Asserts that the value contains [expected] with case sensitivity. - void shouldContain(String expected) { - expect( + String shouldContain(String expected) { + checkExpect( this.contains(expected), isTrue, - reason: 'Expected the String $this to contain $expected', + reason: 'Expected the String "$this" to contain "$expected"', ); + return this; } /// Asserts that the value does not contain [expected] with case sensitivity. - void shouldNotContain(String expected) { - expect( + String shouldNotContain(String expected) { + checkExpect( this.contains(expected), isFalse, - reason: 'Expected the String $this to not to contain $expected', + reason: 'Expected the String "$this" to not contain "$expected"', ); + return this; } /// Asserts that the value contains [expected] ignoring case sensitivity. - void shouldContainIgnoringCase(String expected) { - expect( + String shouldContainIgnoringCase(String expected) { + checkExpect( toLowerCase().contains(expected.toLowerCase()), isTrue, - reason: 'Expected the String $this to contain $expected', + reason: 'Expected the String "$this" to contain "$expected"', ); + return this; } - /// Asserts that the value doesn't contain [expected] - /// ignoring case sensitivity. - void shouldNotContainIgnoringCase(String expected) { - expect( + /// Asserts that the value does not contain [expected] ignoring case sensitivity. + String shouldNotContainIgnoringCase(String expected) { + checkExpect( toLowerCase().contains(expected.toLowerCase()), isFalse, - reason: 'Expected the String $this to not to contain $expected', + reason: 'Expected the String "$this" to not contain "$expected"', ); + return this; } - /// Asserts that the value contains a list of [items] in any order - /// with case sensitivity. - void shouldContainAll(Iterable items) { - expect( - split(''), - containsAll(items), - reason: 'Expected $this to contain all of $items', + /// Asserts that the value contains every substring in [items] with case sensitivity. + String shouldContainAll(Iterable items) { + for (final item in items) { + checkExpect( + this.contains(item), + isTrue, + reason: + 'Expected "$this" to contain all of $items, but missing "$item"', + ); + } + return this; + } + + /// Asserts that the value does not contain all substrings in [items] with case sensitivity. + String shouldNotContainAll(Iterable items) { + final containsAll = items.every((item) => this.contains(item)); + checkExpect( + containsAll, + isFalse, + reason: 'Expected "$this" to not contain all of $items', ); + return this; + } + + /// Asserts that the value contains every substring in [items] ignoring case sensitivity. + String shouldContainAllIgnoringCase(Iterable items) { + final lower = toLowerCase(); + for (final item in items) { + checkExpect( + lower.contains(item.toLowerCase()), + isTrue, + reason: + 'Expected "$this" to contain all of $items ignoring case, but missing "$item"', + ); + } + return this; } - /// Asserts that the value contains a list of [items] in any order - /// ignoring case sensitivity. - void shouldContainAllIgnoringCase(Iterable items) { - expect( - toLowerCase().split(''), - containsAll(items.map((item) => item.toLowerCase())), - reason: 'Expected $this to contain all of $items', + /// Asserts that the value does not contain all substrings in [items] ignoring case sensitivity. + String shouldNotContainAllIgnoringCase(Iterable items) { + final lower = toLowerCase(); + final containsAll = + items.every((item) => lower.contains(item.toLowerCase())); + checkExpect( + containsAll, + isFalse, + reason: 'Expected "$this" to not contain all of $items ignoring case', ); + return this; } - /// Asserts that the value contains a list of [items] in relative order - /// with case sensitivity. - void shouldContainAllInOrder(List items) { - expect( + /// Asserts that the value contains [items] in relative order with case sensitivity. + String shouldContainAllInOrder(List items) { + checkExpect( this, stringContainsInOrder(items), - reason: 'Expected $this to contain all of $items', + reason: 'Expected "$this" to contain all of $items in order', ); + return this; } - /// Asserts that the value contains a list of [items] in relative order - /// ignoring case sensitivity. - void shouldContainAllInOrderIgnoringCase(List items) { - expect( + /// Asserts that the value contains [items] in relative order ignoring case sensitivity. + String shouldContainAllInOrderIgnoringCase(List items) { + checkExpect( toLowerCase(), stringContainsInOrder(items.map((item) => item.toLowerCase()).toList()), - reason: 'Expected $this to contain all of $items', + reason: + 'Expected "$this" to contain all of $items in order ignoring case', ); + return this; + } + + /// Asserts that the value contains at least one of [items] with case sensitivity. + String shouldContainAny(Iterable items) { + final hasAny = items.any((item) => this.contains(item)); + checkExpect( + hasAny, + isTrue, + reason: 'Expected "$this" to contain any of $items, but none were found', + ); + return this; + } + + /// Asserts that the value contains none of [items] with case sensitivity. + String shouldContainNone(Iterable items) { + final hasAny = items.any((item) => this.contains(item)); + checkExpect( + hasAny, + isFalse, + reason: 'Expected "$this" to contain none of $items, but some were found', + ); + return this; + } + + /// Asserts that the value contains at least one of [items] ignoring case sensitivity. + String shouldContainAnyIgnoringCase(Iterable items) { + final lower = toLowerCase(); + final hasAny = items.any((item) => lower.contains(item.toLowerCase())); + checkExpect( + hasAny, + isTrue, + reason: + 'Expected "$this" to contain any of $items ignoring case, but none were found', + ); + return this; + } + + /// Asserts that the value contains none of [items] ignoring case sensitivity. + String shouldContainNoneIgnoringCase(Iterable items) { + final lower = toLowerCase(); + final hasAny = items.any((item) => lower.contains(item.toLowerCase())); + checkExpect( + hasAny, + isFalse, + reason: + 'Expected "$this" to contain none of $items ignoring case, but some were found', + ); + return this; } /// Asserts that the value matches the regular expression given by [regExp]. - /// [regExp] can be a [RegExp] instance or a [String]. - void shouldMatch(Pattern regExp) { - expect( + String shouldMatch(Pattern regExp) { + checkExpect( this, matches(regExp), - reason: 'Expected $this to match $regExp', + reason: 'Expected "$this" to match $regExp', ); + return this; } - /// Asserts that the value does not match the regular expression \ - /// given by [regExp]. - /// [regExp] can be a [RegExp] instance or a [String]. - void shouldNotMatch(Pattern regExp) { - expect( + /// Asserts that the value does not match the regular expression given by [regExp]. + String shouldNotMatch(Pattern regExp) { + checkExpect( this, isNot(matches(regExp)), - reason: 'Expected $this to not to match $regExp', + reason: 'Expected "$this" to not match $regExp', ); + return this; } - /// Asserts that the value is empty. - void shouldBeEmpty() { - expect( + /// Asserts that the string is empty. + String shouldBeEmpty() { + checkExpect( this.isEmpty, isTrue, - reason: 'Expected String to be empty, but was $this', + reason: 'Expected String to be empty, but was "$this"', ); + return this; } - /// Asserts that the value is not empty. - void shouldNotBeEmpty() { - expect( + /// Asserts that the string is not empty. + String shouldNotBeEmpty() { + checkExpect( this.isNotEmpty, isTrue, - reason: 'Expected String to not to be empty, but was $this', + reason: 'Expected String to not be empty, but was empty', ); + return this; } - /// Asserts that the value is blank. - void shouldBeBlank() { - expect( + /// Asserts that the string consists only of whitespace characters or is empty. + String shouldBeBlank() { + checkExpect( trim().isEmpty, isTrue, - reason: 'Expected String to be blank, but was $this', + reason: 'Expected String to be blank, but was "$this"', ); + return this; } - /// Asserts that the value is not blank. - void shouldNotBeBlank() { - expect( + /// Asserts that the string is not blank. + String shouldNotBeBlank() { + checkExpect( trim().isNotEmpty, isTrue, - reason: 'Expected String to not to be blank, but was $this', + reason: 'Expected String to not be blank, but was "$this"', + ); + return this; + } + + /// Asserts that the string has the expected character [length]. + String shouldHaveLength(int expectedLength) { + checkExpect( + length, + equals(expectedLength), + reason: + 'Expected String to have length $expectedLength, but was $length ("$this")', + ); + return this; + } + + /// Asserts that the string can be parsed as a number. + String shouldBeNumeric() { + checkExpect( + num.tryParse(this) != null, + isTrue, + reason: + 'Expected "$this" to be numeric, but could not be parsed as a number', + ); + return this; + } + + /// Asserts that the string cannot be parsed as a number. + String shouldNotBeNumeric() { + checkExpect( + num.tryParse(this) == null, + isTrue, + reason: 'Expected "$this" to not be numeric, but was parsed as a number', ); + return this; } } +/// Extension methods for assertions on nullable [String]s. extension StringNullableAssertions on String? { /// Asserts that the value is null or empty. - void shouldBeNullOrEmpty() { - expect( + String? shouldBeNullOrEmpty() { + checkExpect( this?.isEmpty ?? true, isTrue, - reason: 'Expected String to be null or empty, but was $this', + reason: 'Expected String to be null or empty, but was "$this"', ); + return this; } - /// Asserts that the value is not null nor empty. - void shouldNotBeNullOrEmpty() { - expect( + /// Asserts that the value is neither null nor empty. + String? shouldNotBeNullOrEmpty() { + checkExpect( this?.isNotEmpty ?? false, isTrue, - reason: 'Expected String to not to be null or empty, but was $this', + reason: 'Expected String to not be null or empty, but was "$this"', ); + return this; } /// Asserts that the value is null or blank. - void shouldBeNullOrBlank() { - expect( + String? shouldBeNullOrBlank() { + checkExpect( this?.trim().isEmpty ?? true, isTrue, - reason: 'Expected String to be blank or null, but was $this', + reason: 'Expected String to be blank or null, but was "$this"', ); + return this; } - /// Asserts that the value is not null nor blank. - void shouldNotBeNullOrBlank() { - expect( + /// Asserts that the value is neither null nor blank. + String? shouldNotBeNullOrBlank() { + checkExpect( this?.trim().isNotEmpty ?? false, isTrue, - reason: 'Expected String to not to be blank or null, but was $this', + reason: 'Expected String to not be blank or null, but was "$this"', ); + return this; } } diff --git a/lib/src/temporal/temporal_assertions.dart b/lib/src/temporal/temporal_assertions.dart new file mode 100644 index 0000000..8a396a6 --- /dev/null +++ b/lib/src/temporal/temporal_assertions.dart @@ -0,0 +1,261 @@ +import 'package:fluent_assertions/src/internal/check_expect.dart'; +import 'package:test/test.dart'; + +/// Extension methods for fluent assertions on [DateTime]. +extension DateTimeAssertions on DateTime { + /// Asserts that this [DateTime] is strictly after [expected]. + DateTime shouldBeAfter(DateTime expected) { + checkExpect( + isAfter(expected), + isTrue, + reason: 'Expected $this to be after $expected.', + ); + return this; + } + + /// Asserts that this [DateTime] is not after [expected]. + DateTime shouldNotBeAfter(DateTime expected) { + checkExpect( + isAfter(expected), + isFalse, + reason: 'Expected $this to not be after $expected.', + ); + return this; + } + + /// Asserts that this [DateTime] is strictly before [expected]. + DateTime shouldBeBefore(DateTime expected) { + checkExpect( + isBefore(expected), + isTrue, + reason: 'Expected $this to be before $expected.', + ); + return this; + } + + /// Asserts that this [DateTime] is not before [expected]. + DateTime shouldNotBeBefore(DateTime expected) { + checkExpect( + isBefore(expected), + isFalse, + reason: 'Expected $this to not be before $expected.', + ); + return this; + } + + /// Asserts that this [DateTime] is at the exact same moment as [expected]. + DateTime shouldBeAtSameMomentAs(DateTime expected) { + checkExpect( + isAtSameMomentAs(expected), + isTrue, + reason: 'Expected $this to be at the same moment as $expected.', + ); + return this; + } + + /// Asserts that this [DateTime] is not at the exact same moment as [expected]. + DateTime shouldNotBeAtSameMomentAs(DateTime expected) { + checkExpect( + isAtSameMomentAs(expected), + isFalse, + reason: 'Expected $this to not be at the same moment as $expected.', + ); + return this; + } + + /// Asserts that this [DateTime] is between [start] and [end] inclusive. + DateTime shouldBeBetween(DateTime start, DateTime end) { + final inRange = (isAtSameMomentAs(start) || isAfter(start)) && + (isAtSameMomentAs(end) || isBefore(end)); + checkExpect( + inRange, + isTrue, + reason: 'Expected $this to be between $start and $end.', + ); + return this; + } + + /// Asserts that this [DateTime] is outside the range [[start], [end]]. + DateTime shouldNotBeBetween(DateTime start, DateTime end) { + final inRange = (isAtSameMomentAs(start) || isAfter(start)) && + (isAtSameMomentAs(end) || isBefore(end)); + checkExpect( + inRange, + isFalse, + reason: 'Expected $this to not be between $start and $end.', + ); + return this; + } + + /// Asserts that the year of this [DateTime] equals [year]. + DateTime shouldBeInYear(int year) { + checkExpect( + this.year, + equals(year), + reason: 'Expected year to be $year, but was ${this.year}.', + ); + return this; + } + + /// Asserts that the month of this [DateTime] equals [month]. + DateTime shouldBeInMonth(int month) { + checkExpect( + this.month, + equals(month), + reason: 'Expected month to be $month, but was ${this.month}.', + ); + return this; + } + + /// Asserts that the day of this [DateTime] equals [day]. + DateTime shouldBeOnDay(int day) { + checkExpect( + this.day, + equals(day), + reason: 'Expected day to be $day, but was ${this.day}.', + ); + return this; + } + + /// Asserts that this [DateTime] has the same calendar date (year, month, day) as [expectedDate]. + DateTime shouldBeOnDate(DateTime expectedDate) { + final sameDate = year == expectedDate.year && + month == expectedDate.month && + day == expectedDate.day; + checkExpect( + sameDate, + isTrue, + reason: + 'Expected date to be on ${expectedDate.year}-${expectedDate.month}-${expectedDate.day}, but was $year-$month-$day.', + ); + return this; + } + + /// Asserts that this [DateTime] is within [delta] of [expected]. + DateTime shouldBeNear(DateTime expected, {Duration delta = Duration.zero}) { + final diff = difference(expected).abs(); + checkExpect( + diff <= delta, + isTrue, + reason: + 'Expected $this to be within $delta of $expected, but difference was $diff.', + ); + return this; + } + + /// Asserts that this [DateTime] is not within [delta] of [expected]. + DateTime shouldNotBeNear(DateTime expected, + {Duration delta = Duration.zero}) { + final diff = difference(expected).abs(); + checkExpect( + diff <= delta, + isFalse, + reason: + 'Expected $this to not be within $delta of $expected, but difference was $diff.', + ); + return this; + } +} + +/// Extension methods for fluent assertions on [Duration]. +extension DurationAssertions on Duration { + /// Asserts that this [Duration] is longer than [other]. + Duration shouldBeLongerThan(Duration other) { + checkExpect( + this > other, + isTrue, + reason: 'Expected $this to be longer than $other.', + ); + return this; + } + + /// Asserts that this [Duration] is shorter than [other]. + Duration shouldBeShorterThan(Duration other) { + checkExpect( + this < other, + isTrue, + reason: 'Expected $this to be shorter than $other.', + ); + return this; + } + + /// Asserts that this [Duration] is equal to [other]. + Duration shouldBeEqualTo(Duration other) { + checkExpect( + this, + equals(other), + reason: 'Expected $this to be equal to $other.', + ); + return this; + } + + /// Asserts that this [Duration] is not equal to [other]. + Duration shouldNotBeEqualTo(Duration other) { + checkExpect( + this, + isNot(equals(other)), + reason: 'Expected $this to not be equal to $other.', + ); + return this; + } + + /// Asserts that this [Duration] is positive (> 0). + Duration shouldBePositive() { + checkExpect( + inMicroseconds > 0, + isTrue, + reason: 'Expected $this to be positive.', + ); + return this; + } + + /// Asserts that this [Duration] is non-positive (<= 0). + Duration shouldNotBePositive() { + checkExpect( + inMicroseconds <= 0, + isTrue, + reason: 'Expected $this to not be positive.', + ); + return this; + } + + /// Asserts that this [Duration] is negative (< 0). + Duration shouldBeNegative() { + checkExpect( + inMicroseconds < 0, + isTrue, + reason: 'Expected $this to be negative.', + ); + return this; + } + + /// Asserts that this [Duration] is non-negative (>= 0). + Duration shouldNotBeNegative() { + checkExpect( + inMicroseconds >= 0, + isTrue, + reason: 'Expected $this to not be negative.', + ); + return this; + } + + /// Asserts that this [Duration] is zero. + Duration shouldBeZero() { + checkExpect( + inMicroseconds == 0, + isTrue, + reason: 'Expected $this to be zero.', + ); + return this; + } + + /// Asserts that this [Duration] is not zero. + Duration shouldNotBeZero() { + checkExpect( + inMicroseconds != 0, + isTrue, + reason: 'Expected $this to not be zero.', + ); + return this; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 97d5084..46c778d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,14 @@ name: fluent_assertions description: A very extensive set of extension methods that allow you to more naturally specify the expected outcome of a TDD or BDD-style unit tests. -version: 0.5.1 +version: 1.0.0 homepage: https://github.com/klisiewicz/fluent-assertions environment: - sdk: '>=2.12.0 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: - test: ^1.25.7 + test: ^1.25.0 dev_dependencies: - lint: ^2.3.0 + lints: ^6.0.0 + diff --git a/test/src/basic/basic_assertions_test.dart b/test/src/basic/basic_assertions_test.dart index d3dbba9..8f2febb 100644 --- a/test/src/basic/basic_assertions_test.dart +++ b/test/src/basic/basic_assertions_test.dart @@ -253,4 +253,102 @@ void main() { ); }); }); + + group('same class', () { + test('should return normally when runtimeType matches', () { + expect( + () => 1.shouldHaveTheSameClassAs(2), + returnsNormally, + ); + expect( + () => 'hello'.shouldHaveTheSameClassAs('world'), + returnsNormally, + ); + }); + + test('should fail when runtimeType differs', () { + expect( + () => 1.shouldHaveTheSameClassAs('1'), + failsTest, + ); + }); + + test('shouldNotHaveTheSameClassAs passes when types differ', () { + expect( + () => 1.shouldNotHaveTheSameClassAs('1'), + returnsNormally, + ); + }); + + test('shouldNotHaveTheSameClassAs fails when types match', () { + expect( + () => 1.shouldNotHaveTheSameClassAs(2), + failsTest, + ); + }); + }); + + group('shouldBeIn / shouldNotBeIn', () { + final list = [1, 2, 3]; + + test('shouldBeIn passes when element in collection', () { + expect(() => 2.shouldBeIn(list), returnsNormally); + }); + + test('shouldBeIn fails when element not in collection', () { + expect(() => 4.shouldBeIn(list), failsTest); + }); + + test('shouldNotBeIn passes when element not in collection', () { + expect(() => 4.shouldNotBeIn(list), returnsNormally); + }); + + test('shouldNotBeIn fails when element in collection', () { + expect(() => 2.shouldNotBeIn(list), failsTest); + }); + }); + + group('char digits', () { + test('shouldBeDigit passes for 0-9 single chars', () { + expect(() => '5'.shouldBeDigit(), returnsNormally); + expect(() => '0'.shouldBeDigit(), returnsNormally); + expect(() => '9'.shouldBeDigit(), returnsNormally); + }); + + test('shouldBeDigit fails for non-digits or multi chars', () { + expect(() => 'a'.shouldBeDigit(), failsTest); + expect(() => '12'.shouldBeDigit(), failsTest); + expect(() => ''.shouldBeDigit(), failsTest); + }); + + test('shouldNotBeDigit passes for non-digits', () { + expect(() => 'a'.shouldNotBeDigit(), returnsNormally); + expect(() => '12'.shouldNotBeDigit(), returnsNormally); + }); + + test('shouldNotBeDigit fails for digits', () { + expect(() => '7'.shouldNotBeDigit(), failsTest); + }); + }); + + group('fluent chaining and null promotion', () { + test('shouldNotBeNull returns promoted non-null value', () { + String? getNullable() => 'hello'; + final nonNull = getNullable().shouldNotBeNull(); + expect(nonNull, isA()); + expect(nonNull.length, equals(5)); + }); + + test('BaseAssertions chain fluently', () { + const x = 5; + expect( + () => x + .shouldBeEqualTo(5) + .shouldNotBeEqualTo(6) + .shouldBeInstanceOf() + .shouldBeIn([4, 5, 6]), + returnsNormally, + ); + }); + }); } diff --git a/test/src/equivalency/equivalency_assertions_test.dart b/test/src/equivalency/equivalency_assertions_test.dart new file mode 100644 index 0000000..9f14f5d --- /dev/null +++ b/test/src/equivalency/equivalency_assertions_test.dart @@ -0,0 +1,87 @@ +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; + +import '../util/matchers.dart'; + +void main() { + group('EquivalencyAssertions', () { + test('deeply compares nested maps and lists', () { + final a = { + 'users': [ + { + 'name': 'Alice', + 'roles': ['admin', 'user'] + }, + { + 'name': 'Bob', + 'roles': ['user'] + }, + ], + 'count': 2, + }; + + final b = { + 'users': [ + { + 'name': 'Alice', + 'roles': ['admin', 'user'] + }, + { + 'name': 'Bob', + 'roles': ['user'] + }, + ], + 'count': 2, + }; + + expect(() => a.shouldBeEquivalentTo(b), returnsNormally); + }); + + test('fails when nested value differs', () { + final a = { + 'users': [ + {'name': 'Alice'}, + ], + }; + final b = { + 'users': [ + {'name': 'Bob'}, + ], + }; + + expect(() => a.shouldBeEquivalentTo(b), failsTest); + }); + + test('compares DateTimes by moment', () { + final a = {'time': DateTime.utc(2023, 1, 1, 12)}; + final b = {'time': DateTime.utc(2023, 1, 1, 12).toLocal()}; + + expect(() => a.shouldBeEquivalentTo(b), returnsNormally); + }); + + test('compares NaN as equivalent to NaN', () { + expect( + () => double.nan.shouldBeEquivalentTo(double.nan), returnsNormally); + }); + + test('compares Sets by elements regardless of order', () { + final a = {1, 2, 3}; + final b = {3, 1, 2}; + + expect(() => a.shouldBeEquivalentTo(b), returnsNormally); + }); + + test('shouldNotBeEquivalentTo passes when different', () { + expect(() => [1, 2].shouldNotBeEquivalentTo([2, 3]), returnsNormally); + expect(() => [1, 2].shouldNotBeEquivalentTo([1, 2]), failsTest); + }); + + test('supports fluid chaining', () { + expect( + () => {'a': 1} + .shouldBeEquivalentTo({'a': 1}).shouldNotBeEquivalentTo({'a': 2}), + returnsNormally, + ); + }); + }); +} diff --git a/test/src/exceptions/exception_assertions_test.dart b/test/src/exceptions/exception_assertions_test.dart new file mode 100644 index 0000000..057daba --- /dev/null +++ b/test/src/exceptions/exception_assertions_test.dart @@ -0,0 +1,194 @@ +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; + +import '../util/matchers.dart'; + +class CustomException implements Exception { + final String message; + final Object? cause; + + CustomException(this.message, [this.cause]); + + @override + String toString() => 'CustomException: $message (cause: $cause)'; +} + +void main() { + group('SyncFunctionAssertions', () { + test( + 'shouldThrow returns ExceptionResult when expected exception is thrown', + () { + final result = (() => throw const FormatException('invalid format')) + .shouldThrow(); + expect(result.exception.message, equals('invalid format')); + }); + + test('shouldThrow fails when different exception is thrown', () { + expect( + () => (() => throw ArgumentError('bad arg')) + .shouldThrow(), + failsTest, + ); + }); + + test('shouldThrow fails when function returns normally', () { + expect( + () => (() {}).shouldThrow(), + failsTest, + ); + }); + + test('shouldNotThrow returns normally when no exception is thrown', () { + expect( + () => (() {}).shouldNotThrow(), + returnsNormally, + ); + }); + + test('shouldNotThrow returns normally when a different exception is thrown', + () { + expect( + () => (() => throw ArgumentError()).shouldNotThrow(), + returnsNormally, + ); + }); + + test('shouldNotThrow fails when expected exception is thrown', () { + expect( + () => (() => throw const FormatException('fail')) + .shouldNotThrow(), + failsTest, + ); + }); + + test('shouldNotThrowAny returns normally when no exception is thrown', () { + expect( + () => (() {}).shouldNotThrowAny(), + returnsNormally, + ); + }); + + test('shouldNotThrowAny fails when any exception is thrown', () { + expect( + () => (() => throw Exception('error')).shouldNotThrowAny(), + failsTest, + ); + }); + + test('invoking helper wraps action cleanly', () { + expect( + () => invoking(() => throw RangeError('out of range')) + .shouldThrow(), + returnsNormally, + ); + }); + + group('ExceptionResult chaining', () { + test('withMessage passes when message matches exactly', () { + expect( + () => (() => throw const FormatException('bad format')) + .shouldThrow() + .withMessage('bad format'), + returnsNormally, + ); + }); + + test('withMessage fails when message differs', () { + expect( + () => (() => throw const FormatException('bad format')) + .shouldThrow() + .withMessage('different message'), + failsTest, + ); + }); + + test('withMessageMatching passes when regex matches', () { + expect( + () => (() => throw const FormatException('error code: 404')) + .shouldThrow() + .withMessageMatching(RegExp(r'\d+')), + returnsNormally, + ); + }); + + test('withMessageContaining passes when substring exists', () { + expect( + () => (() => throw const FormatException('connection timed out')) + .shouldThrow() + .withMessageContaining('timed out'), + returnsNormally, + ); + }); + + test('withCause passes when cause is of expected type', () { + final inner = ArgumentError('bad input'); + expect( + () => (() => throw CustomException('outer', inner)) + .shouldThrow() + .withCause(), + returnsNormally, + ); + }); + + test('withCause fails when cause is missing or of different type', () { + expect( + () => (() => throw CustomException('outer', 'string cause')) + .shouldThrow() + .withCause(), + failsTest, + ); + }); + + test('withCauseMatching passes when predicate returns true', () { + expect( + () => (() => throw CustomException('outer', 42)) + .shouldThrow() + .withCauseMatching((cause) => cause == 42), + returnsNormally, + ); + }); + }); + }); + + group('AsyncFunctionAssertions', () { + test('async shouldThrow passes when expected exception thrown', () async { + Future asyncAction() async { + await Future.delayed(const Duration(milliseconds: 1)); + throw const FormatException('async error'); + } + + final result = await asyncAction.shouldThrow(); + expect(result.exception.message, equals('async error')); + }); + + test('async shouldThrow fails when function completes normally', () { + Future asyncAction() async => 42; + expect( + asyncAction.shouldThrow(), + failsTest, + ); + }); + + test('async shouldNotThrow passes when no exception thrown', () { + Future asyncAction() async => 42; + expect( + asyncAction.shouldNotThrow(), + completes, + ); + }); + + test('async shouldNotThrowAny passes when completed normally', () { + Future asyncAction() async => 'success'; + expect( + asyncAction.shouldNotThrowAny(), + completes, + ); + }); + + test('FutureExtensionAssertions directly on Future instance', () async { + final future = Future.error(const FormatException('future error')); + final result = await future.shouldThrow(); + result.withMessage('future error'); + }); + }); +} diff --git a/test/src/iterable/iterable_extensions_test.dart b/test/src/iterable/iterable_extensions_test.dart new file mode 100644 index 0000000..8ea264c --- /dev/null +++ b/test/src/iterable/iterable_extensions_test.dart @@ -0,0 +1,111 @@ +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; + +import '../util/matchers.dart'; + +void main() { + group('IterableExtensions additional tests', () { + group('shouldContainSame / shouldNotContainSame', () { + test('passes when elements match regardless of order', () { + expect(() => [1, 2, 3].shouldContainSame([3, 1, 2]), returnsNormally); + expect( + () => [1, 2, 3].shouldEqualUnordered([3, 1, 2]), returnsNormally); + }); + + test('passes when duplicates match in frequencies', () { + expect(() => [1, 2, 2, 3].shouldContainSame([2, 1, 3, 2]), + returnsNormally); + }); + + test('fails when frequencies differ', () { + expect(() => [1, 2, 2, 3].shouldContainSame([1, 2, 3, 3]), failsTest); + }); + + test('fails when elements differ', () { + expect(() => [1, 2, 3].shouldContainSame([1, 2, 4]), failsTest); + expect(() => [1, 2, 3].shouldEqualUnordered([1, 2, 4]), failsTest); + }); + + test('shouldNotContainSame passes when different', () { + expect( + () => [1, 2, 3].shouldNotContainSame([1, 2, 4]), returnsNormally); + expect(() => [1, 2, 3].shouldNotEqualUnordered([1, 2, 4]), + returnsNormally); + }); + + test('shouldNotContainSame fails when same', () { + expect(() => [1, 2, 3].shouldNotContainSame([3, 2, 1]), failsTest); + }); + }); + + group('shouldBeSorted / shouldNotBeSorted', () { + test('shouldBeSorted passes for naturally ascending iterables', () { + expect(() => [1, 2, 3, 4].shouldBeSorted(), returnsNormally); + expect(() => ['a', 'b', 'c'].shouldBeSorted(), returnsNormally); + }); + + test('shouldBeSorted fails when unsorted', () { + expect(() => [1, 3, 2].shouldBeSorted(), failsTest); + }); + + test('shouldBeSortedAccordingTo with custom comparator', () { + expect( + () => + [4, 3, 2, 1].shouldBeSortedAccordingTo((a, b) => b.compareTo(a)), + returnsNormally, + ); + }); + + test('shouldNotBeSorted passes when not sorted', () { + expect(() => [1, 3, 2].shouldNotBeSorted(), returnsNormally); + }); + + test('shouldNotBeSorted fails when sorted', () { + expect(() => [1, 2, 3].shouldNotBeSorted(), failsTest); + }); + }); + + group('shouldNotContainAll', () { + test('passes when some element is missing', () { + expect(() => [1, 2, 3].shouldNotContainAll([3, 4]), returnsNormally); + }); + + test('fails when all elements are present', () { + expect(() => [1, 2, 3].shouldNotContainAll([1, 2]), failsTest); + }); + }); + + group('predicate aliases', () { + test('shouldContainSome / shouldMatchAtLeastOneOf', () { + expect( + () => [1, 2, 3].shouldContainSome((x) => x > 2), returnsNormally); + expect(() => [1, 2, 3].shouldMatchAtLeastOneOf((x) => x == 2), + returnsNormally); + expect(() => [1, 2, 3].shouldContainSome((x) => x > 5), failsTest); + }); + + test('shouldMatchAllWith', () { + expect(() => [2, 4, 6].shouldMatchAllWith((x) => x.isEven), + returnsNormally); + expect(() => [2, 3, 6].shouldMatchAllWith((x) => x.isEven), failsTest); + }); + + test('shouldHaveCount', () { + expect(() => [1, 2].shouldHaveCount(2), returnsNormally); + expect(() => [1, 2].shouldHaveCount(3), failsTest); + }); + }); + + test('supports fluid chaining', () { + expect( + () => [1, 2, 3] + .shouldNotBeEmpty() + .shouldHaveSize(3) + .shouldContain(2) + .shouldBeSorted() + .shouldContainSame([3, 2, 1]), + returnsNormally, + ); + }); + }); +} diff --git a/test/src/map/map_assertions_test.dart b/test/src/map/map_assertions_test.dart new file mode 100644 index 0000000..86b3581 --- /dev/null +++ b/test/src/map/map_assertions_test.dart @@ -0,0 +1,173 @@ +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; + +import '../util/matchers.dart'; + +void main() { + group('MapAssertions', () { + final map = {'a': 1, 'b': 2, 'c': 3}; + + group('shouldHaveKey / shouldNotHaveKey', () { + test('should return normally when key exists', () { + expect(() => map.shouldHaveKey('a'), returnsNormally); + }); + + test('should fail when key does not exist', () { + expect(() => map.shouldHaveKey('z'), failsTest); + }); + + test('should return normally when key does not exist for notHaveKey', () { + expect(() => map.shouldNotHaveKey('z'), returnsNormally); + }); + + test('should fail when key exists for notHaveKey', () { + expect(() => map.shouldNotHaveKey('a'), failsTest); + }); + }); + + group('shouldHaveValue / shouldNotHaveValue', () { + test('should return normally when value exists', () { + expect(() => map.shouldHaveValue(2), returnsNormally); + }); + + test('should fail when value does not exist', () { + expect(() => map.shouldHaveValue(99), failsTest); + }); + + test('should return normally when value does not exist for notHaveValue', + () { + expect(() => map.shouldNotHaveValue(99), returnsNormally); + }); + + test('should fail when value exists for notHaveValue', () { + expect(() => map.shouldNotHaveValue(1), failsTest); + }); + }); + + group('shouldContain / shouldNotContain (key, value)', () { + test('should return normally when key and value match', () { + expect(() => map.shouldContain('b', 2), returnsNormally); + }); + + test('should fail when key exists but value differs', () { + expect(() => map.shouldContain('b', 99), failsTest); + }); + + test('should fail when key does not exist', () { + expect(() => map.shouldContain('z', 1), failsTest); + }); + + test('should return normally when entry does not match for notContain', + () { + expect(() => map.shouldNotContain('b', 99), returnsNormally); + expect(() => map.shouldNotContain('z', 1), returnsNormally); + }); + + test('should fail when entry matches for notContain', () { + expect(() => map.shouldNotContain('a', 1), failsTest); + }); + }); + + group('shouldContainEntry / shouldNotContainEntry', () { + test('should return normally when entry matches', () { + expect( + () => map.shouldContainEntry(const MapEntry('a', 1)), + returnsNormally, + ); + }); + + test('should fail when entry differs', () { + expect( + () => map.shouldContainEntry(const MapEntry('a', 99)), + failsTest, + ); + }); + + test('should return normally when entry differs for notContainEntry', () { + expect( + () => map.shouldNotContainEntry(const MapEntry('z', 1)), + returnsNormally, + ); + }); + + test('should fail when entry exists for notContainEntry', () { + expect( + () => map.shouldNotContainEntry(const MapEntry('a', 1)), + failsTest, + ); + }); + }); + + group('shouldContainSame / shouldNotContainSame', () { + test( + 'should return normally when maps have same entries regardless of order', + () { + final same = {'c': 3, 'a': 1, 'b': 2}; + expect(() => map.shouldContainSame(same), returnsNormally); + }); + + test('should fail when maps have different values', () { + final diffVal = {'a': 1, 'b': 99, 'c': 3}; + expect(() => map.shouldContainSame(diffVal), failsTest); + }); + + test('should fail when maps have different size', () { + final subset = {'a': 1, 'b': 2}; + expect(() => map.shouldContainSame(subset), failsTest); + }); + + test('should return normally when maps differ for notContainSame', () { + final diff = {'a': 1, 'b': 99}; + expect(() => map.shouldNotContainSame(diff), returnsNormally); + }); + + test('should fail when maps are same for notContainSame', () { + final same = {'a': 1, 'b': 2, 'c': 3}; + expect(() => map.shouldNotContainSame(same), failsTest); + }); + }); + + group('shouldHaveSize / shouldHaveCount', () { + test('should return normally when size matches', () { + expect(() => map.shouldHaveSize(3), returnsNormally); + expect(() => map.shouldHaveCount(3), returnsNormally); + }); + + test('should fail when size differs', () { + expect(() => map.shouldHaveSize(2), failsTest); + expect(() => map.shouldHaveCount(4), failsTest); + }); + }); + + group('shouldBeEmpty / shouldNotBeEmpty', () { + test('should return normally for empty map', () { + expect(() => {}.shouldBeEmpty(), returnsNormally); + }); + + test('should fail for non-empty map with shouldBeEmpty', () { + expect(() => map.shouldBeEmpty(), failsTest); + }); + + test('should return normally for non-empty map with shouldNotBeEmpty', + () { + expect(() => map.shouldNotBeEmpty(), returnsNormally); + }); + + test('should fail for empty map with shouldNotBeEmpty', () { + expect(() => {}.shouldNotBeEmpty(), failsTest); + }); + }); + + test('supports fluid chaining', () { + expect( + () => map + .shouldNotBeEmpty() + .shouldHaveSize(3) + .shouldHaveKey('a') + .shouldHaveValue(1) + .shouldContain('b', 2), + returnsNormally, + ); + }); + }); +} diff --git a/test/src/numerical/numerical_assertions_test.dart b/test/src/numerical/numerical_assertions_test.dart index fae96ec..171dc62 100644 --- a/test/src/numerical/numerical_assertions_test.dart +++ b/test/src/numerical/numerical_assertions_test.dart @@ -730,4 +730,38 @@ void main() { }); }); }); + + group('NaN and shouldNotBeNear', () { + test('NaN is near NaN', () { + expect(() => double.nan.shouldBeNear(double.nan), returnsNormally); + expect(() => double.nan.shouldNotBeNear(double.nan), failsTest); + }); + + test('number is not near NaN', () { + expect(() => 5.shouldNotBeNear(double.nan), returnsNormally); + expect(() => double.nan.shouldNotBeNear(5), returnsNormally); + expect(() => 5.shouldBeNear(double.nan), failsTest); + expect(() => double.nan.shouldBeNear(5), failsTest); + }); + + test('shouldNotBeNear for normal numbers', () { + expect(() => 10.shouldNotBeNear(20, delta: 2), returnsNormally); + expect(() => 10.shouldNotBeNear(11, delta: 2), failsTest); + }); + }); + + group('numerical chaining', () { + test('chains multiple numerical assertions fluently', () { + expect( + () => 42 + .shouldBePositive() + .shouldBeGreaterThan(10) + .shouldBeLessThan(50) + .shouldBeInRange(lowerBound: 40, upperBound: 45) + .shouldNotBeInRange(lowerBound: 45, upperBound: 50) + .shouldNotBeZero(), + returnsNormally, + ); + }); + }); } diff --git a/test/src/softly/softly_test.dart b/test/src/softly/softly_test.dart new file mode 100644 index 0000000..bb388ae --- /dev/null +++ b/test/src/softly/softly_test.dart @@ -0,0 +1,88 @@ +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; + +import '../util/matchers.dart'; + +void main() { + group('assertSoftly', () { + test('passes when all assertions in block pass', () { + expect( + () => assertSoftly(() { + 1.shouldBeEqualTo(1); + 'hello'.shouldStartWith('h'); + true.shouldBeTrue(); + }), + returnsNormally, + ); + }); + + test('returns block result', () { + final value = assertSoftly(() { + 1.shouldBeEqualTo(1); + return 42; + }); + expect(value, equals(42)); + }); + + test('collects multiple failures and throws combined failure', () { + try { + assertSoftly(() { + 1.shouldBeEqualTo(2); + 'hello'.shouldStartWith('z'); + true.shouldBeFalse(); + }); + fail('Expected assertSoftly to throw TestFailure'); + } on TestFailure catch (e) { + expect(e.message, contains('The following 3 assertions failed:')); + expect(e.message, contains('1)')); + expect(e.message, contains('2)')); + expect(e.message, contains('3)')); + } + }); + + test('throws single failure directly when exactly 1 assertion fails', () { + expect( + () => assertSoftly(() { + 1.shouldBeEqualTo(1); + 1.shouldBeEqualTo(2); + }), + failsTest, + ); + }); + + test('hard fails immediately on shouldNotBeNull when null', () { + var executedSecond = false; + try { + assertSoftly(() { + const String? nullStr = null; + nullStr.shouldNotBeNull(); + executedSecond = true; + }); + } on TestFailure catch (_) { + // Expected + } + expect(executedSecond, isFalse); + }); + }); + + group('assertSoftlyAsync', () { + test('passes when all async assertions pass', () async { + await assertSoftlyAsync(() async { + await Future.delayed(const Duration(milliseconds: 1)); + 1.shouldBeEqualTo(1); + }); + }); + + test('collects multiple async failures', () async { + try { + await assertSoftlyAsync(() async { + 1.shouldBeEqualTo(2); + 'abc'.shouldStartWith('x'); + }); + fail('Expected assertSoftlyAsync to throw TestFailure'); + } on TestFailure catch (e) { + expect(e.message, contains('The following 2 assertions failed:')); + } + }); + }); +} diff --git a/test/src/string/string_assertions_test.dart b/test/src/string/string_assertions_test.dart index 817351f..f456954 100644 --- a/test/src/string/string_assertions_test.dart +++ b/test/src/string/string_assertions_test.dart @@ -499,4 +499,63 @@ void main() { expect(() => 'name'.shouldNotBeNullOrBlank(), returnsNormally); }); }); + + group('shouldHaveLength', () { + test('passes when length matches', () { + expect(() => 'hello'.shouldHaveLength(5), returnsNormally); + expect(() => ''.shouldHaveLength(0), returnsNormally); + }); + + test('fails when length differs', () { + expect(() => 'hello'.shouldHaveLength(3), failsTest); + }); + }); + + group('numeric', () { + test('shouldBeNumeric passes for numbers', () { + expect(() => '123'.shouldBeNumeric(), returnsNormally); + expect(() => '-45.67'.shouldBeNumeric(), returnsNormally); + }); + + test('shouldBeNumeric fails for non-numbers', () { + expect(() => 'abc'.shouldBeNumeric(), failsTest); + expect(() => '12a'.shouldBeNumeric(), failsTest); + }); + + test('shouldNotBeNumeric passes for non-numbers', () { + expect(() => 'abc'.shouldNotBeNumeric(), returnsNormally); + }); + + test('shouldNotBeNumeric fails for numbers', () { + expect(() => '123'.shouldNotBeNumeric(), failsTest); + }); + }); + + group('shouldNotContainAll', () { + test('passes when some items missing', () { + expect(() => 'hello'.shouldNotContainAll(['h', 'z']), returnsNormally); + expect(() => 'hello'.shouldNotContainAllIgnoringCase(['H', 'Z']), + returnsNormally); + }); + + test('fails when all items present', () { + expect(() => 'hello'.shouldNotContainAll(['h', 'e']), failsTest); + expect( + () => 'hello'.shouldNotContainAllIgnoringCase(['H', 'E']), failsTest); + }); + }); + + group('string chaining', () { + test('chains multiple string assertions fluently', () { + expect( + () => 'Flutter' + .shouldNotBeEmpty() + .shouldStartWith('Flu') + .shouldEndWith('ter') + .shouldContain('utt') + .shouldHaveLength(7), + returnsNormally, + ); + }); + }); } diff --git a/test/src/temporal/temporal_assertions_test.dart b/test/src/temporal/temporal_assertions_test.dart new file mode 100644 index 0000000..5a23ce3 --- /dev/null +++ b/test/src/temporal/temporal_assertions_test.dart @@ -0,0 +1,184 @@ +import 'package:fluent_assertions/fluent_assertions.dart'; +import 'package:test/test.dart'; + +import '../util/matchers.dart'; + +void main() { + group('DateTimeAssertions', () { + final d1 = DateTime(2023, 5, 10, 12); + final d2 = DateTime(2023, 5, 10, 15); + final d3 = DateTime(2023, 5, 11, 12); + + group('after / before', () { + test('shouldBeAfter passes when strictly after', () { + expect(() => d2.shouldBeAfter(d1), returnsNormally); + }); + + test('shouldBeAfter fails when before or equal', () { + expect(() => d1.shouldBeAfter(d2), failsTest); + expect(() => d1.shouldBeAfter(d1), failsTest); + }); + + test('shouldNotBeAfter passes when before or equal', () { + expect(() => d1.shouldNotBeAfter(d2), returnsNormally); + expect(() => d1.shouldNotBeAfter(d1), returnsNormally); + }); + + test('shouldBeBefore passes when strictly before', () { + expect(() => d1.shouldBeBefore(d2), returnsNormally); + }); + + test('shouldBeBefore fails when after or equal', () { + expect(() => d2.shouldBeBefore(d1), failsTest); + expect(() => d2.shouldBeBefore(d2), failsTest); + }); + + test('shouldNotBeBefore passes when after or equal', () { + expect(() => d2.shouldNotBeBefore(d1), returnsNormally); + expect(() => d2.shouldNotBeBefore(d2), returnsNormally); + }); + }); + + group('same moment', () { + test( + 'shouldBeAtSameMomentAs passes for same moment even with different timezones', + () { + final utc = DateTime.utc(2023, 5, 10, 12); + final local = utc.toLocal(); + expect(() => utc.shouldBeAtSameMomentAs(local), returnsNormally); + }); + + test('shouldBeAtSameMomentAs fails for different moments', () { + expect(() => d1.shouldBeAtSameMomentAs(d2), failsTest); + }); + + test('shouldNotBeAtSameMomentAs passes for different moments', () { + expect(() => d1.shouldNotBeAtSameMomentAs(d2), returnsNormally); + }); + + test('shouldNotBeAtSameMomentAs fails for same moment', () { + expect(() => d1.shouldNotBeAtSameMomentAs(d1), failsTest); + }); + }); + + group('between', () { + test('shouldBeBetween passes when within range or on bounds', () { + expect(() => d2.shouldBeBetween(d1, d3), returnsNormally); + expect(() => d1.shouldBeBetween(d1, d3), returnsNormally); + expect(() => d3.shouldBeBetween(d1, d3), returnsNormally); + }); + + test('shouldBeBetween fails when out of range', () { + final early = DateTime(2020); + expect(() => early.shouldBeBetween(d1, d3), failsTest); + }); + + test('shouldNotBeBetween passes when outside range', () { + final late = DateTime(2025); + expect(() => late.shouldNotBeBetween(d1, d3), returnsNormally); + }); + + test('shouldNotBeBetween fails when within range', () { + expect(() => d2.shouldNotBeBetween(d1, d3), failsTest); + }); + }); + + group('calendar parts and near', () { + test('shouldBeInYear / Month / Day', () { + expect(() => d1.shouldBeInYear(2023), returnsNormally); + expect(() => d1.shouldBeInYear(2022), failsTest); + + expect(() => d1.shouldBeInMonth(5), returnsNormally); + expect(() => d1.shouldBeInMonth(6), failsTest); + + expect(() => d1.shouldBeOnDay(10), returnsNormally); + expect(() => d1.shouldBeOnDay(11), failsTest); + }); + + test('shouldBeOnDate ignores time components', () { + final differentTime = DateTime(2023, 5, 10, 23, 59, 59); + expect(() => d1.shouldBeOnDate(differentTime), returnsNormally); + + final differentDate = DateTime(2023, 5, 11, 12); + expect(() => d1.shouldBeOnDate(differentDate), failsTest); + }); + + test('shouldBeNear and shouldNotBeNear with duration delta', () { + final dPlus10 = d1.add(const Duration(minutes: 10)); + expect( + () => d1.shouldBeNear(dPlus10, delta: const Duration(minutes: 15)), + returnsNormally, + ); + expect( + () => d1.shouldBeNear(dPlus10, delta: const Duration(minutes: 5)), + failsTest, + ); + + expect( + () => d1.shouldNotBeNear(dPlus10, delta: const Duration(minutes: 5)), + returnsNormally, + ); + expect( + () => d1.shouldNotBeNear(dPlus10, delta: const Duration(minutes: 15)), + failsTest, + ); + }); + + test('supports fluid chaining', () { + expect( + () => d1 + .shouldBeInYear(2023) + .shouldBeInMonth(5) + .shouldBeOnDay(10) + .shouldBeBefore(d2), + returnsNormally, + ); + }); + }); + }); + + group('DurationAssertions', () { + const d10m = Duration(minutes: 10); + const d5m = Duration(minutes: 5); + const neg = Duration(minutes: -1); + const zero = Duration.zero; + + test('longer / shorter than', () { + expect(() => d10m.shouldBeLongerThan(d5m), returnsNormally); + expect(() => d5m.shouldBeLongerThan(d10m), failsTest); + + expect(() => d5m.shouldBeShorterThan(d10m), returnsNormally); + expect(() => d10m.shouldBeShorterThan(d5m), failsTest); + }); + + test('equality', () { + expect(() => d10m.shouldBeEqualTo(const Duration(seconds: 600)), + returnsNormally); + expect(() => d10m.shouldNotBeEqualTo(d5m), returnsNormally); + + expect(() => d10m.shouldBeEqualTo(d5m), failsTest); + expect(() => d10m.shouldNotBeEqualTo(const Duration(seconds: 600)), + failsTest); + }); + + test('positive / negative / zero', () { + expect(() => d10m.shouldBePositive(), returnsNormally); + expect(() => d10m.shouldNotBeNegative(), returnsNormally); + expect(() => d10m.shouldNotBeZero(), returnsNormally); + + expect(() => neg.shouldBeNegative(), returnsNormally); + expect(() => neg.shouldNotBePositive(), returnsNormally); + + expect(() => zero.shouldBeZero(), returnsNormally); + expect(() => zero.shouldNotBePositive(), returnsNormally); + expect(() => zero.shouldNotBeNegative(), returnsNormally); + }); + + test('supports fluid chaining', () { + expect( + () => d10m.shouldBePositive().shouldBeLongerThan(d5m).shouldNotBeZero(), + returnsNormally, + ); + }); + }); +}