Skip to content

Commit

Permalink
Merge pull request #5 from kleis-technology/cli/test-runner
Browse files Browse the repository at this point in the history
Cli/test runner
  • Loading branch information
pevab authored Jan 18, 2024
2 parents 4cf5c0e + bf1ddd3 commit 4f9a6c3
Show file tree
Hide file tree
Showing 17 changed files with 585 additions and 33 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is the official repository of LCA as Code language.
## What is LCA as Code?

LCA as Code is a domain-specific language (DSL) for life-cycle analysis experts.
Its *declarative* approach enables to seamlessly define *parametrized* and *reusable* LCA models.
Its *declarative* approach allows to define *parametrized* and *reusable* LCA models.

![LCA as Code](./assets/logo-white-60pct.png)

Expand All @@ -22,7 +22,9 @@ Its *declarative* approach enables to seamlessly define *parametrized* and *reus

## Getting started

Check the sample file in `$GIT_ROOT/cli/samples`.
### Impact assessment

Check the sample file in `$GIT_ROOT/cli/samples/main.lca`.

```lca
process electricity_mix {
Expand Down Expand Up @@ -65,6 +67,26 @@ You can also run multiple assessments with an external data csv file providing v
lcaac assess "electricity_mix" --data params.csv
```

### Tests

The language allows to define tests as well. See the file `$GIT_ROOT/cli/samples/test.lca`.

```lca
test should_pass {
given {
1 kWh electricity from electricity_mix
}
assert {
co2 between 0 kg and 10 kg
}
}
// rest of the file omitted
```

Run the tests using
```bash
lcaac test --show-success
```

## What's inside

Expand Down
15 changes: 15 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ cd $GIT_ROOT/cli/samples
lcaac assess "electricity_mix" --data params.csv
```

## Run tests

You can run all the tests with the following command.
```bash
cd $GIT_ROOT/cli/samples
lcaac test
```

By default, the command does not show the successful assertions.
To show the successful assertions, run
```bash
cd $GIT_ROOT/cli/samples
lcaac test --show-success
```

## Help

```bash
Expand Down
17 changes: 17 additions & 0 deletions cli/samples/test.lca
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
test should_pass {
given {
1 kWh electricity from electricity_mix
}
assert {
co2 between 0 kg and 10 kg
}
}

test should_fail {
given {
1 kWh electricity from electricity_mix
}
assert {
co2 between 100 kg and 1000 kg
}
}
6 changes: 5 additions & 1 deletion cli/src/main/kotlin/ch/kleis/lcaac/cli/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package ch.kleis.lcaac.cli

import ch.kleis.lcaac.cli.cmd.AssessCommand
import ch.kleis.lcaac.cli.cmd.LcaacCommand
import ch.kleis.lcaac.cli.cmd.TestCommand
import com.github.ajalt.clikt.core.subcommands

fun main(args: Array<String>) = LcaacCommand()
.subcommands(AssessCommand())
.subcommands(
AssessCommand(),
TestCommand(),
)
.main(args)
33 changes: 3 additions & 30 deletions cli/src/main/kotlin/ch/kleis/lcaac/cli/cmd/AssessCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,8 @@ import ch.kleis.lcaac.cli.csv.CsvProcessor
import ch.kleis.lcaac.cli.csv.CsvRequest
import ch.kleis.lcaac.cli.csv.CsvRequestReader
import ch.kleis.lcaac.cli.csv.CsvResultWriter
import ch.kleis.lcaac.core.lang.SymbolTable
import ch.kleis.lcaac.core.math.basic.BasicNumber
import ch.kleis.lcaac.core.math.basic.BasicOperations
import ch.kleis.lcaac.grammar.Loader
import ch.kleis.lcaac.grammar.LoaderOption
import ch.kleis.lcaac.grammar.parser.LcaLangLexer
import ch.kleis.lcaac.grammar.parser.LcaLangParser
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.help
Expand All @@ -19,21 +14,18 @@ import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.help
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.file
import org.antlr.v4.runtime.CharStreams
import org.antlr.v4.runtime.CommonTokenStream
import java.io.File
import java.io.InputStream
import java.nio.file.Files
import kotlin.io.path.isRegularFile

