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 = flow { val book = readAllLines(Paths.get(fileName)) var startOffset = 0 val block = mutableListOf() 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() 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") } }