ananke/testing/coverage/coverage_test.go

190 lines
4.8 KiB
Go
Raw Normal View History

package coverage
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"testing"
)
type coverageBlock struct {
file string
stmts float64
hitCount float64
}
type fileCoverage struct {
covered float64
total float64
}
func repoRoot(tb testing.TB) string {
tb.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
tb.Fatalf("unable to resolve caller path")
}
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
}
// runCoverageCommand runs one orchestration or CLI step.
// Signature: runCoverageCommand(t *testing.T, dir string, coverFile string, args ...string).
// Why: coverage is now merged from both root and top-level testing modules, so
// command execution needs one shared helper to avoid drift between invocations.
func runCoverageCommand(t *testing.T, dir string, coverFile string, args ...string) {
t.Helper()
cmdArgs := append([]string{"test"}, args...)
cmdArgs = append(cmdArgs, "-coverprofile="+coverFile)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go test coverage run failed in %s: %v\n%s", dir, err, out)
}
}
// parseCoverageProfile runs one orchestration or CLI step.
// Signature: parseCoverageProfile(t *testing.T, path string, blocks map[string]coverageBlock).
// Why: merged coverage requires block-level union logic so hits from either
// module count toward per-file coverage without double-counting statements.
func parseCoverageProfile(t *testing.T, path string, blocks map[string]coverageBlock) {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open coverprofile %s: %v", path, err)
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "mode:") {
continue
}
parts := strings.Fields(line)
if len(parts) != 3 {
t.Fatalf("unexpected coverprofile line: %q", line)
}
loc := parts[0]
stmts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
t.Fatalf("parse statement count %q: %v", parts[1], err)
}
hitCount, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
t.Fatalf("parse hit count %q: %v", parts[2], err)
}
file := strings.SplitN(loc, ":", 2)[0]
key := loc + "|" + parts[1]
block := blocks[key]
block.file = file
block.stmts = stmts
if hitCount > block.hitCount {
block.hitCount = hitCount
}
blocks[key] = block
}
if err := s.Err(); err != nil {
t.Fatalf("scan coverprofile %s: %v", path, err)
}
}
func TestPerFileCoverageReport(t *testing.T) {
root := repoRoot(t)
tmp := t.TempDir()
rootCover := filepath.Join(tmp, "ananke.root.cover.out")
configCover := filepath.Join(tmp, "ananke.testing.config.cover.out")
testingCover := filepath.Join(tmp, "ananke.testing.cover.out")
runCoverageCommand(t, root, rootCover, "./...")
runCoverageCommand(
t,
filepath.Join(root, "testing"),
configCover,
"./config",
"-coverpkg=scm.bstein.dev/bstein/ananke/...",
)
runCoverageCommand(
t,
filepath.Join(root, "testing"),
testingCover,
"./execx",
"./metrics",
"./hygiene",
"./orchestrator",
"./state",
"./sshutil",
"./ups",
"-coverpkg=scm.bstein.dev/bstein/ananke/...",
)
blocks := map[string]coverageBlock{}
parseCoverageProfile(t, rootCover, blocks)
parseCoverageProfile(t, configCover, blocks)
parseCoverageProfile(t, testingCover, blocks)
byFile := map[string]*fileCoverage{}
for _, block := range blocks {
fc := byFile[block.file]
if fc == nil {
fc = &fileCoverage{}
byFile[block.file] = fc
}
fc.total += block.stmts
if block.hitCount > 0 {
fc.covered += block.stmts
}
}
target := 95.0
if raw := strings.TrimSpace(os.Getenv("ANANKE_PER_FILE_COVERAGE_TARGET")); raw != "" {
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
t.Fatalf("invalid ANANKE_PER_FILE_COVERAGE_TARGET=%q: %v", raw, err)
}
target = parsed
}
enforce := strings.TrimSpace(os.Getenv("ANANKE_ENFORCE_COVERAGE")) == "1"
keys := make([]string, 0, len(byFile))
for file := range byFile {
if strings.Contains(file, "/testing/") {
continue
}
keys = append(keys, file)
}
sort.Strings(keys)
var report bytes.Buffer
report.WriteString("per-file coverage\n")
under := make([]string, 0)
for _, file := range keys {
fc := byFile[file]
pct := 100.0
if fc.total > 0 {
pct = (fc.covered / fc.total) * 100.0
}
report.WriteString(fmt.Sprintf("- %s: %.1f%%\n", file, pct))
if pct < target {
under = append(under, fmt.Sprintf("%s (%.1f%% < %.1f%%)", file, pct, target))
}
}
t.Log(report.String())
if len(under) > 0 && enforce {
t.Fatalf("files below coverage target:\n%s", strings.Join(under, "\n"))
}
if len(under) > 0 && !enforce {
t.Logf("coverage target %.1f%% is not enforced yet; %d files are below target", target, len(under))
}
}