lyng/lynglib/src/jvmTest/kotlin/BookTest.kt

286 lines
8.7 KiB
Kotlin

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Context
import net.sergeych.lyng.ObjVoid
import java.nio.file.Files
import java.nio.file.Files.readAllLines
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
import kotlin.io.path.extension
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
fun leftMargin(s: String): Int {
var cnt = 0
for (c in s) {
when (c) {
' ' -> cnt++
'\t' -> cnt = (cnt / 4.0 + 0.9).toInt() * 4
else -> break
}
}
return cnt
}
data class DocTest(
val fileName: String,
val line: Int,
val code: String,
val expectedOutput: String,
val expectedResult: String,
val expectedError: String? = null,
val bookMode: Boolean = false
) {
val sourceLines by lazy { code.lines() }
val fileNamePart by lazy {
Paths.get(fileName).fileName.toString()
}
override fun toString(): String {
val absPath = Paths.get(fileName).absolutePathString()
return "DocTest: $absPath:${line + 1}"
}
val detailedString by lazy {
val codeWithLines = sourceLines.withIndex().map { (i, s) -> "${i + line + 1}: $s" }.joinToString("\n")
var result = "$this\n$codeWithLines\n"
if( !bookMode) {
if (expectedOutput.isNotBlank())
result += "--------expected output--------\n$expectedOutput\n"
"$result-----expected return value-----\n$expectedResult"
}
else result
}
}
fun parseDocTests(fileName: String, bookMode: Boolean = false): Flow<DocTest> = flow {
val book = readAllLines(Paths.get(fileName))
var startOffset = 0
val block = mutableListOf<String>()
var startIndex = 0
for ((index, l) in book.withIndex()) {
val off = leftMargin(l)
when {
off < startOffset && startOffset != 0 -> {
if (l.isBlank()) {
continue
}
// end of block or just text:
if (block.isNotEmpty()) {
// check/create block
// 2 lines min
if (block.size > 1) {
// remove prefix
for ((i, s) in block.withIndex()) {
var x = s
// could be tabs :(
val initial = leftMargin(x)
do {
x = x.drop(1)
} while (initial - leftMargin(x) != startOffset)
block[i] = x
}
if (bookMode) {
emit(
DocTest(
fileName, startIndex,
block.joinToString("\n"),
"",
"",
null,
bookMode = true
)
)
}
// println(block.joinToString("\n") { "${startIndex + ii++}: $it" })
val outStart = block.indexOfFirst { it.startsWith(">>>") }
if (outStart < 0) {
// println("No output at block from line ${startIndex+1}")
} else {
var isValid = true
val result = mutableListOf<String>()
while (block.size > outStart) {
val line = block.removeAt(outStart)
if (!line.startsWith(">>> ")) {
println("invalid output line, must start with '>>> ', block from ${startIndex + 1}: $line")
isValid = false
break
}
result.add(line.drop(4))
}
if (isValid) {
emit(
DocTest(
fileName, startIndex,
block.joinToString("\n"),
if (result.size > 1)
result.dropLast(1).joinToString("") { it + "\n" }
else "",
result.last()
)
)
}
}
// last line '>>>'
}
block.clear()
startOffset = 0
}
}
off != 0 && startOffset == 0 -> {
// start
block.clear()
startIndex = index
block.add(l)
startOffset = off
}
off != 0 -> {
block.add(l)
}
off == 0 && startOffset == 0 -> {
// skip
}
else -> {
throw RuntimeException("Unexpected line: ($off/$startOffset) $l")
}
}
}
}
.flowOn(Dispatchers.IO)
suspend fun DocTest.test(context: Context = Context()) {
val collectedOutput = StringBuilder()
val currentTest = this
context.apply {
addFn("println") {
if( bookMode ) {
println("${currentTest.fileNamePart}:${currentTest.line}> ${args.joinToString(" "){it.asStr.value}}")
}
else {
for ((i, a) in args.withIndex()) {
if (i > 0) collectedOutput.append(' '); collectedOutput.append(a.asStr.value)
collectedOutput.append('\n')
}
}
ObjVoid
}
}
var error: Throwable? = null
val result = try {
context.eval(code)
} catch (e: Throwable) {
error = e
null
}?.inspect()?.replace(Regex("@\\d+"), "@...")
if (bookMode) {
if (error != null) {
println("Sample failed: ${this.detailedString}")
fail("book sample failed", error)
}
} else {
if (error != null || expectedOutput != collectedOutput.toString() ||
expectedResult != result
) {
System.err.println("\nfailed: ${this.detailedString}")
}
error?.let {
fail(it.message, it)
}
assertEquals(expectedOutput, collectedOutput.toString(), "script output do not match")
assertEquals(expectedResult, result.toString(), "script result does not match")
// println("OK: $this")
}
}
suspend fun runDocTests(fileName: String, bookMode: Boolean = false) {
val bookContext = Context()
var count = 0
parseDocTests(fileName, bookMode).collect { dt ->
if (bookMode) dt.test(bookContext)
else dt.test()
count++
}
print("completed mdtest: $fileName; ")
if (bookMode)
println("fragments processed: $count")
else
println("tests passed: $count")
}
class BookTest {
@Test
fun testsFromTutorial() = runTest {
runDocTests("../docs/tutorial.md")
}
@Test
fun testsFromMath() = runTest {
runDocTests("../docs/math.md")
}
@Test
fun testsFromAdvanced() = runTest {
runDocTests("../docs/advanced_topics.md")
}
@Test
fun testsFromOOP() = runTest {
runDocTests("../docs/OOP.md")
}
@Test
fun testFromReal() = runTest {
runDocTests("../docs/Real.md")
}
@Test
fun testFromList() = runTest {
runDocTests("../docs/List.md")
}
@Test
fun testFromRange() = runTest {
runDocTests("../docs/Range.md")
}
@Test
fun testSet() = runTest {
runDocTests("../docs/Set.md")
}
@Test
fun testMap() = runTest {
runDocTests("../docs/Map.md")
}
@Test
fun testSampleBooks() = runTest {
for (bt in Files.list(Paths.get("../docs/samples")).toList()) {
if (bt.extension == "md") {
runDocTests(bt.toString(), bookMode = true)
}
}
}
@Test
fun testArgumentBooks() = runTest {
runDocTests("../docs/declaring_arguments.md")
}
@Test
fun testExceptionsBooks() = runTest {
runDocTests("../docs/exceptions_handling.md")
}
}