From 91de1b6eb41470558142fb7d5ad9554140dc1313 Mon Sep 17 00:00:00 2001 From: Corneliu Tusnea Date: Wed, 25 Feb 2026 12:12:05 +1100 Subject: [PATCH] Added support for isl-level testing --- build.gradle.kts | 9 + docs/cli.md | 56 +- docs/ext/unit-testing/annotations.md | 107 +- docs/ext/unit-testing/assertions.md | 134 ++- docs/ext/unit-testing/index.md | 163 ++- docs/ext/unit-testing/loading.md | 118 ++ docs/ext/unit-testing/mocking.md | 107 +- docs/ext/unit-testing/setup.md | 123 +- docs/index.md | 1 + isl-cmd/build.gradle.kts | 7 + .../com/intuit/isl/cmd/IslCommandLine.kt | 3 +- .../com/intuit/isl/cmd/LogExtensions.kt | 77 ++ .../kotlin/com/intuit/isl/cmd/TestCommand.kt | 252 ++++ .../com/intuit/isl/cmd/TransformCommand.kt | 3 +- .../com/intuit/isl/cmd/IslCommandLineTest.kt | 29 + isl-test/build.gradle.kts | 93 ++ .../com/intuit/isl/test/LoadFunction.kt | 98 ++ .../intuit/isl/test/TestOperationContext.kt | 93 ++ .../isl/test/TestOperationMockExtensions.kt | 11 + .../com/intuit/isl/test/TransformTestFile.kt | 7 + .../intuit/isl/test/TransformTestPackage.kt | 97 ++ .../isl/test/TransformTestPackageBuilder.kt | 21 + .../isl/test/annotations/SetupAnnotation.kt | 12 + .../isl/test/annotations/TestAnnotation.kt | 84 ++ .../intuit/isl/test/annotations/TestResult.kt | 15 + .../isl/test/annotations/TestResultContext.kt | 3 + .../isl/test/assertions/AssertException.kt | 11 + .../isl/test/assertions/AssertFunction.kt | 257 +++++ .../assertions/ComparisonAssertException.kt | 12 + .../assertions/EvaluationAssertException.kt | 11 + .../isl/test/mocks/MockCaptureContext.kt | 7 + .../com/intuit/isl/test/mocks/MockContext.kt | 10 + .../com/intuit/isl/test/mocks/MockFunction.kt | 339 ++++++ .../com/intuit/isl/test/mocks/MockMatcher.kt | 92 ++ .../com/intuit/isl/test/mocks/MockObject.kt | 104 ++ .../isl/test/mocks/MockParamsMatcher.kt | 30 + .../kotlin/com/intuit/isl/test/package.kt | 7 + .../com/intuit/isl/test/IslTestModuleTest.kt | 1028 +++++++++++++++++ .../com/intuit/isl/test/LoadFunctionTest.kt | 127 ++ .../com/intuit/isl/test/MockLoadTest.kt | 169 +++ isl.bat | 10 +- isl.sh | 5 +- settings.gradle.kts | 1 + 43 files changed, 3820 insertions(+), 123 deletions(-) create mode 100644 docs/ext/unit-testing/loading.md create mode 100644 isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt create mode 100644 isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt create mode 100644 isl-test/build.gradle.kts create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockContext.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockFunction.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockMatcher.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockObject.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockParamsMatcher.kt create mode 100644 isl-test/src/main/kotlin/com/intuit/isl/test/package.kt create mode 100644 isl-test/src/test/kotlin/com/intuit/isl/test/IslTestModuleTest.kt create mode 100644 isl-test/src/test/kotlin/com/intuit/isl/test/LoadFunctionTest.kt create mode 100644 isl-test/src/test/kotlin/com/intuit/isl/test/MockLoadTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 90b636b..be0e6f2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -114,6 +114,15 @@ configure(subprojects.filter { it.name in publishModules }) { } } +tasks.register("copyIslToPlugin") { + group = "build" + description = "Build isl-cmd fat JAR and copy to plugin/lib for extension use" + dependsOn(":isl-cmd:shadowJar") + from(project(":isl-cmd").tasks.named("shadowJar").map { (it as org.gradle.api.tasks.bundling.Jar).archiveFile }) + into(file("plugin/lib")) + rename { "isl-cmd-all.jar" } +} + tasks.register("publishToMavenCentral") { group = "publishing" description = "Publish all modules to Maven Central" diff --git a/docs/cli.md b/docs/cli.md index ee0c6a3..6555d9a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -97,7 +97,7 @@ For development, you can run directly with Gradle: ## Basic Usage -The ISL CLI has three main commands: +The ISL CLI has four main commands: ### 1. Transform Command @@ -138,7 +138,38 @@ isl validate script.isl # If invalid, shows error details and exit code 1 ``` -### 3. Info Command +### 3. Test Command + +Run ISL unit tests. Discovers `.isl` files containing `@test` or `@setup` annotations and executes them. + +```bash +# Run tests in current directory (default: **/*.isl) +isl test + +# Run tests in a specific path (directory, file, or glob) +isl test tests/ +isl test tests/sample.isl + +# Custom glob pattern +isl test tests/ --glob "**/*.test.isl" + +# Write results to JSON file +isl test -o results.json +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `path` | Directory, file, or glob to search (default: current directory) | +| `--glob PATTERN` | Glob to filter files when path is a directory (default: `**/*.isl`) | +| `-o, --output FILE` | Write test results to JSON file | + +Exit code: 0 if all tests pass, 1 if any fail. + +See [Unit Testing](../ext/unit-testing/index.md) for writing tests, assertions, and loading fixtures. + +### 4. Info Command Display version and system information: @@ -176,6 +207,27 @@ isl info debug=true isl transform script.isl -i input.json ``` +### Logging from ISL Scripts + +When running transforms or tests from the CLI, you can log messages from your ISL scripts: + +```isl +@.Log.Info("Processing started") +@.Log.Info("Item count:", $count) +@.Log.Warn("Unexpected value:", $value) +@.Log.Error("Failed:", $error) +@.Log.Debug("Debug info") // Only outputs when -Ddebug=true +``` + +| Function | Output | When | +|----------|--------|------| +| `@.Log.Debug(...)` | stdout | Only when `-Ddebug=true` | +| `@.Log.Info(...)` | stdout | Always | +| `@.Log.Warn(...)` | stderr | Always | +| `@.Log.Error(...)` | stderr | Always | + +All functions accept multiple arguments (strings, variables, expressions); they are joined with spaces. JSON objects are pretty-printed. + ## Working with Input Data ### Using Input Files diff --git a/docs/ext/unit-testing/annotations.md b/docs/ext/unit-testing/annotations.md index 05e015d..63d6a1b 100644 --- a/docs/ext/unit-testing/annotations.md +++ b/docs/ext/unit-testing/annotations.md @@ -5,33 +5,116 @@ grand_parent: Advanced Topics nav_order: 3 --- -## Test +# Test Annotations -To create a unit test, we need to utilise the `@test` annotation to denote that the function -is a unit test. +## @test -```kotlin +Marks a function as a unit test. The function runs as a test case when tests are executed. + +### Basic form + +```isl @test fun test_addNumbers() { - ... + $sum: 1 + 2; + @.Assert.equal(3, $sum); +} +``` + +When no parameters are given, the function name is used as the test name. + +### Custom display name + +```isl +@test("Addition of positive numbers") +fun test_addNumbers() { + @.Assert.equal(3, 1 + 2); } ``` -In the test function, we can write regular ISL code, along with additional functions specific for testing capabilities. +### Name and group + +```isl +@test("Check total", "math") +fun test_total() { + @.Assert.equal(10, 5 + 5); +} +``` + +### Object form + +```isl +@test({ name: "Grouped test", group: "math" }) +fun test_grouped() { + $value: 30; + @.Assert.equal(30, $value); +} +``` + +Use the object form when you need both a custom name and group. + +### Parameter summary + +| Form | Example | Result | +|------|---------|--------| +| No params | `@test` | Test name = function name | +| String | `@test("My test")` | Test name = "My test" | +| Two strings | `@test("Name", "group")` | Custom name and group | +| Object | `@test({ name: "x", group: "y" })` | Custom name and group | + +## @setup -## Setup +Marks a function to run **before each test** in the same file. Use it for shared initialization. -We can also use the `@setup` annotation to denote actions we wish to repeat for each unit tests (e.g. setup of something specific) +- At most **one** `@setup` function per file +- Runs before every `@test` function in that file +- Does not run as a test itself -```kotlin +```isl @setup fun setup() { - // Perform actions required before executing test + $sharedState: { count: 0 }; + // This runs before each test } +@test +fun test_first() { + // setup() already ran + @.Assert.equal(1, 1); +} @test -fun test_addNumbers() { - ... +fun test_second() { + // setup() runs again before this test + @.Assert.equal(2, 2); +} +``` + +## File structure + +A typical test file: + +```isl +// Optional: imports +import Helper from "../helper.isl"; + +@setup +fun setup() { + // Runs before each test +} + +@test +fun test_basic() { + // Test code +} + +@test("Custom name") +fun test_withName() { + // Test code +} + +@test({ name: "Edge case", group: "validation" }) +fun test_edgeCase() { + // Test code } ``` diff --git a/docs/ext/unit-testing/assertions.md b/docs/ext/unit-testing/assertions.md index 4eef7c0..99ed4b1 100644 --- a/docs/ext/unit-testing/assertions.md +++ b/docs/ext/unit-testing/assertions.md @@ -5,100 +5,116 @@ grand_parent: Advanced Topics nav_order: 2 --- -In order to verify values within the unit tests, we can utilise the Assertion framework. +# Assertions -These are accessible under the `@.Assert` namespace. +Assertions verify values in unit tests. They are available under the `@.Assert` namespace. All assertion methods accept an optional third parameter `message` for custom failure output. -## Equal +## Equality -### Description +### equal -Verifies whether or not values are equal. +Verifies that two values are equal (deep equality for objects and arrays; property order is ignored for objects). -### Syntax +**Syntax:** `@.Assert.equal(expected, actual, message?)` -`@.Assert.Equal($expectedValue, $actualValue, $msg)` - -- `$expectedValue`: Expected value. -- `$actualValue`: Actual value to verify against. -- `$msg`: Optional error message to show if assertion fails. - -### Example - -```kotlin +```isl @test -fun assert_equals() { - $var1 : 20 - @.Assert.Equal(40, $var1, "Values don't match."); +fun test_equal() { + $var1: 20; + @.Assert.equal(20, $var1); + + $obj1: { a: 1, b: 2 }; + $obj2: { b: 2, a: 1 }; + @.Assert.equal($obj1, $obj2); // Objects equal despite property order } ``` -## NotEqual - -### Description - -Verifies whether or not values are not equal. - -### Syntax +### notEqual -`@.Assert.NotEqual($expectedValue, $actualValue, $msg)` +Verifies that two values are not equal. -- `$expectedValue`: Expected value. -- `$actualValue`: Actual value to verify against. -- `$msg`: Optional error message to show if assertion fails. +**Syntax:** `@.Assert.notEqual(expected, actual, message?)` -### Example - -```kotlin +```isl @test -fun assert_equals() { - $var1 : 20 - @.Assert.NotEqual(40, $var1, "Values match."); +fun test_notEqual() { + $var1: 20; + @.Assert.notEqual(40, $var1, "Values should differ"); } ``` -## NotNull +## Null Checks -### Description +### notNull -Verifies whether or not value is not null. +Verifies that a value is not null. -### Syntax +**Syntax:** `@.Assert.notNull(value, message?)` -`@.Assert.NotNull($value, $msg)` +```isl +@test +fun test_notNull() { + $var1: 42; + @.Assert.notNull($var1, "Value should not be null"); +} +``` -- `$value`: Expected value. -- `$msg`: Optional error message to show if assertion fails. +### isNull -### Example +Verifies that a value is null. -```kotlin +**Syntax:** `@.Assert.isNull(value, message?)` + +```isl @test -fun assert_equals() { - $var1 : null - @.Assert.NotNull($var1, "Value is null"); +fun test_isNull() { + $var1: null; + @.Assert.isNull($var1, "Value should be null"); } ``` -## IsNull +## Comparisons + +| Assertion | Description | +|-----------|-------------| +| `@.Assert.lessThan(a, b)` | a < b | +| `@.Assert.lessThanOrEqual(a, b)` | a <= b | +| `@.Assert.greaterThan(a, b)` | a > b | +| `@.Assert.greaterThanOrEqual(a, b)` | a >= b | -### Description +## String and Pattern -Verifies whether or not value is null. +| Assertion | Description | +|-----------|-------------| +| `@.Assert.matches(pattern, value)` | value matches regex pattern | +| `@.Assert.notMatches(pattern, value)` | value does not match pattern | +| `@.Assert.contains(expected, actual)` | actual contains expected | +| `@.Assert.notContains(expected, actual)` | actual does not contain expected | +| `@.Assert.startsWith(prefix, value)` | value starts with prefix | +| `@.Assert.notStartsWith(prefix, value)` | value does not start with prefix | +| `@.Assert.endsWith(suffix, value)` | value ends with suffix | +| `@.Assert.notEndsWith(suffix, value)` | value does not end with suffix | -### Syntax +## Membership and Type -`@.Assert.IsNull($value, $msg)` +| Assertion | Description | +|-----------|-------------| +| `@.Assert.in(value, collection)` | value is in collection | +| `@.Assert.notIn(value, collection)` | value is not in collection | +| `@.Assert.isType(value, type)` | value is of type (e.g. `number`, `string`, `array`, `node`, `date`) | +| `@.Assert.isNotType(value, type)` | value is not of type | -- `$value`: Expected value. -- `$msg`: Optional error message to show if assertion fails. +## Custom Failure Message -### Example +All assertions accept an optional third parameter for a custom message: -```kotlin +```isl @test -fun assert_equals() { - $var1 : null - @.Assert.NotNull($var1, "Value not null"); +fun test_withMessage() { + $var1: 1; + $var2: 2; + @.Assert.equal($var1, $var2, "Expected 1 to equal 2 - values mismatch"); } ``` + +When the assertion fails, the custom message is included in the output. diff --git a/docs/ext/unit-testing/index.md b/docs/ext/unit-testing/index.md index 6e0ef59..9a49581 100644 --- a/docs/ext/unit-testing/index.md +++ b/docs/ext/unit-testing/index.md @@ -5,17 +5,162 @@ nav_order: 3 has_children: true --- -**WIP** +# ISL Unit Testing -This section is dedicated to the unit testing framework in ISL. +The ISL unit testing framework lets you write and run tests entirely in ISL. Tests live alongside your transformation code, use the same syntax, and can verify behavior without learning a separate testing language. -The goal of this framework is to add reliability to ISL code, by allowing creation of tests -to ensure critical behaviour within the script is maintained. +## Quick Start -The goal of the framework is to be able to create unit tests entirely within the ISL langauge, -ensuring developers can make changes to the ISL tests when necessary, without the learning curve of -learning a new testing methodology. +1. Create an `.isl` file with `@test`-annotated functions: -The syntax and structure of the ISL testing framework is inspired by JUnit. +```isl +@test +fun test_simpleAssertion() { + $value: 42; + @.Assert.equal(42, $value); +} +``` -More reading on the implementation can be found here: [link](https://docs.google.com/document/d/1E0wlMy5XrKL0lpgtu6IKqQRdbBUIz3f2gLZb5tR064g/edit?usp=sharing) +2. Run tests from the command line: + +```bash +isl test +# or specify a path +isl test tests/ +isl test tests/sample.isl +``` + +3. Or run tests programmatically from Kotlin/Java (see [Test Setup](setup.md)). + +## What You Can Test + +- **Transformations** – Call your ISL functions and assert on the output +- **Conditions** – Verify branching logic, edge cases +- **Modifiers** – Test `| trim`, `| map`, `| filter`, etc. +- **External integrations** – Use [mocking](mocking.md) to replace `@.Call.Api` and similar + +## File Format and Structure + +Tests are written in standard `.isl` files. A test file typically contains: + +- One optional `@setup` function (runs before each test) +- One or more `@test` functions (each is a test case) + +```isl +@setup +fun setup() { + $x: 1; // Shared setup runs before each test +} + +@test +fun test_basic() { + @.Assert.equal(1, 1); +} + +@test("Custom display name") +fun test_withName() { + @.Assert.equal(2, 2); +} + +@test({ name: "Grouped test", group: "math" }) +fun test_grouped() { + @.Assert.equal(3, 3); +} +``` + +File discovery: + +- **CLI**: `isl test` finds all `.isl` files (by default `**/*.isl`) containing `@setup` or `@test` +- **API**: You pass the list of files to `TransformTestPackageBuilder` + +## Attributes (Annotations) + +| Attribute | Description | +|-----------|-------------| +| `@test` | Marks a function as a test. Runs as a test case. | +| `@test("Name")` | Same, with a custom display name | +| `@test(name, group)` | Custom name and group for organization | +| `@test({ name: "x", group: "y" })` | Object form for name and group | +| `@setup` | Marks a function to run before each test in the file (at most one per file) | + +See [Test Annotations](annotations.md) for details. + +## Assertions + +Use `@.Assert` to verify values: + +| Assertion | Description | +|-----------|-------------| +| `@.Assert.equal(expected, actual, message?)` | Deep equality (objects, arrays, primitives) | +| `@.Assert.notEqual(expected, actual, message?)` | Values must differ | +| `@.Assert.notNull(value, message?)` | Value must not be null | +| `@.Assert.isNull(value, message?)` | Value must be null | +| `@.Assert.contains(expected, actual)` | actual contains expected | +| `@.Assert.matches(pattern, value)` | value matches regex | +| `@.Assert.startsWith(prefix, value)` | value starts with prefix | +| ... | See [Assertions](assertions.md) for the full list | + +## Loading Test Fixtures + +Use `@.Load.From(fileName)` to load JSON, YAML, or CSV files relative to the current ISL file: + +```isl +@test +fun test_withFixture() { + $data = @.Load.From("fixtures/input.json") + @.Assert.equal("expected", $data.name) + + $config = @.Load.From("config.yaml") + @.Assert.equal(10, $config.count) + + $rows = @.Load.From("fixtures/data.csv") + @.Assert.equal(2, $rows | length) +} +``` + +Supported formats: `.json`, `.yaml`, `.yml`, `.csv` (all converted to JSON). See [Loading Fixtures](loading.md). + +## How to Run Tests + +### Command Line (Recommended) + +```bash +isl test [path] [options] +``` + +- `path`: Directory, file, or glob (default: current directory) +- `--glob PATTERN`: Filter files (e.g. `**/*.isl`) +- `-o, --output FILE`: Write results to JSON + +### Programmatic (Kotlin/Java) + +See [Test Setup](setup.md) for adding the `isl-test` dependency and running tests from code. + +## Mocking + +Mock external functions (e.g. `@.Call.Api`) so tests don't hit real services: + +```isl +@test +fun test_withMock() { + @.Mock.Func("Call.Api", { status: 200, body: "ok" }) + $result = @.Call.Api("https://example.com") + @.Assert.equal(200, $result.status) +} +``` + +See [Mocking](mocking.md) for parameter matching, indexed (sequential) returns, loading mocks from files, captures, and annotation mocks. + +## Test Output + +- **CLI**: Prints pass/fail per test, with failure messages and locations +- **JSON**: Use `-o results.json` for machine-readable output +- **Exit code**: 1 if any test failed, 0 if all passed + +## Next Steps + +- [Test Setup](setup.md) – CLI usage, dependencies, programmatic API +- [Test Annotations](annotations.md) – `@test` and `@setup` in detail +- [Assertions](assertions.md) – Full assertion reference +- [Loading Fixtures](loading.md) – `@.Load.From` for JSON/YAML/CSV +- [Mocking](mocking.md) – Mock functions and annotations diff --git a/docs/ext/unit-testing/loading.md b/docs/ext/unit-testing/loading.md new file mode 100644 index 0000000..ad346c3 --- /dev/null +++ b/docs/ext/unit-testing/loading.md @@ -0,0 +1,118 @@ +--- +title: Loading Fixtures +parent: Unit Testing +grand_parent: Advanced Topics +nav_order: 5 +--- + +# Loading Test Fixtures + +Use `@.Load.From(fileName)` to load JSON, YAML, or CSV files as test data. Paths are resolved **relative to the directory of the current ISL file**. + +## Syntax + +```isl +$data = @.Load.From("fileName") +``` + +- `fileName` – Path relative to the current file (e.g. `fixtures/data.json`, `../shared/config.yaml`) + +## Supported Formats + +| Extension | Description | +|-----------|-------------| +| `.json` | Parsed as JSON | +| `.yaml`, `.yml` | Parsed as YAML, converted to JSON | +| `.csv` | Parsed as CSV; first row = headers, converted to array of objects | + +All formats are returned as JSON (objects or arrays) for use in assertions. + +## Examples + +### JSON + +**fixtures/user.json:** +```json +{"name": "Alice", "age": 30, "active": true} +``` + +**tests/user.isl:** +```isl +@test +fun test_loadJson() { + $user = @.Load.From("fixtures/user.json") + @.Assert.equal("Alice", $user.name) + @.Assert.equal(30, $user.age) + @.Assert.equal(true, $user.active) +} +``` + +### YAML + +**fixtures/config.yaml:** +```yaml +key: value +nested: + count: 10 + items: [a, b, c] +``` + +**tests/config.isl:** +```isl +@test +fun test_loadYaml() { + $config = @.Load.From("fixtures/config.yaml") + @.Assert.equal("value", $config.key) + @.Assert.equal(10, $config.nested.count) + @.Assert.equal(3, $config.nested.items | length) +} +``` + +### CSV + +**fixtures/data.csv:** +```csv +id,name,score +1,Alice,100 +2,Bob,85 +``` + +**tests/data.isl:** +```isl +@test +fun test_loadCsv() { + $rows = @.Load.From("fixtures/data.csv") + @.Assert.equal(2, $rows | length) + @.Assert.equal("Alice", $rows[0].name) + @.Assert.equal(100, $rows[0].score) + @.Assert.equal("Bob", $rows[1].name) +} +``` + +CSV is parsed with the first row as headers. Each subsequent row becomes an object with those headers as keys. + +## Path Resolution + +Paths are relative to the **directory of the current ISL file**: + +| Current file | `fileName` | Resolved path | +|--------------|------------|---------------| +| `tests/sample.isl` | `fixtures/data.json` | `tests/fixtures/data.json` | +| `tests/sample.isl` | `../shared/config.yaml` | `shared/config.yaml` | +| `tests/unit/sample.isl` | `../../fixtures/data.json` | `fixtures/data.json` | + +## Availability + +`@.Load.From` is available only in the **test context**: + +- ✅ When running tests via `isl test` +- ✅ When running via `TransformTestPackage` with `basePath` passed to `TransformTestPackageBuilder` +- ❌ In regular transform execution (e.g. `isl transform`) + +If `basePath` is not set (e.g. programmatic use without it), `Load.From` throws a clear error. + +## Error Handling + +- **File not found**: Throws with the resolved path +- **Unsupported format**: Throws for extensions other than `.json`, `.yaml`, `.yml`, `.csv` +- **Parse error**: Invalid JSON/YAML/CSV throws during parsing diff --git a/docs/ext/unit-testing/mocking.md b/docs/ext/unit-testing/mocking.md index 7a6f939..10527ad 100644 --- a/docs/ext/unit-testing/mocking.md +++ b/docs/ext/unit-testing/mocking.md @@ -102,6 +102,77 @@ fun assert_mock() { } ``` +### Indexed mocking (sequential returns) + +When a function is called multiple times with the same parameters, you can return different values per call by appending `#1`, `#2`, `#3`, etc. to the function name. Each index corresponds to the Nth invocation. + +- **Standard behaviour** – `@.Mock.Func("Data.GetData", value)` returns the same value on every call. +- **Indexed behaviour** – `@.Mock.Func("Data.GetData#1", value1)` returns `value1` on the first call, `@.Mock.Func("Data.GetData#2", value2)` on the second, and so on. + +On exhaustion (when the function is called more times than defined), the mock fails with a clear error. + +#### Example + +```kotlin +@test +fun assert_indexed_mock() { + @.Mock.Func("Data.GetData#1", [ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 } ]) + @.Mock.Func("Data.GetData#2", [ { id: 1 }, { id: 2 }, { id: 3 } ]) + @.Mock.Func("Data.GetData#3", []) + + $r1 : @.Data.GetData() + $r2 : @.Data.GetData() + $r3 : @.Data.GetData() + + @.Assert.equal(5, $r1 | length) + @.Assert.equal(3, $r2 | length) + @.Assert.equal(0, $r3 | length) +} +``` + +Indexed mocking works for both `@.Mock.Func` and `@.Mock.Annotation`. When using `@.Mock.GetFuncCaptures` or `@.Mock.GetAnnotationCaptures`, you can pass the base name (with or without `#index`); captures are associated with the base function. + +### Loading mocks from a file + +Use `@.Mock.Load(relativeFileName)` to load mocks from a YAML or JSON file. The path is resolved relative to the directory of the current ISL file (same as `@.Load.From`). + +The file format mirrors the existing mock functions: + +```yaml +func: + - name: "Data.GetData#1" + return: [ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 } ] + - name: "Data.GetData#2" + return: [ { id: 1 }, { id: 2 }, { id: 3 } ] + - name: "Data.GetData#3" + return: [] + - name: "Api.Call" + return: { status: 200, body: "ok" } + params: [ "https://example.com" ] # optional parameter matching + +annotation: + - name: "cache#1" + return: "cached-value" +``` + +- `func` and `annotation` are arrays of mock entries. +- Each entry has `name` (required), `return` (required), and optionally `params` (array of values to match). +- Use `#1`, `#2`, etc. in the name for indexed (sequential) returns. +- Supports `.yaml`, `.yml`, and `.json` files. + +#### Example + +```kotlin +@test +fun test_with_loaded_mocks() { + @.Mock.Load("mocks/api-mocks.yaml") + $r1 : @.Data.GetData() + $r2 : @.Data.GetData() + @.Assert.equal(5, $r1 | length) + @.Assert.equal(3, $r2 | length) +} +``` + ### Capturing parameter inputs We can also obtain all of the parameters passed into the function during the test. The parameters are stored in chronological order. @@ -142,6 +213,30 @@ $allPublished = @.Mock.GetFuncCaptures('Event.Publish') ## Functions +### Load + +#### Description + +Loads mocks from a YAML or JSON file. The path is relative to the directory of the current ISL file. + +#### Syntax + +`@.Mock.Load($fileName)` + +- `$fileName`: Relative path to the mock file (e.g. `"mocks/api.yaml"`). Supports `.yaml`, `.yml`, and `.json`. +- `Returns`: null + +#### Example + +```kotlin +@test +fun test_with_mocks() { + @.Mock.Load("mocks/api-mocks.yaml") + $result = @.Api.Call("https://example.com") + @.Assert.equal(200, $result.status) +} +``` + ### Func #### Description @@ -152,10 +247,10 @@ Creates a mock of an ISL function. `@.Mock.Func($funcToMock, $returnValue, ...$paramsToMatch)` -- `$funcToMock`: ISL function to mock. +- `$funcToMock`: ISL function to mock. Use `Function.Name#1`, `Function.Name#2`, etc. for indexed (sequential) returns per call. - `$returnValue`: Return value of mock. -- `$paramsToMatch`: Optional error message to show if assertion fails. -- `Returns`: Unique id of the mock. +- `$paramsToMatch`: Optional parameters to match. When omitted, matches any parameters. +- `Returns`: Unique id of the mock (null for default mocks). #### Example @@ -186,10 +281,10 @@ Currently has no functionality, but can be used to: `@.Mock.Annotation($funcToMock, $returnValue, ...$paramsToMatch)` -- `$funcToMock`: ISL annotation to mock. +- `$funcToMock`: ISL annotation to mock. Use `AnnotationName#1`, `AnnotationName#2`, etc. for indexed (sequential) returns per call. - `$returnValue`: Return value of mock. -- `$paramsToMatch`: Optional error message to show if assertion fails. -- `Returns`: Unique id of the mock. +- `$paramsToMatch`: Optional parameters to match. When omitted, matches any parameters. +- `Returns`: Unique id of the mock (null for default mocks). #### Example diff --git a/docs/ext/unit-testing/setup.md b/docs/ext/unit-testing/setup.md index 61d339e..b023c01 100644 --- a/docs/ext/unit-testing/setup.md +++ b/docs/ext/unit-testing/setup.md @@ -5,52 +5,123 @@ grand_parent: Advanced Topics nav_order: 1 --- -## Setting up your first test +# Test Setup -In order to utilise the library, add the following to your `pom.xml` +## Running Tests via CLI (Recommended) + +The easiest way to run ISL tests is with the `isl test` command: + +```bash +# Run tests in current directory (discovers **/*.isl with @test or @setup) +isl test + +# Run tests in a specific directory +isl test tests/ + +# Run a specific file +isl test tests/sample.isl + +# Custom glob pattern +isl test tests/ --glob "**/*.test.isl" + +# Write results to JSON +isl test -o results.json +``` + +The CLI discovers all `.isl` files containing `@setup` or `@test` annotations and runs them. + +## Running Tests Programmatically + +### Maven + +Add the `isl-test` dependency: ```xml - com.intuit.isl - isl-test - ... + com.intuit.isl + isl-test + 1.1.19+ ``` -To evoke the unit test files, run the following: - -### Kotlin +### Gradle (Kotlin DSL) ```kotlin -val builder = TransformPackageBuilder() - -val scripts : List +dependencies { + implementation("com.intuit.isl:isl-test:1.1.19+") +} +``` -val transformPackage = builder.build(fileInfo) +### Kotlin Example -val transformTestPackage = TransformTestPackage(transformPackage) +```kotlin +import com.intuit.isl.runtime.FileInfo +import com.intuit.isl.test.TransformTestPackageBuilder +import java.nio.file.Path +import java.nio.file.Paths + +// Build test package from files +val basePath = Paths.get(".").toAbsolutePath() +val fileInfos = listOf( + FileInfo("tests/sample.isl", Path.of("tests/sample.isl").toFile().readText()) +).toMutableList() + +val testPackage = TransformTestPackageBuilder().build( + fileInfos, + findExternalModule = null, + basePath = basePath // Required for @.Load.From +) // Run all tests -val testResults = testPackage.runAllTests() +val results = testPackage.runAllTests() -// Run a specific test function within defined test file. -val individualTestResult = testPackage.runTest("test.isl", "test") +// Run a specific test +val singleResult = testPackage.runTest("tests/sample.isl", "test_simpleAssertion") + +// Check results +results.testResults.forEach { tr -> + println("${tr.testName}: ${if (tr.success) "PASS" else "FAIL"} ${tr.message ?: ""}") +} ``` -### Java +### Java Example ```java -TransformPackageBuilder builder = new TransformPackageBuilder(); +import com.intuit.isl.runtime.FileInfo; +import com.intuit.isl.test.TransformTestPackageBuilder; +import com.intuit.isl.test.annotations.TestResultContext; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.ArrayList; + +Path basePath = Paths.get(".").toAbsolutePath(); +List fileInfos = new ArrayList<>(); +fileInfos.add(new FileInfo("tests/sample.isl", Files.readString(Path.of("tests/sample.isl")))); + +var builder = new TransformTestPackageBuilder(); +var testPackage = builder.build(fileInfos, null, basePath); + +TestResultContext results = testPackage.runAllTests(); + +// Run specific test +TestResultContext singleResult = testPackage.runTest("tests/sample.isl", "test_simpleAssertion"); +``` -List scripts; +## basePath and Load.From -TransformPackage transformPackage = builder.build(fileInfo) +When running tests programmatically, pass `basePath` to `TransformTestPackageBuilder.build()` so that `@.Load.From(fileName)` can resolve relative paths correctly. If `basePath` is `null`, `Load.From` will throw when used. -TransformTestPackage transformTestPackage = new TransformTestPackage(transformPackage) +## Test Result Structure -// Run all tests -TestResultContext testResults = testPackage.runAllTests() +`TestResultContext` contains: -// Run a specific test function within defined test file. -TestResultContext individualTestResult = testPackage.runTest("test.isl", "test") -``` +- `testResults` – List of `TestResult` with: + - `testFile` – Source file + - `functionName` – Function name + - `testName` – Display name (from `@test` or function name) + - `testGroup` – Group (from `@test` or file name) + - `success` – Whether the test passed + - `message` – Failure message if any + - `errorPosition` – File/line/column if available diff --git a/docs/index.md b/docs/index.md index aec052e..9ce17bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,6 +124,7 @@ Will output: - Support for [parsing XML](./types/xml.md#xml-processing) and [outputting XML](./types/xml.md#xml-output), [parsing CSV](./types/csv.md#csv-processing) or yaml. - Support for advanced String Interpolation `Hi there $name. Today is ${ @.Date.Now() | to.string("yyyy MM dd") }. `. - Support for [`find`, `match` and `replace` using Regular Expressions](./language/modifiers.md#regex-processing). +- [Unit Testing](./ext/unit-testing/index.md) – Write and run tests in ISL with `@test`, assertions, fixtures (`@.Load.From`), and mocking. ## Learning And Support diff --git a/isl-cmd/build.gradle.kts b/isl-cmd/build.gradle.kts index bc7e783..74f6be8 100644 --- a/isl-cmd/build.gradle.kts +++ b/isl-cmd/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { // ISL modules implementation(project(":isl-transform")) implementation(project(":isl-validation")) + implementation(project(":isl-test")) // Kotlin implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") @@ -74,6 +75,7 @@ tasks.register("runIsl") { description = "Run ISL CLI with arguments" classpath = sourceSets.main.get().runtimeClasspath mainClass.set("com.intuit.isl.cmd.IslCommandLineKt") + workingDir = project.findProperty("runWorkingDir")?.toString()?.takeIf { it.isNotBlank() }?.let { file(it) } ?: rootProject.projectDir // Allow passing arguments: ./gradlew :isl-cmd:runIsl --args="script.isl" if (project.hasProperty("args")) { @@ -81,6 +83,11 @@ tasks.register("runIsl") { } } +// Application run task: use invocation directory when set (e.g. from isl.bat/isl.sh) +tasks.named("run").configure { + workingDir = project.findProperty("runWorkingDir")?.toString()?.takeIf { it.isNotBlank() }?.let { file(it) } ?: rootProject.projectDir +} + // Configure JAR manifest tasks.jar { manifest { diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt index 96ad028..b29674d 100644 --- a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt +++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt @@ -18,7 +18,8 @@ import kotlin.system.exitProcess subcommands = [ TransformCommand::class, ValidateCommand::class, - InfoCommand::class + InfoCommand::class, + TestCommand::class ] ) class IslCommandLine : Runnable { diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt new file mode 100644 index 0000000..00c7cc9 --- /dev/null +++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt @@ -0,0 +1,77 @@ +package com.intuit.isl.cmd + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.SerializationFeature +import com.intuit.isl.common.FunctionExecuteContext +import com.intuit.isl.common.IOperationContext +import com.intuit.isl.utils.ConvertUtils +import com.intuit.isl.utils.JsonConvert + +/** + * Log extension functions for ISL scripts when running transforms or tests from the command line. + * + * Usage in ISL: + * @.Log.Info("Processing item", $count) + * @.Log.Warn("Unexpected value:", $value) + * @.Log.Error("Failed:", $error) + * @.Log.Debug("Debug info") // Only outputs when -Ddebug=true + */ +object LogExtensions { + fun registerExtensions(context: IOperationContext) { + context.registerExtensionMethod("Log.Debug", LogExtensions::debug) + context.registerExtensionMethod("Log.Info", LogExtensions::info) + context.registerExtensionMethod("Log.Warn", LogExtensions::warn) + context.registerExtensionMethod("Log.Error", LogExtensions::error) + } + + @Suppress("UNUSED_PARAMETER") + private fun debug(context: FunctionExecuteContext): Any? { + if (System.getProperty("debug") != "true") return null + val message = formatMessage(context.parameters) + println("[DEBUG] $message") + return null + } + + @Suppress("UNUSED_PARAMETER") + private fun info(context: FunctionExecuteContext): Any? { + val message = formatMessage(context.parameters) + println("[INFO] $message") + return null + } + + @Suppress("UNUSED_PARAMETER") + private fun warn(context: FunctionExecuteContext): Any? { + val message = formatMessage(context.parameters) + System.err.println("[WARN] $message") + return null + } + + @Suppress("UNUSED_PARAMETER") + private fun error(context: FunctionExecuteContext): Any? { + val message = formatMessage(context.parameters) + System.err.println("[ERROR] $message") + return null + } + + private fun formatMessage(parameters: Array<*>): String { + if (parameters.isEmpty()) return "" + return parameters.joinToString(" ") { param -> + when (param) { + null -> "null" + is JsonNode -> param.toPrettyString() + else -> ConvertUtils.tryToString(param) ?: param.toString() + } + } + } + + private fun JsonNode.toPrettyString(): String { + return try { + JsonConvert.mapper + .copy() + .enable(SerializationFeature.INDENT_OUTPUT) + .writeValueAsString(this) + } catch (_: Exception) { + this.toString() + } + } +} diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt new file mode 100644 index 0000000..5e06bed --- /dev/null +++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt @@ -0,0 +1,252 @@ +package com.intuit.isl.cmd + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intuit.isl.runtime.FileInfo +import com.intuit.isl.runtime.TransformCompilationException +import com.intuit.isl.test.TransformTestPackageBuilder +import com.intuit.isl.test.annotations.TestResult +import com.intuit.isl.test.annotations.TestResultContext +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import picocli.CommandLine.Parameters +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.isRegularFile +import kotlin.system.exitProcess + +/** + * Command to execute ISL tests. + * Discovers .isl files containing @setup or @test annotations and runs them. + */ +@Command( + name = "test", + aliases = ["tests"], + description = ["Execute ISL tests from the specified path or current folder"] +) +class TestCommand : Runnable { + + @Parameters( + index = "0", + arity = "0..1", + description = ["Path to search for tests: directory, file, or glob pattern (e.g. tests/**/*.isl). Default: current directory"] + ) + var path: File? = null + + @Option( + names = ["--glob"], + description = ["Glob pattern to filter files (e.g. **/*.isl). Used when path is a directory"] + ) + var globPattern: String? = null + + @Option( + names = ["-o", "--output"], + description = ["Write results to a JSON file for parsing by other tools"] + ) + var outputFile: File? = null + + override fun run() { + try { + val basePath = (path?.absoluteFile ?: File(System.getProperty("user.dir"))).toPath().normalize() + val searchBase = if (basePath.toFile().isDirectory) basePath else basePath.parent + when { + basePath.toFile().isFile -> println("Searching: ${basePath.toAbsolutePath()}") + else -> { + val pattern = globPattern ?: "**/*.isl" + println("Searching: ${basePath.toAbsolutePath()} (glob: $pattern)") + } + } + val testFiles = discoverTestFiles(basePath) + if (testFiles.isEmpty()) { + System.err.println(red("No test files found (looking for .isl files with @setup or @test)")) + exitProcess(1) + } + println("Found ${testFiles.size} test file(s)") + val fileInfos = testFiles.map { (filePath, content) -> + val moduleName = searchBase.relativize(filePath).toString().replace("\\", "/") + FileInfo(moduleName, content) + }.toMutableList() + val findExternalModule = createModuleResolver(testFiles, searchBase) + val result = try { + @Suppress("UNCHECKED_CAST") + val testPackage = TransformTestPackageBuilder().build( + fileInfos, + findExternalModule as java.util.function.BiFunction, + searchBase, + listOf { LogExtensions.registerExtensions(it) } + ) + testPackage.runAllTests() + } catch (e: Exception) { + createErrorResult(e, fileInfos) + } + reportResults(result) + outputFile?.let { writeResultsToJson(result, it) } + val failedCount = result.testResults.count { !it.success } + if (failedCount > 0) { + exitProcess(1) + } + } catch (e: Exception) { + System.err.println(red("Error: ${e.message}")) + if (System.getProperty("debug") == "true") { + e.printStackTrace() + } + exitProcess(1) + } + } + + private fun discoverTestFiles(basePath: Path): List> { + val islFiles = when { + basePath.toFile().isFile -> { + if (basePath.toString().endsWith(".isl", true)) listOf(basePath) else emptyList() + } + basePath.toFile().isDirectory -> { + val pattern = globPattern ?: "**/*.isl" + val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") + Files.walk(basePath) + .use { stream -> + stream + .filter { it.isRegularFile() && it.extension.equals("isl", true) } + .filter { path -> + val relative = basePath.relativize(path) + val normalized = relative.toString().replace("\\", "/") + globPattern == null || matcher.matches(FileSystems.getDefault().getPath(normalized)) + } + .toList() + } + } + else -> emptyList() + } + return islFiles + .mapNotNull { path -> + val content = path.toFile().readText() + if (content.contains("@setup") || content.contains("@test")) { + path to content + } else null + } + } + + private fun createModuleResolver(testFiles: List>, searchBase: Path): java.util.function.BiFunction { + val fileByModuleName = testFiles.associate { (filePath, content) -> + val moduleName = searchBase.relativize(filePath).toString().replace("\\", "/").removeSuffix(".isl") + moduleName to content + } + val fileByFullName = testFiles.associate { (filePath, content) -> + searchBase.relativize(filePath).toString().replace("\\", "/") to content + } + return java.util.function.BiFunction { fromModule: String, dependentModule: String -> + fileByFullName[dependentModule] + ?: fileByModuleName[dependentModule] + ?: resolveExternalModule(searchBase, fromModule, dependentModule) + } + } + + private fun resolveExternalModule(searchBase: Path, fromModule: String, dependentModule: String): String? { + val fromDir = searchBase.resolve(fromModule).parent ?: searchBase + val candidateNames = if (dependentModule.endsWith(".isl", ignoreCase = true)) { + listOf(dependentModule) + } else { + listOf("$dependentModule.isl", "$dependentModule.ISL") + } + val searchedPaths = mutableListOf() + for (name in candidateNames) { + val candidatePath = fromDir.resolve(name) + searchedPaths.add(candidatePath.toAbsolutePath()) + val file = candidatePath.toFile() + if (file.exists()) return file.readText() + } + val moduleBaseName = if (dependentModule.endsWith(".isl", ignoreCase = true)) { + dependentModule.dropLast(4) + } else { + dependentModule + } + val found = searchBase.toFile().walkTopDown() + .filter { it.isFile && it.extension.equals("isl", true) } + .find { it.nameWithoutExtension.equals(moduleBaseName, true) } + if (found != null) return found.readText() + searchedPaths.forEach { System.err.println("Module not found. Searched: $it") } + return null + } + + private fun reportResults(result: TestResultContext) { + val passed = result.testResults.count { it.success } + val failed = result.testResults.count { !it.success } + val byGroup = result.testResults.groupBy { it.testGroup ?: it.testFile } + byGroup.forEach { (group, tests) -> + println(" $group") + tests.forEach { tr -> + val displayName = if (tr.testName != tr.functionName) "${tr.testName} (${tr.functionName})" else tr.testName + if (tr.success) { + println(" ${green("[PASS]")} $displayName") + } else { + println(" ${red("[FAIL]")} $displayName") + tr.message?.let { println(" ${red(it)}") } + tr.errorPosition?.let { pos -> + val loc = "${pos.file}:${pos.line}:${pos.column}" + println(" ${red("at $loc")}") + } + } + } + } + println("---") + val resultsLine = "Results: $passed passed, $failed failed, ${result.testResults.size} total" + println(if (failed > 0) red(resultsLine) else resultsLine) + } + + private fun green(text: String) = "\u001B[32m$text\u001B[0m" + private fun red(text: String) = "\u001B[31m$text\u001B[0m" + + private fun createErrorResult(e: Exception, fileInfos: List): TestResultContext { + val (message, position) = when (e) { + is TransformCompilationException -> e.message to e.position + is com.intuit.isl.runtime.TransformException -> e.message to e.position + is com.intuit.isl.runtime.IslException -> e.message to e.position + else -> e.message to null + } + val firstFile = fileInfos.firstOrNull()?.name ?: "unknown" + val errorResult = TestResult( + testFile = position?.file ?: firstFile, + functionName = "compilation", + testName = "compilation", + testGroup = firstFile, + success = false, + message = message ?: e.toString(), + errorPosition = position + ) + return TestResultContext(mutableListOf(errorResult)) + } + + private fun writeResultsToJson(result: TestResultContext, file: File) { + val passed = result.testResults.count { it.success } + val failed = result.testResults.count { !it.success } + val results = result.testResults.map { tr -> + mapOf( + "testFile" to tr.testFile, + "functionName" to tr.functionName, + "testName" to tr.testName, + "testGroup" to tr.testGroup, + "success" to tr.success, + "message" to tr.message, + "errorPosition" to (tr.errorPosition?.let { pos -> + mapOf( + "file" to pos.file, + "line" to pos.line, + "column" to pos.column + ) + }) + ) + } + val output = mapOf( + "passed" to passed, + "failed" to failed, + "total" to result.testResults.size, + "success" to (failed == 0), + "results" to results + ) + val mapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT) + file.writeText(mapper.writeValueAsString(output)) + println("Results written to: ${file.absolutePath}") + } +} diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt index a645bdd..0c8cb07 100644 --- a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt +++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt @@ -130,8 +130,9 @@ class TransformCommand : Runnable { val compiler = TransformCompiler() val transformer = compiler.compileIsl("script", scriptContent) - // Create operation context with variables + // Create operation context with variables and CLI extensions (e.g. Log) val context = OperationContext() + LogExtensions.registerExtensions(context) variables.forEach { (key, value) -> val varName = if (key.startsWith("$")) key else "$$key" val varValue = JsonConvert.convert(value); diff --git a/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt b/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt index 8e18bc7..54ab995 100644 --- a/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt +++ b/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt @@ -114,6 +114,35 @@ class IslCommandLineTest { assertTrue(output.contains("true")) } + @Test + fun `test transform with Log extensions`(@TempDir tempDir: Path) { + val scriptFile = tempDir.resolve("log-test.isl").toFile() + scriptFile.writeText(""" + fun run(${'$'}input) { + @.Log.Info("Processing", ${'$'}input.name) + result: { message: "done" } + } + """.trimIndent()) + + val inputFile = tempDir.resolve("input.json").toFile() + inputFile.writeText("""{"name": "test"}""") + + val outputStream = ByteArrayOutputStream() + System.setOut(PrintStream(outputStream)) + + val cmd = CommandLine(IslCommandLine()) + val exitCode = cmd.execute( + "transform", + scriptFile.absolutePath, + "-i", inputFile.absolutePath, + "--pretty" + ) + + assertEquals(0, exitCode) + val output = outputStream.toString() + assertTrue(output.contains("[INFO]") && output.contains("Processing"), "Expected [INFO] in output: $output") + } + @Test fun `test validate command with valid script`(@TempDir tempDir: Path) { val scriptFile = tempDir.resolve("valid.isl").toFile() diff --git a/isl-test/build.gradle.kts b/isl-test/build.gradle.kts new file mode 100644 index 0000000..878807a --- /dev/null +++ b/isl-test/build.gradle.kts @@ -0,0 +1,93 @@ +plugins { + kotlin("jvm") + id("jacoco") + id("org.gradle.test-retry") version "1.6.0" +} + +val kotlinVersion: String = "2.1.10" +val kotlinCoroutinesVersion: String = "1.10.1" +val jacksonVersion: String = "2.18.3" + +dependencies { + // Project dependency + implementation(project(":isl-transform")) + + // Kotlin + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinCoroutinesVersion") + + // Jackson (needed for JSON/YAML when using isl-transform) + implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion") + implementation("com.opencsv:opencsv:5.10") + + // Logging + implementation("org.slf4j:slf4j-api:2.0.17") + implementation("ch.qos.logback:logback-classic:1.5.16") + + // Testing + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:$kotlinVersion") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.12.1") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.12.1") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.12.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.12.1") + testImplementation("io.mockk:mockk-jvm:1.13.17") +} + +sourceSets { + main { + kotlin { + srcDirs("src/main/kotlin") + } + } + test { + kotlin { + srcDirs("src/test/kotlin") + } + } +} + +tasks.jacocoTestReport { + dependsOn(tasks.test, tasks.processResources, tasks.classes) +} + +tasks.jacocoTestCoverageVerification { + dependsOn(tasks.jacocoTestReport, tasks.classes) + violationRules { + rule { + limit { + counter = "LINE" + value = "COVEREDRATIO" + minimum = "0.0".toBigDecimal() + } + } + } + classDirectories.setFrom(tasks.jacocoTestReport.get().classDirectories) +} + +tasks.test { + finalizedBy(tasks.jacocoTestReport) + retry { + maxRetries.set(3) + maxFailures.set(10) + } +} + +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + +tasks.jar { + manifest { + attributes( + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + "Specification-Title" to project.name, + "Specification-Version" to project.version + ) + } +} diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt new file mode 100644 index 0000000..0ad7917 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt @@ -0,0 +1,98 @@ +package com.intuit.isl.test + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.intuit.isl.common.FunctionExecuteContext +import com.intuit.isl.common.IOperationContext +import com.intuit.isl.utils.ConvertUtils +import com.intuit.isl.utils.JsonConvert +import com.opencsv.CSVParserBuilder +import com.opencsv.CSVReaderBuilder +import java.io.File +import java.io.StringReader +import java.nio.file.Path + +/** + * Loads resources from files relative to the current ISL file. + * Use @.Load.From("fileName") where fileName is relative to the directory of the current file. + * Supports .json, .yaml, .yml, and .csv - all converted to JSON. + */ +object LoadFunction { + private const val functionName = "Load" + + fun registerExtensions(context: IOperationContext) { + context.registerExtensionMethod("$functionName.From") { ctx -> + from(ctx) + } + } + + private fun from(context: FunctionExecuteContext): Any? { + val fileName = ConvertUtils.tryToString(context.firstParameter) + ?: throw IllegalArgumentException("@.Load.From requires a file name (string)") + + val testContext = context.executionContext.operationContext as? TestOperationContext + ?: throw IllegalStateException("@.Load.From is only available in test context") + + val basePath = testContext.basePath + ?: throw IllegalStateException("@.Load.From requires basePath; run tests via isl test command or pass basePath to TransformTestPackageBuilder") + + val currentFile = testContext.currentFile + ?: throw IllegalStateException("@.Load.From requires currentFile; run tests via isl test command") + + val resolvedPath = resolvePath(basePath, currentFile, fileName) + val file = resolvedPath.toFile() + + if (!file.exists()) { + throw IllegalArgumentException("File not found: $resolvedPath (resolved from $fileName relative to $currentFile)") + } + if (!file.isFile) { + throw IllegalArgumentException("Not a file: $resolvedPath") + } + + val ext = file.extension.lowercase() + return when (ext) { + "json" -> JsonConvert.mapper.readTree(file) + "yaml", "yml" -> { + val yamlMapper = com.fasterxml.jackson.databind.ObjectMapper(YAMLFactory()) + yamlMapper.readTree(file) + } + "csv" -> parseCsvToJson(file.readText()) + else -> throw IllegalArgumentException( + "@.Load.From supports .json, .yaml, .yml, .csv; got: $fileName" + ) + } + } + + private fun resolvePath(basePath: Path, currentFile: String, fileName: String): Path { + val currentDir = basePath.resolve(currentFile).parent ?: basePath + return currentDir.resolve(fileName).normalize() + } + + private fun parseCsvToJson(text: String): JsonNode { + val parser = CSVParserBuilder() + .withSeparator(',') + .withEscapeChar('\\') + .withIgnoreQuotations(false) + .build() + val reader = CSVReaderBuilder(StringReader(text)) + .withSkipLines(0) + .withCSVParser(parser) + .build() + + val result = JsonNodeFactory.instance.arrayNode() + val firstLine = reader.readNext() ?: return result + val headers = firstLine + + var line: Array? + while (reader.readNext().also { line = it } != null) { + val item = JsonNodeFactory.instance.objectNode() + line?.forEachIndexed { i: Int, value: String? -> + val key = if (i < headers.size) headers[i] ?: "Col$i" else "Col$i" + item.put(key, value) + } + result.add(item) + } + return result + } +} diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt new file mode 100644 index 0000000..a3056aa --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt @@ -0,0 +1,93 @@ +package com.intuit.isl.test + +import com.intuit.isl.test.annotations.SetupAnnotation +import com.intuit.isl.test.annotations.TestAnnotation +import com.intuit.isl.test.annotations.TestResultContext +import com.intuit.isl.test.assertions.AssertFunction +import com.intuit.isl.common.* +import com.intuit.isl.test.mocks.MockFunction +import java.nio.file.Path + +class TestOperationContext : BaseOperationContext { + /** Current ISL file path (module name, e.g. "tests/sample.isl") for resolving relative paths in @.Load.From() */ + var currentFile: String? = null + internal set + + /** Base path for resolving relative file paths (e.g. project root) */ + var basePath: Path? = null + internal set + + companion object { + fun create( + testResultContext: TestResultContext, + currentFile: String? = null, + basePath: Path? = null, + contextCustomizers: List<(IOperationContext) -> Unit> = emptyList() + ): TestOperationContext { + val context = TestOperationContext() + + context.registerAnnotation(SetupAnnotation.annotationName, SetupAnnotation::runAnnotationFunction) + TestAnnotation.registerAnnotation(context, testResultContext) + + AssertFunction.registerExtensions(context) + LoadFunction.registerExtensions(context) + MockFunction.registerExtensions(context) + + contextCustomizers.forEach { it(context) } + + context.currentFile = currentFile + context.basePath = basePath + + return context + } + } + + constructor() : super() { + this.mockExtensions = TestOperationMockExtensions() + } + + private constructor( + extensions: HashMap, + annotations: HashMap, + statementExtensions: HashMap, + internalExtensions: HashMap, + mockExtensions: TestOperationMockExtensions + ) : super( + extensions, annotations, statementExtensions, internalExtensions, HashMap() + ) { + this.mockExtensions = mockExtensions + } + + val mockExtensions : TestOperationMockExtensions + + override fun getExtension(name: String): AsyncContextAwareExtensionMethod? { + val function = mockExtensions.mockExtensions[name.lowercase()]?.func + if (function != null) { + return function + } + return super.getExtension(name) + } + + override fun getAnnotation(annotationName: String): AsyncExtensionAnnotation? { + val function = mockExtensions.mockAnnotations[annotationName.lowercase()]?.func + if (function != null) { + return function + } + return super.getAnnotation(annotationName) + } + + override fun getStatementExtension(name: String): AsyncStatementsExtensionMethod? { + val function = mockExtensions.mockStatementExtensions[name.lowercase()]?.func + if (function != null) { + return function + } + return super.getStatementExtension(name) + } + + override fun clone(newInternals: HashMap): IOperationContext { + return TestOperationContext( + this.extensions, this.annotations, this.statementExtensions, newInternals, this.mockExtensions + ) + } +} + diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt new file mode 100644 index 0000000..1e33bf0 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt @@ -0,0 +1,11 @@ +package com.intuit.isl.test + +import com.intuit.isl.commands.CommandResult +import com.intuit.isl.common.* +import com.intuit.isl.test.mocks.MockContext + +class TestOperationMockExtensions { + val mockExtensions = HashMap>() + val mockAnnotations = HashMap>() + val mockStatementExtensions = HashMap>() +} \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt new file mode 100644 index 0000000..8946ca1 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt @@ -0,0 +1,7 @@ +package com.intuit.isl.test + +data class TransformTestFile( + val fileName: String, + val testFunctions: Set = setOf(), + val setupFile: String? = null +) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt new file mode 100644 index 0000000..15e2596 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt @@ -0,0 +1,97 @@ +package com.intuit.isl.test + +import com.intuit.isl.common.IOperationContext +import com.intuit.isl.test.annotations.SetupAnnotation +import com.intuit.isl.test.annotations.TestAnnotation +import com.intuit.isl.test.annotations.TestResultContext +import com.intuit.isl.commands.IFunctionDeclarationCommand +import com.intuit.isl.runtime.TransformModule +import com.intuit.isl.runtime.TransformPackage +import java.nio.file.Path + +class TransformTestPackage( + private val transformPackage: TransformPackage, + private val basePath: Path? = null, + private val contextCustomizers: List<(IOperationContext) -> Unit> = emptyList() +) { + private val testFiles = mutableMapOf() + + init { + transformPackage.modules.forEach { file -> + val module = transformPackage.getModule(file)?.module + module?.functions?.forEach { function -> + val testFile = verifyIfModuleIsTestFile(function, module, file) + if (testFile != null) { + val existing = testFiles[file] + testFiles[file] = if (existing != null) { + TransformTestFile( + file, + existing.testFunctions + testFile.testFunctions, + existing.setupFile ?: testFile.setupFile + ) + } else { + testFile + } + } + } + } + } + + fun runAllTests(testResultContext: TestResultContext? = null) : TestResultContext { + val context = testResultContext ?: TestResultContext() + testFiles.forEach { (_, file) -> + file.testFunctions.forEach { function -> + runTest(file.fileName, function, context) + } + } + + return context + } + + fun runTest(testFile: String, testFunc: String, testResultContext: TestResultContext? = null) : TestResultContext { + var context = testResultContext ?: TestResultContext() + runTest(testFile, testFunc, context, testFiles[testFile]?.setupFile) + return context + } + + private fun runTest(testFile: String, testFunc: String, testResultContext: TestResultContext, setupFunc: String? = null) { + val fullFunctionName = TransformPackage.toFullFunctionName(testFile, testFunc) + val context = TestOperationContext.create(testResultContext, testFile, basePath, contextCustomizers) + // Run setup function if it exists + if (setupFunc != null) { + val fullSetupFunctionName = TransformPackage.toFullFunctionName(testFile, setupFunc) + transformPackage.runTransform(fullSetupFunctionName, context) + } + transformPackage.runTransform(fullFunctionName, context) + } + + private fun verifyIfModuleIsTestFile( + function: IFunctionDeclarationCommand, + module: TransformModule, + file: String + ): TransformTestFile? { + val testFunctions = mutableSetOf() + var setUpFunction: String? = null + function.token.annotations.forEach { a -> + when (a.annotationName) { + TestAnnotation.annotationName -> { + testFunctions.add(function.token.functionName) + } + + SetupAnnotation.annotationName -> { + if (setUpFunction != null) { + throw Exception("Multiple setUp functions found. File: ${module.name}, Function: ${function.token.functionName}") + } + setUpFunction = function.token.functionName + } + } + } + + // Mark file as test file if it has any test functions + if (testFunctions.isNotEmpty()) { + return TransformTestFile(file, testFunctions, setUpFunction) + } + return null + } +} + diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt new file mode 100644 index 0000000..1d64b4e --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt @@ -0,0 +1,21 @@ +package com.intuit.isl.test + +import com.intuit.isl.common.IOperationContext +import com.intuit.isl.runtime.FileInfo +import com.intuit.isl.runtime.TransformPackageBuilder +import java.nio.file.Path +import java.util.function.BiFunction + +class TransformTestPackageBuilder { + private val transformPackageBuilder = TransformPackageBuilder() + + fun build( + files: MutableList, + findExternalModule: BiFunction? = null, + basePath: Path? = null, + contextCustomizers: List<(IOperationContext) -> Unit> = emptyList() + ): TransformTestPackage { + val transformPackage = transformPackageBuilder.build(files, findExternalModule) + return TransformTestPackage(transformPackage, basePath, contextCustomizers) + } +} \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt new file mode 100644 index 0000000..253223b --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt @@ -0,0 +1,12 @@ +package com.intuit.isl.test.annotations + +import com.intuit.isl.common.AnnotationExecuteContext + +object SetupAnnotation { + const val annotationName = "setup" + + suspend fun runAnnotationFunction(context: AnnotationExecuteContext) : Any? { + return context.runNextCommand() + } +} + diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt new file mode 100644 index 0000000..0c7f4d1 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt @@ -0,0 +1,84 @@ +package com.intuit.isl.test.annotations; + +import com.fasterxml.jackson.databind.JsonNode +import com.intuit.isl.test.assertions.AssertException +import com.intuit.isl.common.IOperationContext +import com.intuit.isl.runtime.IslException +import com.intuit.isl.runtime.TransformException +import com.intuit.isl.utils.ConvertUtils + +object TestAnnotation { + const val annotationName = "test" + + fun registerAnnotation(operationContext: IOperationContext, testResultContext: TestResultContext) { + operationContext.registerAnnotation(annotationName) { context -> + var contextCommandOutput : Any? = null + val (testName, testGroup) = parseTestAnnotationParams(context) + val result = TestResult( + testFile = context.command.token.position.file, + functionName = context.functionName, + testName = testName, + testGroup = testGroup, + success = true + ) + try { + contextCommandOutput = context.runNextCommand() + } + catch (e: Exception) { + result.success = false + result.message = e.message + result.exception = e + if (e is TransformException && e.cause != null) { + e.cause.let { + if (it is AssertException) { + // Surface the assertion exception message + result.message = it.message + result.errorPosition = it.position + result.exception = it + } + } + } + if (e is IslException) { + result.errorPosition = e.position + } + + } + finally { + testResultContext.testResults.add(result) + } + + contextCommandOutput + } + } + + /** + * Parse @test annotation parameters. + * Supports: @test(), @test(name), @test(name, group), @test({ name: "x", group: "y" }) + */ + private fun parseTestAnnotationParams(context: com.intuit.isl.common.AnnotationExecuteContext): Pair { + val functionName = context.functionName + val testFile = context.command.token.position.file + val defaultGroup = testFile.substringAfterLast('/').substringAfterLast('\\') + val params = context.parameters + return when { + params.isEmpty() -> Pair(functionName, defaultGroup) + params.size == 1 -> { + val first = params[0] + when { + first is JsonNode && first.isObject -> { + val name = first.path("name").takeIf { !it.isMissingNode }?.asText() ?: functionName + val group = first.path("group").takeIf { !it.isMissingNode }?.asText() + Pair(name, group) + } + else -> Pair(ConvertUtils.tryToString(first) ?: functionName, defaultGroup) + } + } + params.size >= 2 -> { + val name = ConvertUtils.tryToString(params[0]) ?: functionName + val group = ConvertUtils.tryToString(params[1]) + Pair(name, group) + } + else -> Pair(functionName, defaultGroup) + } + } +} diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt new file mode 100644 index 0000000..1af2b75 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt @@ -0,0 +1,15 @@ +package com.intuit.isl.test.annotations + +import com.intuit.isl.utils.Position +import java.lang.Exception + +data class TestResult( + val testFile: String, + val functionName: String, + val testName: String, + val testGroup: String?, + var success: Boolean, + var message: String? = null, + var errorPosition: Position? = null, + var exception: Exception? = null +) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt new file mode 100644 index 0000000..123d388 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt @@ -0,0 +1,3 @@ +package com.intuit.isl.test.annotations + +data class TestResultContext(val testResults: MutableList = mutableListOf()) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt new file mode 100644 index 0000000..820d2d9 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt @@ -0,0 +1,11 @@ +package com.intuit.isl.test.assertions + +import com.intuit.isl.runtime.IslException +import com.intuit.isl.utils.Position + +open class AssertException( + message: String, + val functionName: String, + override val position: Position? = null, + cause: Throwable? = null +) : IslException, Exception(message, cause) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt new file mode 100644 index 0000000..9959444 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt @@ -0,0 +1,257 @@ +package com.intuit.isl.test.assertions + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.intuit.isl.test.TestOperationContext +import com.intuit.isl.commands.ConditionEvaluator +import com.intuit.isl.common.FunctionExecuteContext +import com.intuit.isl.utils.ConvertUtils +import com.intuit.isl.utils.JsonConvert + +object AssertFunction { + private const val functionName = "Assert" + + fun registerExtensions(context: TestOperationContext) { + mapOf Any?>( + AssertFunction::equal.name to AssertFunction::equal, + AssertFunction::notEqual.name to AssertFunction::notEqual, + AssertFunction::lessThan.name to AssertFunction::lessThan, + AssertFunction::lessThanOrEqual.name to AssertFunction::lessThanOrEqual, + AssertFunction::greaterThan.name to AssertFunction::greaterThan, + AssertFunction::greaterThanOrEqual.name to AssertFunction::greaterThanOrEqual, + AssertFunction::matches.name to AssertFunction::matches, + AssertFunction::notMatches.name to AssertFunction::notMatches, + AssertFunction::contains.name to AssertFunction::contains, + AssertFunction::notContains.name to AssertFunction::notContains, + AssertFunction::startsWith.name to AssertFunction::startsWith, + AssertFunction::notStartsWith.name to AssertFunction::notStartsWith, + AssertFunction::endsWith.name to AssertFunction::endsWith, + AssertFunction::notEndsWith.name to AssertFunction::notEndsWith, + "in" to AssertFunction::assertIn, + AssertFunction::notIn.name to AssertFunction::notIn, + AssertFunction::isType.name to AssertFunction::isType, + AssertFunction::isNotType.name to AssertFunction::isNotType, + AssertFunction::notNull.name to AssertFunction::notNull, + AssertFunction::isNull.name to AssertFunction::isNull + ).forEach { (t, u) -> + registerExtensionMethod(context, t, u) + } + } + + private fun equal(context: FunctionExecuteContext): Any? { + return evaluateCondition(context, "==") + } + + private fun notEqual(context: FunctionExecuteContext): Any? { + return evaluateCondition(context, "!=") + } + + private fun lessThan(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "<") + + private fun lessThanOrEqual(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "<=") + + private fun greaterThan(context: FunctionExecuteContext): Any? = + evaluateCondition(context, ">") + + private fun greaterThanOrEqual(context: FunctionExecuteContext): Any? = + evaluateCondition(context, ">=") + + private fun matches(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "matches") + + private fun notMatches(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "!matches") + + private fun contains(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "contains") + + private fun notContains(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "!contains") + + private fun startsWith(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "startswith") + + private fun notStartsWith(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "!startswith") + + private fun endsWith(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "endswith") + + private fun notEndsWith(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "!endswith") + + private fun assertIn(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "in") + + private fun notIn(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "!in") + + private fun isType(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "is") + + private fun isNotType(context: FunctionExecuteContext): Any? = + evaluateCondition(context, "!is") + + private fun isNull(context: FunctionExecuteContext): Any? { + val expectedValue = context.firstParameter + val messageStr = tryGetMessageStr(context.secondParameter) + + val result = ConditionEvaluator.evaluate(expectedValue, "notexists", null) + val functionName = context.functionName + if (!result) { + throw EvaluationAssertException( + "$functionName failed. Input value is not null. Value: $expectedValue$messageStr", + functionName, + expectedValue, + context.command.token.position + ) + } + + return null + } + + private fun notNull(context: FunctionExecuteContext): Any? { + val expectedValue = context.firstParameter + val messageStr = tryGetMessageStr(context.secondParameter) + + val result = ConditionEvaluator.evaluate(expectedValue, ConditionEvaluator.EXISTS, null) + val functionName = context.functionName + if (!result) { + throw EvaluationAssertException( + "$functionName failed. Input value is null. Value: $expectedValue$messageStr", + functionName, + expectedValue, + context.command.token.position + ) + } + + return null + } + + private fun evaluateCondition(context: FunctionExecuteContext, condition: String): Nothing? { + val expectedValue = context.firstParameter + val actualValue = context.secondParameter + val messageStr = tryGetMessageStr(context.thirdParameter) + + val result = when (condition) { + "==" -> deepEqual(expectedValue, actualValue) + "!=" -> !deepEqual(expectedValue, actualValue) + else -> ConditionEvaluator.evaluate(expectedValue, condition, actualValue) + } + val functionName = context.functionName + if (!result) { + throw ComparisonAssertException( + "$functionName failed. Expected: \n${toReadableString(expectedValue)}\nReceived: \n${ + toReadableString( + actualValue + ) + }\n$messageStr", + functionName, + expectedValue, + actualValue, + context.command.token.position + ) + } + + return null + } + + /** + * Deep equality comparison that identifies objects and compares them + * ignoring the order of properties. Arrays are compared with order preserved. + */ + private fun deepEqual(left: Any?, right: Any?): Boolean { + if (left == null && right == null) return true + if (left == null || right == null) return false + + return when { + isJsonObject(left) && isJsonObject(right) -> + objectsEqualIgnoringPropertyOrder(toJsonNode(left), toJsonNode(right)) + isJsonArray(left) && isJsonArray(right) -> + arraysEqual(toJsonNode(left) as ArrayNode, toJsonNode(right) as ArrayNode) + else -> ConditionEvaluator.equalish(left, right) + } + } + + private fun isJsonObject(value: Any?): Boolean = when (value) { + is ObjectNode -> true + is JsonNode -> value.isObject + is Map<*, *> -> true + else -> false + } + + private fun isJsonArray(value: Any?): Boolean = when (value) { + is ArrayNode -> true + is JsonNode -> value.isArray + else -> false + } + + private fun toJsonNode(value: Any?): JsonNode = when (value) { + is JsonNode -> value + is Map<*, *> -> JsonConvert.convert(value) + else -> JsonConvert.convert(value) + } + + private fun objectsEqualIgnoringPropertyOrder(left: JsonNode, right: JsonNode): Boolean { + if (!left.isObject || !right.isObject) return ConditionEvaluator.equalish(left, right) + + val leftKeys = left.fieldNames().asSequence().toSet() + val rightKeys = right.fieldNames().asSequence().toSet() + if (leftKeys != rightKeys) return false + + for (key in leftKeys) { + if (!deepEqual(left.get(key), right.get(key))) return false + } + return true + } + + private fun arraysEqual(left: ArrayNode, right: ArrayNode): Boolean { + if (left.size() != right.size()) return false + for (i in 0 until left.size()) { + if (!deepEqual(left.get(i), right.get(i))) return false + } + return true + } + + private fun toReadableString(value: Any?): String { + val valueStr = ConvertUtils.tryToString(value) + return when { + valueStr == null -> { + "" + } + + valueStr.isEmpty() -> { + "\"\"" + } + + valueStr.isBlank() -> { + "\"$valueStr\"" + } + + else -> { + valueStr + } + } + } + + private fun tryGetMessageStr(msg: Any?): String { + val message = ConvertUtils.tryToString(msg) + var messageStr = "" + if (message != null) { + messageStr = ". Additional message: $message" + } + return messageStr + } + + private fun registerExtensionMethod( + context: TestOperationContext, name: String, method: (FunctionExecuteContext) -> Any? + ) { + context.registerExtensionMethod("$functionName.${name}") { + method(it) + } + } +} + diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt new file mode 100644 index 0000000..a52b5e5 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt @@ -0,0 +1,12 @@ +package com.intuit.isl.test.assertions + +import com.intuit.isl.utils.Position + +class ComparisonAssertException( + message: String, + functionName : String, + val expectedValue: Any?, + val actualValue: Any?, + position: Position? = null, + cause: Throwable? = null +) : AssertException(message, functionName, position, cause) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt new file mode 100644 index 0000000..4264a71 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt @@ -0,0 +1,11 @@ +package com.intuit.isl.test.assertions + +import com.intuit.isl.utils.Position + +class EvaluationAssertException( + message: String, + functionName : String, + val inputValue: Any?, + position: Position? = null, + cause: Throwable? = null +) : AssertException(message, functionName, position, cause) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt new file mode 100644 index 0000000..704f09b --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt @@ -0,0 +1,7 @@ +package com.intuit.isl.test.mocks + +import com.fasterxml.jackson.databind.JsonNode + +class MockCaptureContext { + val captures = mutableListOf>() +} \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockContext.kt new file mode 100644 index 0000000..a7eb5ed --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockContext.kt @@ -0,0 +1,10 @@ +package com.intuit.isl.test.mocks + +class MockContext(mappingFunc : (mockObject : MockObject) -> T) { + val func : T + val mockObject = MockObject() + + init { + func = mappingFunc(mockObject) + } +} \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockFunction.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockFunction.kt new file mode 100644 index 0000000..7ec47c6 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockFunction.kt @@ -0,0 +1,339 @@ +package com.intuit.isl.test.mocks + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.intuit.isl.test.TestOperationContext +import com.intuit.isl.common.* +import com.intuit.isl.utils.ConvertUtils +import com.intuit.isl.utils.JsonConvert +import java.nio.file.Path + +object MockFunction { + private const val funcRegex = "[A-Za-z_]+\\.[A-Za-z0-9_]+(#[0-9]+)?" + private const val annotationRegex = "[A-Za-z0-9_]+(#[0-9]+)?" + + fun registerExtensions(context: TestOperationContext) { + context.registerExtensionMethod("Mock.Func") { + mockFunction(it, funcRegex) { funcName -> + createFunctionContext(context, funcName) + } + } + + context.registerExtensionMethod("Mock.Annotation") { + mockFunction(it, annotationRegex) { funcName -> + createAnnotationContext(context, funcName) + } + } + + context.registerExtensionMethod("Mock.StatementFunc") { + mockFunction(it, funcRegex) { funcName -> + createStatementFuncContext(context, funcName) + } + } + + context.registerExtensionMethod("Mock.GetFuncCaptures") { + getCaptures(it, funcRegex) { funcName -> + context.mockExtensions.mockExtensions[funcName] + } + } + + context.registerExtensionMethod("Mock.GetAnnotationCaptures") { + getCaptures(it, annotationRegex) { funcName -> + context.mockExtensions.mockAnnotations[funcName] + } + } + + context.registerExtensionMethod("Mock.GetStatementFuncCaptures") { + getCaptures(it, funcRegex) { funcName -> + context.mockExtensions.mockStatementExtensions[funcName] + } + } + + context.registerExtensionMethod("Mock.Load") { + loadMocksFromFile(it) + } + } + + private fun loadMocksFromFile(executeContext: FunctionExecuteContext): Any? { + val context = executeContext.executionContext.operationContext as? TestOperationContext + ?: throw IllegalStateException("@.Mock.Load is only available in test context") + val fileName = ConvertUtils.tryToString(executeContext.firstParameter) + ?: throw IllegalArgumentException("@.Mock.Load requires a file name (string)") + + val basePath = context.basePath + ?: throw IllegalStateException("@.Mock.Load requires basePath; run tests via isl test command or pass basePath to TransformTestPackageBuilder") + + val currentFile = context.currentFile + ?: throw IllegalStateException("@.Mock.Load requires currentFile; run tests via isl test command") + + val resolvedPath = resolvePath(basePath, currentFile, fileName) + val file = resolvedPath.toFile() + + if (!file.exists()) { + throw IllegalArgumentException("File not found: $resolvedPath (resolved from $fileName relative to $currentFile)") + } + if (!file.isFile) { + throw IllegalArgumentException("Not a file: $resolvedPath") + } + + val ext = file.extension.lowercase() + val root = when (ext) { + "json" -> JsonConvert.mapper.readTree(file) + "yaml", "yml" -> com.fasterxml.jackson.databind.ObjectMapper(YAMLFactory()).readTree(file) + else -> throw IllegalArgumentException("@.Mock.Load supports .json, .yaml, .yml; got: $fileName") + } + + if (!root.isObject) { + throw IllegalArgumentException("Mock file must have a root object with 'func' and/or 'annotation' keys") + } + + val obj = root as ObjectNode + val funcMocks = obj.get("func") + val annotationMocks = obj.get("annotation") + + if (funcMocks != null && funcMocks.isArray) { + funcMocks.forEach { entry -> + if (entry.isObject) { + registerMockFromNode(context, entry as ObjectNode, ::createFuncMock, funcRegex) + } + } + } + if (annotationMocks != null && annotationMocks.isArray) { + annotationMocks.forEach { entry -> + if (entry.isObject) { + registerMockFromNode(context, entry as ObjectNode, ::createAnnotationMock, annotationRegex) + } + } + } + + return null + } + + private fun resolvePath(basePath: Path, currentFile: String, fileName: String): Path { + val currentDir = basePath.resolve(currentFile).parent ?: basePath + return currentDir.resolve(fileName).normalize() + } + + private fun registerMockFromNode( + context: TestOperationContext, + node: ObjectNode, + registerMock: (TestOperationContext, String, Any?, Map) -> Int?, + nameRegex: String + ) { + val nameNode = node.get("name") ?: throw IllegalArgumentException("Mock entry must have 'name' field") + val name = ConvertUtils.tryToString(nameNode)?.trim() + ?: throw IllegalArgumentException("Mock 'name' must be a non-empty string") + if (name.isBlank()) { + throw IllegalArgumentException("Mock 'name' must be a non-empty string") + } + if (!name.matches(Regex(nameRegex))) { + throw IllegalArgumentException("Invalid mock name: $name") + } + + val returnNode = node.get("return") + val returnValue: Any? = when { + returnNode == null || returnNode.isNull -> null + else -> returnNode + } + + val params = mutableMapOf() + val paramsNode = node.get("params") + if (paramsNode != null && paramsNode.isArray) { + (paramsNode as ArrayNode).forEachIndexed { i, param -> + params[i] = param + } + } + + registerMock(context, name.lowercase(), returnValue, params) + } + + /*** + * Create a function mock. + * @param context The test operation context. + * @param functionNameStr The function name string. + * @param returnValue The return value. + * @param parameters The parameters. + * @return The function id. + */ + fun createFuncMock( + context: TestOperationContext, functionNameStr: String, returnValue: Any?, parameters: Map + ): Int? { + return mockFunction(functionNameStr, returnValue, parameters) { + createFunctionContext(context, it) + } + } + + /*** + * Create an annotation mock. + * @param context The test operation context. + * @param functionNameStr The function name string. + * @param returnValue The return value. + * @param parameters The parameters. + * @return The function id. + */ + fun createAnnotationMock( + context: TestOperationContext, functionNameStr: String, returnValue: Any?, parameters: Map + ): Int? { + return mockFunction(functionNameStr, returnValue, parameters) { + createAnnotationContext(context, it) + } + } + + /*** + * Create a statement function mock. + * @param context The test operation context. + * @param functionNameStr The function name string. + * @param returnValue The return value. + * @param parameters The parameters. + * @return The function id. + */ + fun createStatementFuncMock( + context: TestOperationContext, functionNameStr: String, returnValue: Any?, parameters: Map + ): Int? { + return mockFunction(functionNameStr, returnValue, parameters) { + createStatementFuncContext(context, it) + } + } + + private fun createStatementFuncContext( + context: TestOperationContext, + funcName: String + ): MockContext { + return context.mockExtensions.mockStatementExtensions.getOrPut(funcName) { + MockContext { mockObj -> + { mockContext, statementExecution -> + // Capture the argument inputs + tryFindMatch(mockObj, mockContext) + // Run the statement + statementExecution(mockContext.executionContext) + // Return null + null + } + } + } + } + + private fun createAnnotationContext( + context: TestOperationContext, + funcName: String + ): MockContext { + return context.mockExtensions.mockAnnotations.getOrPut(funcName) { + MockContext { mockObj -> + { mockContext -> + // Capture the argument inputs + tryFindMatch(mockObj, mockContext) + // Run and return the underlying function value + mockContext.runNextCommand() + } + } + } + } + + private fun createFunctionContext( + context: TestOperationContext, + funcName: String + ): MockContext { + return context.mockExtensions.mockExtensions.getOrPut(funcName) { + MockContext { mockObj -> + { mockContext -> + tryFindMatch(mockObj, mockContext) + } + } + } + } + + private fun mockFunction( + context: FunctionExecuteContext, + funcValidationRegex: String, + getMockContext: (funcNameStr: String) -> MockContext + ): Any? { + val (functionNameStr, returnValue, parameters) = parseMockFunctionExecuteContext(context, funcValidationRegex) + + return mockFunction(functionNameStr, returnValue, parameters, getMockContext) + } + + private fun mockFunction( + functionNameStr: String, + returnValue: Any?, + parameters: Map, + getMockContext: (funcNameStr: String) -> MockContext + ): Int? { + val (baseName, index) = parseFunctionNameWithIndex(functionNameStr) + val obtainedContext = getMockContext(baseName) + + return obtainedContext.mockObject.addMock(returnValue, parameters, index) + } + + /** + * Parses function name for optional #index suffix. + * e.g. "data.getdata#1" -> ("data.getdata", 1), "data.getdata" -> ("data.getdata", null) + */ + private fun parseFunctionNameWithIndex(functionNameStr: String): Pair { + val hashIndex = functionNameStr.lastIndexOf('#') + if (hashIndex >= 0) { + val baseName = functionNameStr.substring(0, hashIndex) + val indexStr = functionNameStr.substring(hashIndex + 1) + val index = indexStr.toIntOrNull() + if (index != null && index >= 1) { + return baseName to index + } + } + return functionNameStr to null + } + + private fun parseMockFunctionExecuteContext( + context: FunctionExecuteContext, funcValidationRegex: String + ): Triple> { + val functionNameStr = getAndValidateFunctionName(context, funcValidationRegex) + val returnValue = context.secondParameter + val parameters = mutableMapOf() + if (context.parameters.size > 2) { + parameters.putAll(context.parameters.slice(2 until context.parameters.size).mapIndexed { i, it -> + i to JsonConvert.convert(it) + }.toMap()) + } + return Triple(functionNameStr, returnValue, parameters) + } + + private fun getCaptures( + context: FunctionExecuteContext, regex: String, getMockContext: (funcNameStr: String) -> MockContext? + ): Any? { + val functionNameStr = getAndValidateFunctionName(context, regex) + val (baseName, _) = parseFunctionNameWithIndex(functionNameStr) + val instanceId = context.secondParameter + + val obtainedContext = + getMockContext(baseName) ?: throw Exception("Mock function $baseName is not registered.") + + return obtainedContext.mockObject.getCaptures(ConvertUtils.tryParseInt(instanceId)) + } + + private fun getAndValidateFunctionName(context: FunctionExecuteContext, regex: String): String { + val functionName = context.firstParameter + // Get the function name in lower case + val functionNameStr = ConvertUtils.tryToString(functionName)?.lowercase() + if (functionNameStr.isNullOrBlank()) { + throw Exception("Function name to mock is not provided.") + } + if (!validateFunctionName(functionNameStr, regex)) { + throw Exception("Valid function name to mock must provided. Invalid function name: $functionNameStr") + } + return functionNameStr + } + + private fun validateFunctionName(functionName: String, regex: String): Boolean { + return functionName.matches(Regex(regex)) + } + + + private fun tryFindMatch(mockObject: MockObject, executeContext: IExecuteContext): Any? { + val inputParams = executeContext.parameters.mapIndexed { i, it -> + i to JsonConvert.convert(it) + }.toMap() + return mockObject.tryFindMatch(inputParams) + } +} + + diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockMatcher.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockMatcher.kt new file mode 100644 index 0000000..a1d9290 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockMatcher.kt @@ -0,0 +1,92 @@ +package com.intuit.isl.test.mocks + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.intuit.isl.commands.ConditionEvaluator +import com.intuit.isl.utils.JsonConvert + +class MockMatcher(private val field: JsonNode) { + private val parameterMap = hashMapOf() + + init { + when (field) { + is ObjectNode -> { + field.fields().forEach { + parameterMap[it.key] = MockMatcher(it.value) + } + } + + is ArrayNode -> { + field.forEachIndexed { i, it -> + parameterMap[i.toString()] = MockMatcher(it) + } + } + } + } + + fun match(targetNode: JsonNode, looseMatch: Boolean = true): Boolean { + // Keep track of params that have been matched + val paramsToMatch = parameterMap.keys.toMutableSet() + val noParams = paramsToMatch.isEmpty() + var matchedField = false + + when (targetNode) { + is ObjectNode -> { + targetNode.fields().forEach { + val key = it.key + val value = it.value + val matcher = parameterMap[key] + paramsToMatch.remove(key) + if (matcher != null) { + // Check if elements match + if (!matcher.match(value, looseMatch)) { + return false + } + } + // If there's no matcher, then it's considered a match + // only if it's a loose match + else if (!looseMatch) { + return false + } + } + } + + is ArrayNode -> { + targetNode.forEachIndexed { i, it -> + val key = i.toString() + val matcher = parameterMap[key] + if (matcher != null) { + paramsToMatch.remove(key) + // Check if elements match + if (!matcher.match(it, looseMatch)) { + return false + } + } + // If there's no matcher, then it's considered a match + // only if it's a loose match + else if (!looseMatch) { + return false + } + } + } + + else -> { + matchedField = true + val srcValue = JsonConvert.getValue(field) + val targetValue = JsonConvert.getValue(targetNode) + // Do simple check for other elements + if (!ConditionEvaluator.equalish(srcValue, targetValue)) { + return false + } + } + } + // If there's no params left to match, then it's a match + // If the field is matched and there's no params in the first place, then it's a match + return if (noParams && paramsToMatch.isEmpty()) { + matchedField + } else { + paramsToMatch.isEmpty() + } + } +} \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockObject.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockObject.kt new file mode 100644 index 0000000..e3a4354 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockObject.kt @@ -0,0 +1,104 @@ +package com.intuit.isl.test.mocks + +import com.fasterxml.jackson.databind.JsonNode +import com.intuit.isl.utils.JsonConvert + +class MockObject { + private val matchingParamMap = mutableMapOf() + private val matchingParamMapIndexed = mutableMapOf>() + private val matchingParamCallCount = mutableMapOf() + private val matchingParamCaptures = mutableMapOf() + + private var defaultReturnValue: Any? = null + private val defaultReturnIndexed = mutableListOf() + private var defaultCallCount = 0 + private val defaultReturnCaptures = MockCaptureContext() + + fun addMock(returnValue: Any?, parameters: Map, index: Int? = null): Int? { + return if (parameters.isEmpty()) { + if (index != null) { + ensureIndexCapacity(defaultReturnIndexed, index) + defaultReturnIndexed[index - 1] = returnValue + } else { + defaultReturnValue = returnValue + } + null + } else { + val mockMatcher = MockParamsMatcher(parameters) + if (index != null) { + val list = matchingParamMapIndexed.getOrPut(mockMatcher) { mutableListOf() } + ensureIndexCapacity(list, index) + list[index - 1] = returnValue + } else { + matchingParamMap[mockMatcher] = returnValue + } + mockMatcher.hashCode() + } + } + + private fun ensureIndexCapacity(list: MutableList, index: Int) { + while (list.size < index) { + list.add(null) + } + } + + fun getCaptures(instanceId: Int?): Any? { + val captures = mutableListOf>() + if (instanceId == null) { + captures.addAll(matchingParamCaptures.values.flatMap { it.captures }) + captures.addAll(defaultReturnCaptures.captures) + } else { + matchingParamCaptures[instanceId]?.let { + captures.addAll(it.captures) + } + } + + return if (captures.isEmpty()) { + null + } else { + JsonConvert.convert(captures) + } + } + + fun tryFindMatch(targetParams: Map, looseMatch: Boolean = true): Any? { + matchingParamMap.forEach { (matcher, returnValue) -> + if (matcher.match(targetParams, looseMatch)) { + val captureContext = matchingParamCaptures.getOrPut(matcher.hashCode()) { MockCaptureContext() } + captureContext.captures.add(targetParams) + return returnValue + } + } + + matchingParamMapIndexed.forEach { (matcher, returnList) -> + if (matcher.match(targetParams, looseMatch)) { + val captureContext = matchingParamCaptures.getOrPut(matcher.hashCode()) { MockCaptureContext() } + captureContext.captures.add(targetParams) + val callCount = matchingParamCallCount.getOrPut(matcher.hashCode()) { 0 } + if (callCount >= returnList.size) { + throw MockExhaustedException( + "Mock exhausted: expected at most ${returnList.size} call(s), but got call #${callCount + 1}" + ) + } + val result = returnList[callCount] + matchingParamCallCount[matcher.hashCode()] = callCount + 1 + return result + } + } + + // Default (no param match): check indexed first, then standard + defaultReturnCaptures.captures.add(targetParams) + if (defaultReturnIndexed.isNotEmpty()) { + if (defaultCallCount >= defaultReturnIndexed.size) { + throw MockExhaustedException( + "Mock exhausted: expected at most ${defaultReturnIndexed.size} call(s), but got call #${defaultCallCount + 1}" + ) + } + val result = defaultReturnIndexed[defaultCallCount] + defaultCallCount++ + return result + } + return defaultReturnValue + } +} + +class MockExhaustedException(message: String) : Exception(message) \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockParamsMatcher.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockParamsMatcher.kt new file mode 100644 index 0000000..3f9cfe6 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockParamsMatcher.kt @@ -0,0 +1,30 @@ +package com.intuit.isl.test.mocks + +import com.fasterxml.jackson.databind.JsonNode + +class MockParamsMatcher(matchingParams: Map) { + private val matchingParamMap = mutableMapOf() + + init { + matchingParams.forEach { (i, it) -> + matchingParamMap[i] = MockMatcher(it) + } + } + + fun match(targetParams: Map, looseMatch: Boolean = true): Boolean { + targetParams.forEach { (i, it) -> + val matcher = matchingParamMap[i] + if (matcher != null) { + if (!matcher.match(it, looseMatch)) { + return false + } + } + // If there's no matcher, then it's considered a match + // only if it's a loose match + else if (!looseMatch) { + return false + } + } + return true + } +} \ No newline at end of file diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/package.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/package.kt new file mode 100644 index 0000000..b091083 --- /dev/null +++ b/isl-test/src/main/kotlin/com/intuit/isl/test/package.kt @@ -0,0 +1,7 @@ +@file:JvmName("IslTest") +package com.intuit.isl.test + +/** + * ISL test utilities and shared test infrastructure. + * This module depends on isl-transform for running and asserting transforms. + */ diff --git a/isl-test/src/test/kotlin/com/intuit/isl/test/IslTestModuleTest.kt b/isl-test/src/test/kotlin/com/intuit/isl/test/IslTestModuleTest.kt new file mode 100644 index 0000000..4de771a --- /dev/null +++ b/isl-test/src/test/kotlin/com/intuit/isl/test/IslTestModuleTest.kt @@ -0,0 +1,1028 @@ +package com.intuit.isl.test + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.intuit.isl.test.TransformTestPackage +import com.intuit.isl.runtime.FileInfo +import com.intuit.isl.runtime.TransformPackage +import com.intuit.isl.runtime.TransformPackageBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import java.util.stream.Stream +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TransformTestPackageTest { + companion object { + @JvmStatic + fun testFunctions(): Stream { + return Stream.of( + // assert equal = true tests + Arguments.of("@.Assert.equal(1, 1)", true), + Arguments.of("@.Assert.equal(\"hello\", \"hello\")", true), + Arguments.of("@.Assert.equal(null, null)", true), + Arguments.of("@.Assert.equal(true, true)", true), + Arguments.of( + """ + |${"$"}var1 = 1; + |${"$"}var2 = 1; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "number" : 1 }; + |${"$"}var2 = { "number" : 1 }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : "valid" }; + |${"$"}var2 = { "words" : "valid" }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : true }; + |${"$"}var2 = { "words" : true }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : null }; + |${"$"}var2 = { "words" : null }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : [1, 2, 3] }; + |${"$"}var2 = { "words" : [1, 2, 3] }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes" } }; + |${"$"}var2 = { "words" : { "valid" : "yes" } }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes", "words" : { "valid" : "yes" } } }; + |${"$"}var2 = { "words" : { "valid" : "yes", "words" : { "valid" : "yes" } } }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + // Objects with different property order should be equal + Arguments.of( + """ + |${"$"}var1 = { "a" : 1, "b" : 2, "c" : "three" }; + |${"$"}var2 = { "c" : "three", "a" : 1, "b" : 2 }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "outer" : { "x" : 1, "y" : 2 } }; + |${"$"}var2 = { "outer" : { "y" : 2, "x" : 1 } }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + + // assert equal = false tests + Arguments.of("@.Assert.equal(1, 2)", false), + Arguments.of("@.Assert.equal(\"hello\", \"there\")", false), + Arguments.of("@.Assert.equal(null, 1)", false), + Arguments.of("@.Assert.equal(true, false)", false), + Arguments.of( + """ + |${"$"}var1 = 1; + |${"$"}var2 = 2; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "number" : 1 }; + |${"$"}var2 = { "number" : 2 }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : "valid" }; + |${"$"}var2 = { "words" : "not" }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : true }; + |${"$"}var2 = { "words" : false }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : null }; + |${"$"}var2 = { "words" : 1 }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : [1, 2, 3] }; + |${"$"}var2 = { "words" : [1, 2, 4] }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes" } }; + |${"$"}var2 = { "words" : { "valid" : "no" } }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes", "words" : { "valid" : "yes" } } }; + |${"$"}var2 = { "words" : { "valid" : "yes", "words" : { "valid" : "no" } } }; + |@.Assert.equal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + + // assert notequal = false tests + Arguments.of("@.Assert.notequal(1, 1)", false), + Arguments.of("@.Assert.notequal(\"hello\", \"hello\")", false), + Arguments.of("@.Assert.notequal(null, null)", false), + Arguments.of("@.Assert.notequal(true, true)", false), + Arguments.of( + """ + |${"$"}var1 = 1; + |${"$"}var2 = 1; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "number" : 1 }; + |${"$"}var2 = { "number" : 1 }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : "valid" }; + |${"$"}var2 = { "words" : "valid" }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : true }; + |${"$"}var2 = { "words" : true }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : null }; + |${"$"}var2 = { "words" : null }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : [1, 2, 3] }; + |${"$"}var2 = { "words" : [1, 2, 3] }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes" } }; + |${"$"}var2 = { "words" : { "valid" : "yes" } }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes", "words" : { "valid" : "yes" } } }; + |${"$"}var2 = { "words" : { "valid" : "yes", "words" : { "valid" : "yes" } } }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), false + ), + + // assert notequal = true tests + Arguments.of("@.Assert.notequal(1, 2)", true), + Arguments.of("@.Assert.notequal(\"hello\", \"there\")", true), + Arguments.of("@.Assert.notequal(null, 1)", true), + Arguments.of("@.Assert.notequal(true, false)", true), + Arguments.of( + """ + |${"$"}var1 = 1; + |${"$"}var2 = 2; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "number" : 1 }; + |${"$"}var2 = { "number" : 2 }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : "valid" }; + |${"$"}var2 = { "words" : "not" }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : true }; + |${"$"}var2 = { "words" : false }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : null }; + |${"$"}var2 = { "words" : 1 }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : [1, 2, 3] }; + |${"$"}var2 = { "words" : [1, 2, 4] }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes" } }; + |${"$"}var2 = { "words" : { "valid" : "no" } }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + Arguments.of( + """ + |${"$"}var1 = { "words" : { "valid" : "yes", "words" : { "valid" : "yes" } } }; + |${"$"}var2 = { "words" : { "valid" : "yes", "words" : { "valid" : "no" } } }; + |@.Assert.notequal(${"$"}var1, ${"$"}var2) + """.trimMargin(), true + ), + + // assert isnull tests + Arguments.of("@.Assert.isnull(null)", true), + Arguments.of( + """ + |${"$"}var1 = null + |@.Assert.isnull(${"$"}var1) + """.trimMargin(), true + ), + + Arguments.of("@.Assert.isnull(1)", false), + Arguments.of("@.Assert.isnull(\"hello\")", false), + Arguments.of("@.Assert.isnull(true)", false), + Arguments.of("@.Assert.isnull({ \"number\" : 1 })", false), + Arguments.of( + """ + |${"$"}var1 = { "number" : 1 } + |@.Assert.isnull(${"$"}var1) + """.trimMargin(), false + ), + + // assert notnull tests + Arguments.of("@.Assert.notnull(null)", false), + Arguments.of( + """ + |${"$"}var1 = null + |@.Assert.notnull(${"$"}var1) + """.trimMargin(), false + ), + + Arguments.of("@.Assert.notnull(1)", true), + Arguments.of("@.Assert.notnull(\"hello\")", true), + Arguments.of("@.Assert.notnull(true)", true), + Arguments.of("@.Assert.notnull({ \"number\" : 1 })", true), + Arguments.of( + """ + |${"$"}var1 = { "number" : 1 } + |@.Assert.notnull(${"$"}var1) + """.trimMargin(), true + ), + ) + } + + @JvmStatic + fun testMockFunctions(): Stream { + val mapper = ObjectMapper() + return Stream.of( + // mocks with default value + Arguments.of("Test.Function", "hello", true, null, null), + Arguments.of("Test.Function", "hello", true, null, listOf(1)), + Arguments.of("Test.Function", "hello", true, null, listOf("hello")), + Arguments.of("Test.Function", "hello", true, null, listOf(mapper.readTree("{ \"result\" : 1 }"))), + + // mocks that return the value when params exactly match + Arguments.of("Test.Function", "hello", true, listOf("there"), listOf("there")), + Arguments.of("Test.Function", "hello", true, listOf(1), listOf(1)), + Arguments.of("Test.Function", "hello", true, listOf(true), listOf(true)), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : 1 }")), + listOf(mapper.readTree("{ \"result\" : 1 }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : true }")), + listOf(mapper.readTree("{ \"result\" : true }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : null }")), + listOf(mapper.readTree("{ \"result\" : null }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : [1, 2, 3] }")), + listOf(mapper.readTree("{ \"result\" : [1, 2, 3] }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\" } }")), + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\" } }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\", \"result\" : { \"valid\" : \"yes\" } } }")), + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\", \"result\" : { \"valid\" : \"yes\" } } }")) + ), + + // mocks that return the value when params loosely match + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : \"yes\" }")), + listOf(mapper.readTree("{ \"result\" : \"yes\", \"other\" : \"no\" }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : \"yes\" }")), + listOf(mapper.readTree("{ \"result\" : \"yes\", \"other\" : 1 }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : \"yes\" }")), + listOf(mapper.readTree("{ \"result\" : \"yes\", \"other\" : true }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : \"yes\" }")), + listOf(mapper.readTree("{ \"result\" : \"yes\", \"other\" : [1, 2, 3] }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : [1, 2] }")), + listOf(mapper.readTree("{ \"result\" : [1, 2, 4] }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : [{ \"valid\" : \"yes\" }, { \"valid\" : \"no\" }] }")), + listOf(mapper.readTree("{ \"result\" : [{ \"valid\" : \"yes\" }, { \"valid\" : \"no\", \"invalid\" : \"yes\" }, 4] }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\" } }")), + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\", \"not\" : \"there\" } }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\" } }")), + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\", \"not\" : [1, 2, 3] } }")) + ), + Arguments.of( + "Test.Function", + "hello", + true, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\", \"result\" : { \"valid\" : \"yes\" } } }")), + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\", \"result\" : { \"valid\" : \"yes\", \"result\" : { \"valid\" : \"yes\" } } } }")) + ), + + // mocks that don't return the value when params don't exactly match + Arguments.of("Test.Function", "hello", false, listOf(1), listOf(null)), + Arguments.of("Test.Function", "hello", false, listOf("there"), listOf("not there")), + Arguments.of("Test.Function", "hello", false, listOf(true), listOf(false)), + Arguments.of("Test.Function", "hello", false, listOf(1), listOf(2)), + Arguments.of( + "Test.Function", + "hello", + false, + listOf(mapper.readTree("{ \"result\" : 1 }")), + listOf(mapper.readTree("{ \"result\" : 2 }")) + ), + Arguments.of( + "Test.Function", + "hello", + false, + listOf(mapper.readTree("{ \"result\" : true }")), + listOf(mapper.readTree("{ \"result\" : false }")) + ), + Arguments.of( + "Test.Function", + "hello", + false, + listOf(mapper.readTree("{ \"result\" : null }")), + listOf(mapper.readTree("{ \"result\" : 1 }")) + ), + Arguments.of( + "Test.Function", + "hello", + false, + listOf(mapper.readTree("{ \"result\" : [1, 2, 3] }")), + listOf(mapper.readTree("{ \"result\" : [1, 2, 4] }")) + ), + Arguments.of( + "Test.Function", + "hello", + false, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\" } }")), + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"no\" } }")) + ), + Arguments.of( + "Test.Function", + "hello", + false, + listOf(mapper.readTree("{ \"result\" : { \"valid\" : \"yes\" } }")), + listOf(mapper.readTree("{ \"result\" : { \"notValid\" : \"yes\" } }")) + ), + ) + } + + @JvmStatic + fun testCaptureFunctions(): Stream { + return Stream.of( + Arguments.of( + "0", "[{ \"0\" : 0 }]", 1 + ), Arguments.of( + "\"hello\"", "[{ \"0\" : \"hello\" }]", 1 + ), Arguments.of( + "0, 1, 2", "[{ \"0\" : 0, \"1\" : 1, \"2\" : 2 }]", 1 + ), Arguments.of( + "0, \"1\", \"hello\"", "[{ \"0\" : 0, \"1\" : \"1\", \"2\" : \"hello\" }]", 1 + ), Arguments.of( + "0", "[{ \"0\" : 0 }, { \"0\" : 0 }, { \"0\" : 0 }]", 3 + ), Arguments.of( + "\"hello\"", "[{ \"0\" : \"hello\" }, { \"0\" : \"hello\" }, { \"0\" : \"hello\" }]", 3 + ), Arguments.of( + "0, 1, 2", + "[{ \"0\" : 0, \"1\" : 1, \"2\" : 2 }, { \"0\" : 0, \"1\" : 1, \"2\" : 2 }, { \"0\" : 0, \"1\" : 1, \"2\" : 2 }]", + 3 + ), Arguments.of( + "0, \"1\", \"hello\"", + "[{ \"0\" : 0, \"1\" : \"1\", \"2\" : \"hello\" }, { \"0\" : 0, \"1\" : \"1\", \"2\" : \"hello\" }, { \"0\" : 0, \"1\" : \"1\", \"2\" : \"hello\" }]", + 3 + ) + ) + } + } + + private val testFileFunction = "test" + private val testFileName = "testFile.isl" + + private val transformPackageBuilder = TransformPackageBuilder() + private lateinit var transformPackage: TransformPackage + + private fun setup(testFile: String) { + val fileInfo = mutableListOf(FileInfo(testFileName, testFile)) + transformPackage = transformPackageBuilder.build(fileInfo) + } + + private fun createParamsString(params: List?, firstParam: Boolean): String { + return (if (params == null) { + "" + } else ((if (firstParam) { + "" + } else ", ") + params.joinToString(", ", transform = ::createParamString))) + } + + private fun createParamString(param: Any?): String { + return when (param) { + (param == null) -> "null" + is Int -> param.toString() + is JsonNode -> param.toPrettyString() + is Boolean -> param.toString() + else -> "\"$param\"" + } + } + + + @ParameterizedTest + @MethodSource("testFunctions") + fun inputTransformPackageWithTest_RunTest_AssertTestResultExists(func: String) { + val testFile = """ + |@test + |fun ${testFileFunction}() { + | $func + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val testResult = testPackage.runTest(testFileName, "test") + assertFalse(testResult.testResults.isEmpty()) + assertTrue { + testResult.testResults.any { + it.testName == testFileFunction + } + } + } + + @ParameterizedTest + @MethodSource("testFunctions") + fun inputTransformPackageWithTest_RunTest_AssertTestResultEqualsExpected(func: String, expectResult: Boolean) { + val testFile = """ + |@test + |fun ${testFileFunction}() { + | $func + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val testResult = testPackage.runTest(testFileName, "test") + assertFalse(testResult.testResults.isEmpty()) + assertTrue { + testResult.testResults.all { + it.success == expectResult + } + } + } + + @Test + fun inputTransformPackageWithTest_RunFailedTest_InputErrorMessageInError() { + val errorMsg = "This was a failure" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Assert.Equal(1, 2, "$errorMsg") + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertContains(testResult.message ?: "", errorMsg) + } + + @ParameterizedTest + @ValueSource(strings = [ + "Test.Function", + "test.function", + "Test.function", + "test.Function", + "Test.FUNCTION", + "test.FUNCTION", + "Test.function1", + "test.function1", + "Test.FUNCTION1", + "test.FUNCTION1", + "Test.function_1", + "test.function_1", + "Test.FUNCTION_1", + "test.FUNCTION_1", + "Test.Function#1", + "test.function#2" + ]) + fun inputTransformPackageWithMock_MockFunction_VerifyFunctionNamePasses(funcName : String) { + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.func("$funcName") + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @ParameterizedTest + @ValueSource(strings = [ + "Test1.Function", + "test1.function", + "Test1.function", + "test1.Function", + "Test_1.function", + "test_1.function", + "Test_1.function", + "test_1.Function", + "Test_1.h@!!0", + "test.hello.there", + "Test.h@llo", + "test_hell0" + ]) + fun inputTransformPackageWithMock_MockFunction_VerifyIncorrectFunctionNameFails(funcName : String) { + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.func("$funcName") + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertFalse(testResult.success) + } + + @ParameterizedTest + @MethodSource("testMockFunctions") + fun inputTransformPackageWithTestAndMock_RunTestWithMock_AssertMockReturnsInputValue( + functionName: String, + expectedReturnValue: Any?, + expectResult: Boolean, + matchingParams: List? = null, + inputParams: List? = null + ) { + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.func("$functionName", ${createParamString(expectedReturnValue)}${ + createParamsString( + matchingParams, false + ) + }) + | ${"$"}value = @.$functionName(${createParamsString(inputParams, true)}) + | @.Assert.equal(${createParamString(expectedReturnValue)}, ${"$"}value) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertEquals(expectResult, testResult.success, testResult.message) + } + + @ParameterizedTest + @MethodSource("testCaptureFunctions") + fun inputTransformPackageWithTestAndMock_RunMock_VerifyParamCaptureIsReturned( + inputParams: String, expectedCaptures: String, numberOfTimesToCallFunc: Int + ) { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.func("$functionName") + | ${"@.$functionName($inputParams)".repeat(numberOfTimesToCallFunc)} + | ${"$"}value = @.Mock.GetFuncCaptures("$functionName", ${"$"}instanceId) + | @.Assert.equal($expectedCaptures, ${"$"}value) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun runFuncMockWithDifferentParams_GetCapturesWithoutInstanceId_VerifyCorrectParamCaptureIsReturned() { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.func("$functionName", null, 1) + | @.$functionName(0) + | @.$functionName(1) + | ${"$"}value = @.Mock.GetFuncCaptures("$functionName", ${"$"}instanceId) + | @.Assert.equal([{ "0": 1 }], ${"$"}value) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun runFuncMockWithDifferentParams_GetCapturesWithoutInstanceId_VerifyAllCapturesAreReturned() { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.func("$functionName", null, 1) + | @.$functionName(0) + | @.$functionName(1) + | ${"$"}value = @.Mock.GetFuncCaptures("$functionName") + | @.Assert.equal([{ "0": 1 }, {"0": 0 }], ${"$"}value) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun runAnnotationMockWithDifferentParams_GetCapturesWithInstanceId_VerifyCorrectParamCaptureIsReturned() { + val annotationName = "hello" + val functionName = "greetings" + val functionCallName = "This.$functionName" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.annotation("$annotationName", null, 1) + | @.${functionCallName}A(1) + | @.${functionCallName}B(1) + | ${"$"}value = @.Mock.GetAnnotationCaptures("$annotationName", ${"$"}instanceId) + | @.Assert.equal([{ "0": 1 }], ${"$"}value) + |} + | + |@hello(0) + |fun ${functionName}A(${"$"}input) { + | return "Hello" + |} + | + |@hello(1) + |fun ${functionName}B(${"$"}input) { + | return "Hello" + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun runAnnotationMockWithDifferentParams_GetCapturesWithoutInstanceId_VerifyAllCapturesAreReturned() { + val annotationName = "hello" + val functionName = "greetings" + val functionCallName = "This.$functionName" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.annotation("$annotationName", null, 1) + | ${"$"}result1 = @.${functionCallName}1(1) + | ${"$"}result2 = @.${functionCallName}2(1) + | ${"$"}value = @.Mock.GetAnnotationCaptures("$annotationName") + | @.Assert.equal([{ "0": 1 }, {"0": 0 }], ${"$"}value) + |} + | + |@$annotationName(0) + |fun ${functionName}1(${"$"}input) { + | return "Hello" + |} + | + |@$annotationName(1) + |fun ${functionName}2(${"$"}input) { + | return "Hello" + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun runStatementFuncMockWithDifferentParams_GetCapturesWithInstanceId_VerifyCorrectParamCaptureIsReturned() { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.statementFunc("$functionName", null, 1) + | @.$functionName(0) { + | ${"$"}value = 1 + | } + | @.$functionName(1) { + | ${"$"}value = 1 + | } + | ${"$"}value = @.Mock.GetStatementFuncCaptures("$functionName", ${"$"}instanceId) + | @.Assert.equal([{ "0": 1 }], ${"$"}value) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun runStatementFuncMockWithDifferentParams_GetCapturesWithoutInstanceId_VerifyAllCapturesAreReturned() { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | ${"$"}instanceId = @.Mock.statementFunc("$functionName", null, 1) + | @.$functionName(0) { + | ${"$"}value = 1 + | } + | @.$functionName(1) { + | ${"$"}value = 1 + | } + | ${"$"}value = @.Mock.GetStatementFuncCaptures("$functionName") + | @.Assert.equal([{ "0": 1 }, {"0": 0 }], ${"$"}value) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { + it.testName == testFileFunction + } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun indexedFuncMock_ReturnsDifferentValuesPerCall_Succeeds() { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.func("$functionName#1", 5) + | @.Mock.func("$functionName#2", 3) + | @.Mock.func("$functionName#3", null) + | ${"$"}r1 = @.$functionName() + | ${"$"}r2 = @.$functionName() + | ${"$"}r3 = @.$functionName() + | @.Assert.equal(5, ${"$"}r1) + | @.Assert.equal(3, ${"$"}r2) + | @.Assert.isnull(${"$"}r3) + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { it.testName == testFileFunction } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun indexedFuncMock_Exhausted_FailsWithClearMessage() { + val functionName = "Test.Function" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.func("$functionName#1", 5) + | @.Mock.func("$functionName#2", 3) + | ${"$"}r1 = @.$functionName() + | ${"$"}r2 = @.$functionName() + | ${"$"}r3 = @.$functionName() + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { it.testName == testFileFunction } + assertFalse(testResult.success) + assertContains(testResult.message ?: "", "Mock exhausted") + } + + @Test + fun indexedAnnotationMock_AllowsMultipleCallsWhenDefined_Succeeds() { + val annotationName = "seq" + val functionName = "greetings" + val functionCallName = "This.$functionName" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.annotation("$annotationName#1", null) + | @.Mock.annotation("$annotationName#2", null) + | ${"$"}r1 = @.${functionCallName}A() + | ${"$"}r2 = @.${functionCallName}B() + | @.Assert.equal("Hello", ${"$"}r1) + | @.Assert.equal("Hello", ${"$"}r2) + |} + | + |@$annotationName + |fun ${functionName}A() { + | return "Hello" + |} + | + |@$annotationName + |fun ${functionName}B() { + | return "Hello" + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { it.testName == testFileFunction } + assertTrue(testResult.success, testResult.message) + } + + @Test + fun indexedAnnotationMock_Exhausted_FailsWithClearMessage() { + val annotationName = "seq" + val functionName = "greetings" + val functionCallName = "This.$functionName" + val testFile = """ + |@test + |fun ${testFileFunction}() { + | @.Mock.annotation("$annotationName#1", "first") + | ${"$"}r1 = @.${functionCallName}A() + | ${"$"}r2 = @.${functionCallName}B() + |} + | + |@$annotationName + |fun ${functionName}A() { + | return "Hello" + |} + | + |@$annotationName + |fun ${functionName}B() { + | return "Hello" + |} + """.trimMargin() + setup(testFile) + val testPackage = TransformTestPackage(transformPackage) + val result = testPackage.runTest(testFileName, "test") + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first { it.testName == testFileFunction } + assertFalse(testResult.success) + assertContains(testResult.message ?: "", "Mock exhausted") + } +} \ No newline at end of file diff --git a/isl-test/src/test/kotlin/com/intuit/isl/test/LoadFunctionTest.kt b/isl-test/src/test/kotlin/com/intuit/isl/test/LoadFunctionTest.kt new file mode 100644 index 0000000..ca4442b --- /dev/null +++ b/isl-test/src/test/kotlin/com/intuit/isl/test/LoadFunctionTest.kt @@ -0,0 +1,127 @@ +package com.intuit.isl.test + +import com.intuit.isl.runtime.FileInfo +import com.intuit.isl.test.annotations.TestResultContext +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LoadFunctionTest { + + @Test + fun loadFrom_jsonFile_returnsParsedJson(@TempDir tempDir: Path) { + val fixturesDir = tempDir.resolve("tests").resolve("fixtures") + Files.createDirectories(fixturesDir) + Files.writeString(fixturesDir.resolve("data.json"), """{"name": "test", "value": 42}""") + + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testLoadJson() { + | ${'$'}data = @.Load.From("fixtures/data.json") + | @.Assert.equal("test", ${'$'}data.name) + | @.Assert.equal(42, ${'$'}data.value) + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testLoadJson") + + assertFalse(result.testResults.isEmpty(), "Expected test results; got: ${result.testResults}") + val testResult = result.testResults.firstOrNull { it.testName.equals("testLoadJson", ignoreCase = true) } + ?: result.testResults.first() + assertTrue(testResult.success, "Test failed: ${testResult.message}") + } + + @Test + fun loadFrom_yamlFile_returnsParsedJson(@TempDir tempDir: Path) { + val fixturesDir = tempDir.resolve("tests").resolve("fixtures") + Files.createDirectories(fixturesDir) + Files.writeString( + fixturesDir.resolve("config.yaml"), + """ + |key: value + |nested: + | count: 10 + """.trimMargin() + ) + + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testLoadYaml() { + | ${'$'}data = @.Load.From("fixtures/config.yaml") + | @.Assert.equal("value", ${'$'}data.key) + | @.Assert.equal(10, ${'$'}data.nested.count) + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testLoadYaml") + + assertFalse(result.testResults.isEmpty(), "Expected test results; got: ${result.testResults}") + val testResult = result.testResults.firstOrNull { it.testName.equals("testLoadYaml", ignoreCase = true) } + ?: result.testResults.first() + assertTrue(testResult.success, "Test failed: ${testResult.message}") + } + + @Test + fun loadFrom_csvFile_returnsArrayOfObjects(@TempDir tempDir: Path) { + val fixturesDir = tempDir.resolve("tests").resolve("fixtures") + Files.createDirectories(fixturesDir) + Files.writeString( + fixturesDir.resolve("data.csv"), + """ + |id,name,score + |1,Alice,100 + |2,Bob,85 + """.trimMargin() + ) + + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testLoadCsv() { + | ${'$'}data = @.Load.From("fixtures/data.csv") + | @.Assert.equal(2, ${'$'}data | length) + | @.Assert.equal("Alice", ${'$'}data[0].name) + | @.Assert.equal("Bob", ${'$'}data[1].name) + | @.Assert.equal(100, ${'$'}data[0].score) + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testLoadCsv") + + assertFalse(result.testResults.isEmpty(), "Expected test results; got: ${result.testResults}") + val testResult = result.testResults.firstOrNull { it.testName.equals("testLoadCsv", ignoreCase = true) } + ?: result.testResults.first() + assertTrue(testResult.success, "Test failed: ${testResult.message}") + } +} + diff --git a/isl-test/src/test/kotlin/com/intuit/isl/test/MockLoadTest.kt b/isl-test/src/test/kotlin/com/intuit/isl/test/MockLoadTest.kt new file mode 100644 index 0000000..bb41fb1 --- /dev/null +++ b/isl-test/src/test/kotlin/com/intuit/isl/test/MockLoadTest.kt @@ -0,0 +1,169 @@ +package com.intuit.isl.test + +import com.intuit.isl.runtime.FileInfo +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MockLoadTest { + + @Test + fun mockLoad_yamlFile_registersMocksAndReturnsExpectedValues(@TempDir tempDir: Path) { + val mocksDir = tempDir.resolve("tests").resolve("mocks") + Files.createDirectories(mocksDir) + Files.writeString( + mocksDir.resolve("api-mocks.yaml"), + """ + |func: + | - name: "Test.Function#1" + | return: 5 + | - name: "Test.Function#2" + | return: 3 + | - name: "Test.Function#3" + | return: null + """.trimMargin() + ) + + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testMockLoad() { + | @.Mock.Load("mocks/api-mocks.yaml") + | ${'$'}r1 = @.Test.Function() + | ${'$'}r2 = @.Test.Function() + | ${'$'}r3 = @.Test.Function() + | @.Assert.equal(5, ${'$'}r1) + | @.Assert.equal(3, ${'$'}r2) + | @.Assert.isnull(${'$'}r3) + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testMockLoad") + + assertFalse(result.testResults.isEmpty(), "Expected test results; got: ${result.testResults}") + val testResult = result.testResults.firstOrNull { it.testName.equals("testMockLoad", ignoreCase = true) } + ?: result.testResults.first() + assertTrue(testResult.success, "Test failed: ${testResult.message}") + } + + @Test + fun mockLoad_yamlFile_withParams_registersParamBasedMocks(@TempDir tempDir: Path) { + val mocksDir = tempDir.resolve("tests").resolve("mocks") + Files.createDirectories(mocksDir) + Files.writeString( + mocksDir.resolve("param-mocks.yaml"), + """ + |func: + | - name: "Test.Function" + | return: "matched" + | params: [4] + """.trimMargin() + ) + + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testMockLoadParams() { + | @.Mock.Load("mocks/param-mocks.yaml") + | ${'$'}r1 = @.Test.Function(4) + | ${'$'}r2 = @.Test.Function(5) + | @.Assert.equal("matched", ${'$'}r1) + | @.Assert.notEqual("matched", ${'$'}r2) + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testMockLoadParams") + + assertFalse(result.testResults.isEmpty(), "Expected test results; got: ${result.testResults}") + val testResult = result.testResults.firstOrNull { it.testName.equals("testMockLoadParams", ignoreCase = true) } + ?: result.testResults.first() + assertTrue(testResult.success, "Test failed: ${testResult.message}") + } + + @Test + fun mockLoad_jsonFile_registersMocks(@TempDir tempDir: Path) { + val mocksDir = tempDir.resolve("tests").resolve("mocks") + Files.createDirectories(mocksDir) + Files.writeString( + mocksDir.resolve("mocks.json"), + """ + |{ + | "func": [ + | { "name": "Test.Function", "return": "from-json" } + | ] + |} + """.trimMargin() + ) + + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testMockLoadJson() { + | @.Mock.Load("mocks/mocks.json") + | ${'$'}r = @.Test.Function() + | @.Assert.equal("from-json", ${'$'}r) + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testMockLoadJson") + + assertFalse(result.testResults.isEmpty(), "Expected test results; got: ${result.testResults}") + val testResult = result.testResults.firstOrNull { it.testName.equals("testMockLoadJson", ignoreCase = true) } + ?: result.testResults.first() + assertTrue(testResult.success, "Test failed: ${testResult.message}") + } + + @Test + fun mockLoad_fileNotFound_failsWithClearMessage(@TempDir tempDir: Path) { + val testIsl = tempDir.resolve("tests").resolve("sample.isl") + Files.createDirectories(testIsl.parent) + Files.writeString( + testIsl, + """ + |@test + |fun testMockLoadMissing() { + | @.Mock.Load("mocks/nonexistent.yaml") + |} + """.trimMargin() + ) + + val fileInfos = mutableListOf( + FileInfo("tests/sample.isl", testIsl.toFile().readText()) + ) + val testPackage = TransformTestPackageBuilder().build(fileInfos, null, tempDir) + val result = testPackage.runTest("tests/sample.isl", "testMockLoadMissing") + + assertFalse(result.testResults.isEmpty()) + val testResult = result.testResults.first() + assertFalse(testResult.success) + assertContains(testResult.message ?: "", "File not found") + } +} diff --git a/isl.bat b/isl.bat index 84ae92e..ef6251b 100644 --- a/isl.bat +++ b/isl.bat @@ -1,4 +1,5 @@ @echo off +setlocal enabledelayedexpansion REM ISL Command Line Runner for Windows REM REM This script allows you to run ISL commands without manually invoking Gradle @@ -8,8 +9,6 @@ REM isl.bat --version REM isl.bat info REM isl.bat transform script.isl -i input.json -setlocal - REM Get the directory where this script is located set SCRIPT_DIR=%~dp0 @@ -19,15 +18,16 @@ for /f "tokens=1,* delims==" %%a in ('findstr "^version=" "%SCRIPT_DIR%gradle.pr REM Check if the shadow JAR exists set JAR_FILE=%SCRIPT_DIR%isl-cmd\build\libs\isl-%VERSION%.jar if exist "%JAR_FILE%" ( - REM Use the pre-built JAR + REM Use the pre-built JAR (runs from current directory) java -jar "%JAR_FILE%" %* ) else ( - REM Fall back to Gradle + REM Fall back to Gradle (pass invocation dir so test/search use it) + set "INVOCATION_DIR=%cd%" echo Shadow JAR not found. Building and running via Gradle... echo Run "gradlew.bat :isl-cmd:shadowJar" to build the JAR for faster startup. echo. cd /d "%SCRIPT_DIR%" - call "%SCRIPT_DIR%gradlew.bat" :isl-cmd:run --quiet --console=plain --args="%*" + call "%SCRIPT_DIR%gradlew.bat" :isl-cmd:run -PrunWorkingDir="!INVOCATION_DIR!" --quiet --console=plain --args="%*" ) endlocal diff --git a/isl.sh b/isl.sh index f9bfea0..4296340 100644 --- a/isl.sh +++ b/isl.sh @@ -21,11 +21,12 @@ if [ -f "$JAR_FILE" ]; then # Use the pre-built JAR java -jar "$JAR_FILE" "$@" else - # Fall back to Gradle + # Fall back to Gradle (pass invocation dir so test/search use it) + INVOCATION_DIR="$(pwd)" echo "Shadow JAR not found. Building and running via Gradle..." echo "Run './gradlew :isl-cmd:shadowJar' to build the JAR for faster startup." echo "" cd "$SCRIPT_DIR" - ./gradlew :isl-cmd:run --quiet --console=plain --args="$*" + ./gradlew :isl-cmd:run --quiet --console=plain -PrunWorkingDir="$INVOCATION_DIR" --args="$*" fi diff --git a/settings.gradle.kts b/settings.gradle.kts index e0970f3..1880413 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,3 +3,4 @@ rootProject.name = "isl-pom" include("isl-transform") include("isl-validation") include("isl-cmd") +include("isl-test")