From 3ac7fd7cebbb6546bc9d248c0bc3f62bdc250f32 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 21 Dec 2025 19:11:37 +0100 Subject: [PATCH] Add `assert`-related testing functions, `findFirst`/`findFirstOrNull` methods, and expand documentation. --- .gitignore | 3 +- README.md | 1 + docs/Iterable.md | 19 ++++ docs/Testing.md | 94 +++++++++++++++++++ docs/tutorial.md | 7 +- .../kotlin/net/sergeych/lyng/Script.kt | 24 ++++- lynglib/src/commonTest/kotlin/ScriptTest.kt | 14 +++ lynglib/stdlib/lyng/root.lyng | 47 +++++++--- 8 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 docs/Testing.md diff --git a/.gitignore b/.gitignore index 25e225a..b5f10f1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ xcuserdata /test.lyng /sample_texts/1.txt.gz /kotlin-js-store/wasm/yarn.lock -/distributables \ No newline at end of file +/distributables +/.output.txt diff --git a/README.md b/README.md index 23a85f9..d77ce24 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ and it is multithreaded on platforms supporting it (automatically, no code chang - [Language home](https://lynglang.com) - [introduction and tutorial](docs/tutorial.md) - start here please +- [Testing and Assertions](docs/Testing.md) - [Samples directory](docs/samples) - [Formatter (core + CLI + IDE)](docs/formatter.md) - [Books directory](docs) diff --git a/docs/Iterable.md b/docs/Iterable.md index c4d874c..80cff82 100644 --- a/docs/Iterable.md +++ b/docs/Iterable.md @@ -81,6 +81,21 @@ Used to transform either the whole iterable stream or also skipping som elements >>> void +## findFirst and findFirstOrNull + +Search for the first element that satisfies the given predicate: + + val source = [1, 2, 3, 4] + assertEquals( 2, source.findFirst { it % 2 == 0 } ) + assertEquals( 2, source.findFirstOrNull { it % 2 == 0 } ) + + // findFirst throws if not found: + assertThrows( NoSuchElementException ) { source.findFirst { it > 10 } } + + // findFirstOrNull returns null if not found: + assertEquals( null, source.findFirstOrNull { it > 10 } ) + + >>> void ## Instance methods: @@ -96,6 +111,8 @@ Used to transform either the whole iterable stream or also skipping som elements | map(f) | create a list of values returned by `f` called for each element of the iterable | | indexOf(i) | return index if the first encounter of i or a negative value if not found | | associateBy(kf) | create a map where keys are returned by kf that will be called for each element | +| findFirst(p) | return first element matching predicate `p` or throw (1) | +| findFirstOrNull(p) | return first element matching predicate `p` or `null` | | first | first element (1) | | last | last element (1) | | take(n) | return [Iterable] of up to n first elements | @@ -129,6 +146,8 @@ optional function applied to each item that must return result string for an ite fun Iterable.forEach(block: (Any?)->Void ): Void fun Iterable.map(block: (Any?)->Void ): List fun Iterable.associateBy( keyMaker: (Any?)->Any): Map + fun Iterable.findFirst( predicate: (Any?)->Bool): Any + fun Iterable.findFirstOrNull( predicate: (Any?)->Bool): Any? ## Abstract methods: diff --git a/docs/Testing.md b/docs/Testing.md new file mode 100644 index 0000000..51ad00d --- /dev/null +++ b/docs/Testing.md @@ -0,0 +1,94 @@ +# Testing and Assertions + +Lyng provides several built-in functions for testing and verifying code behavior. These are available in all scripts. + +## Basic Assertions + +### `assert` + +Assert that a condition is true. + + assert(condition, message=null) + +- `condition`: A boolean expression. +- `message` (optional): A string message to include in the exception if the assertion fails. + +If the condition is false, it throws an `AssertionFailedException`. + +```lyng +assert(1 + 1 == 2) +assert(true, "This should be true") +``` + +### `assertEquals` and `assertEqual` + +Assert that two values are equal. `assertEqual` is an alias for `assertEquals`. + + assertEquals(expected, actual) + assertEqual(expected, actual) + +If `expected != actual`, it throws an `AssertionFailedException` with a message showing both values. + +```lyng +assertEquals(4, 2 * 2) +assertEqual("hello", "hel" + "lo") +``` + +### `assertNotEquals` + +Assert that two values are not equal. + + assertNotEquals(unexpected, actual) + +If `unexpected == actual`, it throws an `AssertionFailedException`. + +```lyng +assertNotEquals(5, 2 * 2) +``` + +## Exception Testing + +### `assertThrows` + +Assert that a block of code throws an exception. + + assertThrows(code) + assertThrows(expectedExceptionClass, code) + +- `expectedExceptionClass` (optional): The class of the exception that is expected to be thrown. +- `code`: A lambda block or statement to execute. + +If the code does not throw an exception, an `AssertionFailedException` is raised. +If an `expectedExceptionClass` is provided, the thrown exception must be of that class (or its subclass), otherwise an error is raised. + +`assertThrows` returns the caught exception object if successful. + +```lyng +// Just assert that something is thrown +assertThrows { 1 / 0 } + +// Assert that a specific exception class is thrown +assertThrows(NoSuchElementException) { + [1, 2, 3].findFirst { it > 10 } +} + +// You can use the returned exception +val ex = assertThrows { throw Exception("custom error") } +assertEquals("custom error", ex.message) +``` + +## Other Validation Functions + +While not strictly for testing, these functions help in defensive programming: + +### `require` + + require(condition, message="requirement not met") + +Throws an `IllegalArgumentException` if the condition is false. Use this for validating function arguments. + +### `check` + + check(condition, message="check failed") + +Throws an `IllegalStateException` if the condition is false. Use this for validating internal state. diff --git a/docs/tutorial.md b/docs/tutorial.md index 521c459..4d5974c 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -9,6 +9,7 @@ __Other documents to read__ maybe after this one: - [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.md), [Scopes and Closures](scopes_and_closures.md) - [OOP notes](OOP.md), [exception handling](exceptions_handling.md) - [math in Lyng](math.md), [the `when` statement](when.md) +- [Testing and Assertions](Testing.md) - [time](time.md) and [parallelism](parallelism.md) - [parallelism] - multithreaded code, coroutines, etc. - Some class @@ -329,7 +330,7 @@ will be thrown: // WRONG! Exception will be thrown at next line: foo + "bar" -Correct pattern is: +The correct pattern is: foo = "foo" // now is OK: @@ -478,6 +479,8 @@ one could be with ellipsis that means "the rest pf arguments as List": ### Using lambda as the parameter +See also: [Testing and Assertions](Testing.md) + // note that fun returns its last calculated value, // in our case, result after in-place addition: fun mapValues(iterable, transform) { @@ -1466,6 +1469,8 @@ Lambda avoid unnecessary execution if assertion is not failed. for example: [List]: List.md +[Testing]: Testing.md + [Iterable]: Iterable.md [Iterator]: Iterator.md diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 2fa7a82..a3df3e6 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -204,7 +204,19 @@ class Script( ) } addFn("assertThrows") { - val code = requireOnlyArg() + val code: Statement + val expectedClass: ObjClass? + when(args.size) { + 1 -> { + code = requiredArg(0) + expectedClass = null + } + 2 -> { + code = requiredArg(1) + expectedClass = requiredArg(0) + } + else -> raiseIllegalArgument("Expected 1 or 2 arguments, got ${args.size}") + } val result = try { code.execute(this) null @@ -213,7 +225,15 @@ class Script( } catch (_: ScriptError) { ObjNull } - result ?: raiseError(ObjAssertionFailedException(this, "Expected exception but nothing was thrown")) + if( result == null ) raiseError(ObjAssertionFailedException(this, "Expected exception but nothing was thrown")) + expectedClass?.let { + if( result !is ObjException) + raiseError("Expected $expectedClass, got $result") + if (result.exceptionClass != expectedClass) { + raiseError("Expected $expectedClass, got ${result.exceptionClass}") + } + } + result } addFn("dynamic") { diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index a174630..1ea2ebb 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4270,4 +4270,18 @@ class ScriptTest { assertEquals(51, r.toInt()) } + @Test + fun testFirstInEnum() = runTest { + eval(""" + enum E { + one, two, three + } + println(E.entries) + assertEquals( E.two, E.entries.findFirst { + println(it.name) + it.name in ["aaa", "two"] + } ) + + """.trimIndent()) + } } diff --git a/lynglib/stdlib/lyng/root.lyng b/lynglib/stdlib/lyng/root.lyng index 0a4ea17..8f22b52 100644 --- a/lynglib/stdlib/lyng/root.lyng +++ b/lynglib/stdlib/lyng/root.lyng @@ -1,8 +1,8 @@ package lyng.stdlib - + /* - Wrap a builder into a zero-argument thunk that computes once and caches the result. - The first call invokes builder() and stores the value; subsequent calls return the cached value. +Wrap a builder into a zero-argument thunk that computes once and caches the result. +The first call invokes builder() and stores the value; subsequent calls return the cached value. */ fun cached(builder) { var calculated = false @@ -37,9 +37,33 @@ fun Iterable.drop(n) { fun Iterable.first() { val i = iterator() if( !i.hasNext() ) throw NoSuchElementException() - i.next().also { i.cancelIteration() } + i.next().also { i.cancelIteration() } } +/* +Return the first element that matches the predicate or throws +NuSuchElementException +*/ +fun Iterable.findFirst(predicate) { + for( x in this ) { + if( predicate(x) ) + break x + } + else throw NoSuchElementException() +} + +/* +return the first element matching the predicate or null +*/ +fun Iterable.findFirstOrNull(predicate) { + for( x in this ) { + if( predicate(x) ) + break x + } + else null +} + + /* Return the last element or throw if the iterable is empty. */ fun Iterable.last() { var found = false @@ -49,7 +73,7 @@ fun Iterable.last() { found = true } if( !found ) throw NoSuchElementException() - element + element } /* Emit all but the last N elements of this iterable. */ @@ -58,7 +82,7 @@ fun Iterable.dropLast(n) { val buffer = RingBuffer(n) flow { for( item in list ) { - if( buffer.size == n ) + if( buffer.size == n ) emit( buffer.first() ) buffer += item } @@ -129,7 +153,7 @@ fun Iterable.minOf( lambda ) { minimum } -/* Maximum value of the given function applied to elements of the collection. */ +/* Maximum value of the given function applied to elements of the collection. */ fun Iterable.maxOf( lambda ) { val i = iterator() var maximum = lambda( i.next() ) @@ -172,10 +196,10 @@ fun List.sort() { /* Represents a single stack trace element. */ class StackTraceEntry( - val sourceName: String, - val line: Int, - val column: Int, - val sourceString: String + val sourceName: String, + val line: Int, + val column: Int, + val sourceString: String ) { /* Formatted representation: source:line:column: text. */ fun toString() { @@ -194,4 +218,3 @@ fun Exception.printStackTrace() { /* Compile this string into a regular expression. */ fun String.re(): Regex { Regex(this) } - \ No newline at end of file