diff --git a/pyproject.toml b/pyproject.toml index 6e4dedd..16a0fae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "1.20.1" +version = "1.20.2" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/user_calls.cpp b/src/user_calls.cpp index c7b13cf..919503f 100644 --- a/src/user_calls.cpp +++ b/src/user_calls.cpp @@ -691,6 +691,14 @@ void Evaluator::recordTailCallHop(const std::string& calleeName, const oscad::AS // and never see the NEW one at all. noteActiveDeclExit(frame.declNode); noteActiveDeclEnter(&calleeDecl); + // Body coverage: this declaration was entered, exactly as in + // enterUserCall. A hop is the ONLY way into a tail-called body, so + // without this a function reached only in tail position reports its + // body as never run while its own branch arms report hits -- which + // reads as "the condition was not covered but the branches were". + // BOSL2's _str_split_recurse is the shape that shows it: recursion + // through a ternary arm is a tail call every time. + coverHit(calleeDecl); frame.name = calleeName; frame.declNode = &calleeDecl; frame.declPosition = &calleeDecl.position(); diff --git a/tests/test_coverage.cpp b/tests/test_coverage.cpp index d7863c4..15a0d8e 100644 --- a/tests/test_coverage.cpp +++ b/tests/test_coverage.cpp @@ -168,6 +168,33 @@ TEST(Coverage, VmAndInterpreterAgree) { } } +// A tail call hops the frame instead of pushing one, so it never reaches +// enterUserCall -- where the body hit is recorded. A function reached ONLY +// in tail position therefore reported its body as never run while its own +// ternary arms reported hits, which reads on screen as "the condition is +// uncovered but both branches are covered". BOSL2's _str_split_recurse is +// the shape that shows it: recursion through a ternary arm is a tail call +// every time. +TEST(Coverage, TailCalledBodyIsCounted) { + const std::string code = + "function inner(i) = i == 0 ? \"done\" : inner(i-1);\n" + "function tail_caller(i) = inner(i);\n" + "echo(tail_caller(2));\n"; + for (bool useVm : {true, false}) { + ScopedVm vm(useVm); + const CoverageResult r = cover(code); + std::uint32_t innerBody = 0, arms = 0; + for (const CoverageSpan& s : r.spans) { + if (s.kind == CoverageKind::Body && s.line == 1) innerBody = s.hits; + if (s.kind == CoverageKind::Branch && s.line == 1) arms += s.hits; + } + EXPECT_GT(innerBody, 0u) << (useVm ? "VM" : "interpreter") + << ": a body reached only by tail calls still ran"; + EXPECT_EQ(innerBody, arms) << (useVm ? "VM" : "interpreter") + << ": every entry takes exactly one arm"; + } +} + TEST(Coverage, PerFilePercentages) { const CoverageResult r = cover( "function f(x) = x > 0 ? 1 : 2;\n" // body hit; arms: 1 of 2