@Suppress("MemberVisibilityCanBePrivate")
class AssessCommand : CliktCommand(name = "assess", help = "Returns the unitary impacts of a process in CSV format") {
val name: String by argument().help("Process name")
val labels: Map<String, String> by option("-l", "--label").associate()
val root: File by option("-r", "--root").file(canBeFile = false).default(File(".")).help("Root folder")
val data: File? by option("-d", "--data").file(canBeDir = false).help("CSV file with parameter values")

override fun run() {
val symbolTable = loadSymbolTable()
val files = lcaFiles(root)
val symbolTable = Loader(BasicOperations).load(files)
val processor = CsvProcessor(symbolTable)
val iterator = loadRequests()
val writer = CsvResultWriter()
Expand All @@ -60,23 +52,4 @@ class AssessCommand : CliktCommand(name = "assess", help = "Returns the unitary
val reader = CsvRequestReader(name, labels, file.inputStream())
return reader.iterator()
}

private fun loadSymbolTable(): SymbolTable<BasicNumber> {
val files = Files.walk(root.toPath())
.filter { it.isRegularFile() }
.filter { it.toString().endsWith(".lca") }
.map { lcaFile(it.toFile().inputStream()) }
.toList()
.asSequence()
val loader = Loader(BasicOperations)
return loader.load(files, listOf(LoaderOption.WITH_PRELUDE))
}

private fun lcaFile(inputStream: InputStream): LcaLangParser.LcaFileContext {
val lexer = LcaLangLexer(CharStreams.fromStream(inputStream))
val tokens = CommonTokenStream(lexer)
val parser = LcaLangParser(tokens)
return parser.lcaFile()
}

}
59 changes: 59 additions & 0 deletions cli/src/main/kotlin/ch/kleis/lcaac/cli/cmd/TestCommand.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package ch.kleis.lcaac.cli.cmd

import ch.kleis.lcaac.core.math.basic.BasicOperations
import ch.kleis.lcaac.core.testing.BasicTestRunner
import ch.kleis.lcaac.core.testing.GenericFailure
import ch.kleis.lcaac.core.testing.RangeAssertionFailure
import ch.kleis.lcaac.core.testing.RangeAssertionSuccess
import ch.kleis.lcaac.grammar.CoreTestMapper
import ch.kleis.lcaac.grammar.Loader
import ch.kleis.lcaac.grammar.LoaderOption
import ch.kleis.lcaac.grammar.parser.LcaLangParser
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.help
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.file
import java.io.File

private const val greenTick = "\u2705"
private const val redCross = "\u274C"

class TestCommand : CliktCommand(name = "test", help = "Run specified tests") {
val root: File by option("-r", "--root").file(canBeFile = false).default(File(".")).help("Root folder")
val data: File? by option("-d", "--data").file(canBeDir = false).help("CSV file with parameter values")
val showSuccess: Boolean by option("--show-success").flag(default = false).help("Show successful assertions")

override fun run() {
val files = lcaFiles(root)
val symbolTable = Loader(BasicOperations).load(files, listOf(LoaderOption.WITH_PRELUDE))
val mapper = CoreTestMapper()
val cases = files
.flatMap { it.testDefinition() }
.map { mapper.test(it) }
val runner = BasicTestRunner<LcaLangParser.TestDefinitionContext>(symbolTable)
val results = cases.map { runner.run(it) }

results.forEach { result ->
result.results.forEachIndexed { id, assertion ->
val isSuccess: Boolean = assertion is RangeAssertionSuccess
val tick = if (isSuccess) greenTick else redCross

val message = when (assertion) {
is GenericFailure -> assertion.message
is RangeAssertionFailure -> "${assertion.name} = ${assertion.actual} is not in between ${assertion.lo} and ${assertion.hi}"
is RangeAssertionSuccess -> "${assertion.name} = ${assertion.actual} is in between ${assertion.lo} and ${assertion.hi}"
}
if ((isSuccess && showSuccess) || !isSuccess)
echo("$tick ${result.name}[$id] $message")
}
}
val nbTests = results.flatMap { it.results }.count()
val nbSuccesses = results.flatMap { it.results }.count { it is RangeAssertionSuccess }
val nbFailures = nbTests - nbSuccesses
echo("Run $nbTests tests, $nbSuccesses passed, $nbFailures failed")
if (nbFailures > 0) throw ProgramResult(1)
}
}
26 changes: 26 additions & 0 deletions cli/src/main/kotlin/ch/kleis/lcaac/cli/cmd/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ch.kleis.lcaac.cli.cmd

import ch.kleis.lcaac.grammar.parser.LcaLangLexer
import ch.kleis.lcaac.grammar.parser.LcaLangParser
import org.antlr.v4.runtime.CharStreams
import org.antlr.v4.runtime.CommonTokenStream
import java.io.File
import java.io.InputStream
import java.nio.file.Files
import kotlin.io.path.isRegularFile

fun lcaFiles(root: File): Sequence<LcaLangParser.LcaFileContext> {
return Files.walk(root.toPath())
.filter { it.isRegularFile() }
.filter { it.toString().endsWith(".lca") }
.map { lcaFile(it.toFile().inputStream()) }
.toList()
.asSequence()
}

private fun lcaFile(inputStream: InputStream): LcaLangParser.LcaFileContext {
val lexer = LcaLangLexer(CharStreams.fromStream(inputStream))
val tokens = CommonTokenStream(lexer)
val parser = LcaLangParser(tokens)
return parser.lcaFile()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ch.kleis.lcaac.core.testing

import ch.kleis.lcaac.core.lang.value.QuantityValue
import ch.kleis.lcaac.core.math.basic.BasicNumber

sealed interface AssertionResult

data class RangeAssertionSuccess(
val name: String,
val lo: QuantityValue<BasicNumber>,
val hi: QuantityValue<BasicNumber>,
val actual: QuantityValue<BasicNumber>,
) : AssertionResult

data class GenericFailure(val message: String) : AssertionResult

data class RangeAssertionFailure(
val name: String,
val lo: QuantityValue<BasicNumber>,
val hi: QuantityValue<BasicNumber>,
val actual: QuantityValue<BasicNumber>,
) : AssertionResult
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package ch.kleis.lcaac.core.testing

import ch.kleis.lcaac.core.assessment.ContributionAnalysisProgram
import ch.kleis.lcaac.core.lang.SymbolTable
import ch.kleis.lcaac.core.lang.evaluator.Evaluator
import ch.kleis.lcaac.core.lang.evaluator.EvaluatorException
import ch.kleis.lcaac.core.lang.evaluator.ToValue
import ch.kleis.lcaac.core.lang.evaluator.reducer.DataExpressionReducer
import ch.kleis.lcaac.core.lang.value.DataValue
import ch.kleis.lcaac.core.lang.value.QuantityValue
import ch.kleis.lcaac.core.lang.value.QuantityValueOperations
import ch.kleis.lcaac.core.math.basic.BasicNumber
import ch.kleis.lcaac.core.math.basic.BasicOperations

class BasicTestRunner<S>(
symbolTable: SymbolTable<BasicNumber>,
private val evaluator: Evaluator<BasicNumber> = Evaluator(symbolTable, BasicOperations),
private val dataReducer: DataExpressionReducer<BasicNumber> = DataExpressionReducer(symbolTable.data, BasicOperations),
) {
fun run(case: TestCase<S>): TestResult<S> {
try {
val trace = evaluator.with(case.template).trace(case.template)
val program = ContributionAnalysisProgram(trace.getSystemValue(), trace.getEntryPoint())
val analysis = program.run()
val target = trace.getEntryPoint().products.first().port()
val results = case.assertions.map { assertion ->
val ports = analysis.findAllPortsByShortName(assertion.ref)
if (ports.isEmpty()) {
GenericFailure("unknown reference '${assertion.ref}'")
} else {
val impact = with(QuantityValueOperations(BasicOperations)) {
ports.map {
if (analysis.isControllable(it)) analysis.getPortContribution(target, it)
else analysis.supplyOf(it)
}.reduce { acc, quantityValue -> acc + quantityValue }
}
val lo = with(ToValue(BasicOperations)) { dataReducer.reduce(assertion.lo).toValue() }
val hi = with(ToValue(BasicOperations)) { dataReducer.reduce(assertion.hi).toValue() }
test(assertion.ref, impact, lo, hi)
}
}
return TestResult(
case.source,
case.name,
results,
)
} catch (e: EvaluatorException) {
return TestResult(
case.source,
case.name,
listOf(
GenericFailure(e.message ?: "unknown error"),
)
)
}
}

private fun test(
name: String,
impact: QuantityValue<BasicNumber>,
lo: DataValue<BasicNumber>,
hi: DataValue<BasicNumber>
): AssertionResult {
with(QuantityValueOperations(BasicOperations)) {
val actual = impact.toDouble()
return when {
lo is QuantityValue<BasicNumber> && hi is QuantityValue<BasicNumber> ->
when {
!allTheSameDimension(impact, lo, hi) ->
GenericFailure("incompatible dimensions: $name (${impact.unit.dimension}) between $lo (${lo.unit.dimension}) and $hi (${hi.unit.dimension})")

lo.toDouble() <= actual && actual <= hi.toDouble() ->
RangeAssertionSuccess(name, lo, hi, impact)

else -> RangeAssertionFailure(name, lo, hi, impact)
}

else -> GenericFailure("invalid range: $lo and $hi")
}
}
}

private fun allTheSameDimension(
a: QuantityValue<BasicNumber>,
b: QuantityValue<BasicNumber>,
c: QuantityValue<BasicNumber>
): Boolean {
return a.unit.dimension == b.unit.dimension
&& b.unit.dimension == c.unit.dimension
}
}
10 changes: 10 additions & 0 deletions core/src/main/kotlin/ch/kleis/lcaac/core/testing/RangeAssertion.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ch.kleis.lcaac.core.testing

import ch.kleis.lcaac.core.lang.expression.DataExpression
import ch.kleis.lcaac.core.math.basic.BasicNumber

data class RangeAssertion(
val ref: String,
val lo: DataExpression<BasicNumber>,
val hi: DataExpression<BasicNumber>,
)
13 changes: 13 additions & 0 deletions core/src/main/kotlin/ch/kleis/lcaac/core/testing/TestCase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ch.kleis.lcaac.core.testing

import ch.kleis.lcaac.core.lang.expression.DataExpression
import ch.kleis.lcaac.core.lang.expression.EProcessTemplate
import ch.kleis.lcaac.core.math.basic.BasicNumber

data class TestCase<S>(
val source: S,
val name: String,
val assertions: List<RangeAssertion>,
val template: EProcessTemplate<BasicNumber>,
val arguments: Map<String, DataExpression<BasicNumber>> = emptyMap(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ch.kleis.lcaac.core.testing

data class TestResult<S>(
val source: S,
val name: String,
val results: List<AssertionResult>,
)
Loading

0 comments on commit 4f9a6c3

Please sign in to comment.