sourcejig::Runner.fan

//
// Copyright (c) 2026, Novant LLC
// Licensed under the MIT License
//
// History:
//   26 Oct 2026  Andy Frank  Creation
//

using concurrent

*************************************************************************
** Runner
*************************************************************************

** Runner spawns fant, parses its output, and renders a live
** status bar with results printed above it.  Failures get one line
** as they happen, with full output and trace held for the summary.
class Runner : FantListener
{
  ** Create runner; in 'quiet' mode only failures are printed,
  ** otherwise a pass/fail line is printed for each test type
  ** grouped by pod.
  new make(Str[] targets, Str[] tests, Bool quiet := false, Bool? tty := null, OutStream out := Env.cur.out)
  {
    this.targets = targets
    this.total   = tests.size

    // expected methods per type; name and count columns are sized
    // across all pods so they line up for the whole run
    pods := Str:Str[:]
    tests.each |q|
    {
      type := typeOf(q)
      typeTotals[type] = (typeTotals[type] ?: 0) + 1
      pod := podOf(q)
      pods[pod] = pod
      podTotals[pod] = (podTotals[pod] ?: 0) + 1
      nameWidth = nameWidth.max(type.size - pod.size - 2)
    }
    typeTotals.each |n| { countWidth = countWidth.max(n.toStr.size) }
    this.multiPod = pods.size > 1

    this.quiet   = quiet
    this.tty     = tty ?: Term.isTty
    this.width   = this.tty ? Term.width : 80
    this.out     = out
  }

  ** Run fant for '-es' or '-js' with output passed through as-is.
  static Int passthrough(Str flag, Str[] targets)
  {
    out := Env.cur.out
    return Term.exec(Term.fant.add(flag).addAll(targets)) |line| { out.printLine(line).flush }
  }

  ** Run tests and return exit code.
  Int run()
  {
    parser := FantParser(this)
    redraw
    pool := startTicker
    exit := Term.exec(Term.fant.addAll(targets), |line| { parser.parseLine(line) }) |->Bool| { parser.isDone }
    parser.finish
    if (!quiet) { printType; printAbove(podSummary) }

    // stop ticker before writing summary
    running.val = false
    pool?.stop?.join(1sec)
    if (tty) out.print(erase).flush
    return summary(exit, parser.isDone)
  }

  ** Redraw every 100ms so elapsed time moves during long tests.
  private ActorPool? startTicker()
  {
    if (!tty) return null
    pool := ActorPool { it.name = "jigTicker"; it.maxThreads = 1 }
    self := Unsafe(this)
    flag := this.running
    Actor(pool) |->Obj?|
    {
      while (flag.val) { Actor.sleep(100ms); ((Runner)self.val).redraw }
      return null
    }.send(null)
    return pool
  }

//////////////////////////////////////////////////////////////////////////
// FantListener
//////////////////////////////////////////////////////////////////////////

  override Void onStart(Str qname)
  {

    // fallback if a type ends without reaching its expected count
    type := typeOf(qname)
    if (!quiet && type != curType)
    {
      printType
      printPod(podOf(type))
      typeStart = now
    }
    curType = type
    current = qname
    methodStart = now
    redraw
  }

  override Void onPass(Str qname, Int verifies)
  {
    passed++
    typePassed++
    podPassed++
    if (quiet) redraw
    else checkType
  }

  override Void onFail(Str qname, Str[] output, Str[] trace)
  {
    failed++
    failures.add(qname)

    // one line now; output and trace are saved for the summary so
    // they are all together at the end rather than in the scrollback
    failDetails.add(failLines(qname, output, trace, ""))
    if (quiet)
    {
      printAbove([color(Term.red, "FAIL: $qname")])
    }
    else
    {
      typeFailed++
      podFailed++
      typeFails.add("    " + color(Term.red, "FAIL: " + qname[qname.index(".")+1..-1]))
      checkType
    }
  }

  override Void onStray(Str line) { printAbove([line]) }

  override Void onSummary(Int types, Int methods, Int verifies) { this.verifies = verifies }

//////////////////////////////////////////////////////////////////////////
// Rendering
//////////////////////////////////////////////////////////////////////////

  ** Lines for a failed test with a red header to stand out.
  private Str[] failLines(Str name, Str[] output, Str[] trace, Str indent)
  {
    lines := [indent + color(Term.red, "FAIL: $name")]
    output.each |s| { lines.add(color(Term.dim, "$indent  | $s")) }
    trimTrace(trace).each |s| { lines.add("$indent  $s") }
    return lines
  }

  ** Print current type line once all its expected methods ran.
  private Void checkType()
  {
    if (typePassed + typeFailed >= (typeTotals[curType] ?: 0)) printType
    else redraw
  }

  ** Print previous pod's summary and this pod's header if this
  ** is the first type of a new pod.
  private Void printPod(Str pod)
  {
    if (pod == curPod) return
    lines := Str[,]
    if (curPod != null) lines.addAll(podSummary).add("")
    lines.add(tty ? "$Term.bold$pod$Term.reset" : pod)
    curPod     = pod
    podPassed  = 0
    podFailed  = 0
    podStart   = now
    printAbove(lines)
  }

  ** Summary line for current pod, or empty if this run only covers
  ** one pod since the final summary already says the same thing.
  internal Str[] podSummary()
  {
    if (!multiPod || curPod == null) return Str[,]
    res := podFailed > 0
      ? color(Term.red, "✗ $podFailed failed, $podPassed passed")
      : color(Term.green, "✓ $podPassed passed")

    return ["  [$res " + color(Term.dim, "(${StatusBar.fmtDurMs(now - podStart)})") + "]"]
  }

  ** Print final line for current type followed by its failures.
  private Void printType()
  {
    type := curType
    if (type == null) return
    curType = null

    expected := typeTotals[type] ?: typePassed + typeFailed
    ok := typeFailed == 0 && typePassed >= expected
    lines := ["  " + typeCol(type) +
      color(Term.magenta, countStr(type, typePassed, expected)) + " " +
      (ok ? color(Term.green, "pass") : color(Term.red, "fail")) + " " +
      color(Term.dim, "(${StatusBar.fmtDurMs(now - typeStart)})")]
    lines.addAll(typeFails)

    typePassed = 0
    typeFailed = 0
    typeFails  = Str[,]
    printAbove(lines)
  }

  ** Line for the type currently running, shown above the status
  ** bar with live counts and the running method, or null if none.
  internal Str? liveLine()
  {
    type := curType
    if (quiet || type == null) return null

    count  := countStr(type, typePassed, typeTotals[type] ?: 0)
    fails  := typeFailed > 0 ? " $typeFailed failed" : ""
    method := current == null ? "" : current[current.indexr(".")+1..-1]
    timer  := " (${StatusBar.fmtDur(now - methodStart)})"

    // keep on one line so cursor math in 'erase' holds
    avail := width - 1 - ("  " + typeCol(type, false) + "$count$fails  $timer").size
    if (avail < 4) method = ""
    else if (method.size > avail) method = method[0..<avail-1] + "…"

    s := "  " + typeCol(type) + color(Term.magenta, count)
    if (typeFailed > 0) s += " " + color(Term.red, "$typeFailed failed")
    if (!method.isEmpty) s += "  " + color(Term.dim, method + timer)
    return s
  }

  ** Format '[ 4/ 4]' with both sides right aligned to the run's
  ** count digits so slashes and totals line up.
  private Str countStr(Str type, Int passed, Int expected)
  {
    "[" + passed.toStr.padl(countWidth) + "/" + expected.toStr.padl(countWidth) + "]"
  }

  ** Type name without pod plus dim dot leaders out to the count
  ** column, which is aligned across all pods.  The longest name
  ** gets a plain two space gap.
  private Str typeCol(Str type, Bool colored := true)
  {
    name := type[podOf(type).size+2..-1]
    n := nameWidth - name.size
    if (n <= 0) return "$name  "
    dots := "·" * n
    return "$name " + (colored ? color(Term.dim, dots) : dots) + " "
  }

  private static Str typeOf(Str qname) { qname[0..<qname.indexr(".")] }

  private static Str podOf(Str qname) { qname[0..<qname.index("::")] }

  ** Print lines above the status bar.
  private Void printAbove(Str[] lines)
  {
    lock.lock
    try
    {
      if (tty) out.print(erase)
      lines.each |s| { out.printLine(s) }
      redraw
    }
    finally lock.unlock
  }

  ** Redraw live type line and status bar in place; called from
  ** both the output pump thread and the ticker, so serialized
  ** with 'lock'.
  private Void redraw()
  {
    if (!tty) return
    lock.lock
    try
    {
      if (!running.val) return
      live := liveLine
      buf  := StrBuf().add(erase)
      drawn := 0
      if (live != null) { buf.add(live).add("\n"); drawn++ }

      // blank line separates results from the bar; kept between
      // types too so the bar doesn't jump as each type finishes
      if (!quiet && curPod != null) { buf.add("\n"); drawn++ }

      buf.add(StatusBar.render(total, passed, failed, barLabel, elapsed, width))
      liveDrawn = drawn
      out.print(buf.toStr).flush
    }
    finally lock.unlock
  }

  ** Label for the status bar: the current pod and its test progress,
  ** which stays pinned once the pod header scrolls away.  The live
  ** line already shows the class and method, but quiet mode has no
  ** live line so shows the current test instead.
  internal Str? barLabel()
  {
    if (quiet) return current
    pod := curPod
    if (pod == null) return null
    return "$pod [${podPassed + podFailed}/${podTotals[pod]}]"
  }

  ** Escape sequence to clear the status bar and any live
  ** lines above it, leaving the cursor where they started.
  private Str erase()
  {
    s := Term.clearLine + Term.upClearLine * liveDrawn
    liveDrawn = 0
    return s
  }

  ** Strip test harness frames (verify internals, reflection,
  ** TestRunner) from a trace, keeping messages and causes.
  static Str[] trimTrace(Str[] lines)
  {
    acc  := Str[,]
    skip := false
    msg  := false
    lines.each |s|
    {
      t := s.trim
      if (!s.startsWith(" ") || msg) { acc.add(s); skip = false; msg = false; return }
      if (t == "Cause:") { acc.add(s); skip = false; msg = true; return }
      if (skip || t.startsWith("fan.sys.Test.")) return
      if (isHarness(t)) { skip = true; return }
      acc.add(s)
    }
    return acc
  }

  private static Bool isHarness(Str frame)
  {
    frame.startsWith("jdk.internal.reflect.") ||
    frame.startsWith("java.lang.reflect.")    ||
    frame.startsWith("fan.sys.Method")        ||
    frame.startsWith("util::TestRunner.")     ||
    frame.endsWith(" More...")
  }

  ** Print final summary and return exit code.
  internal Int summary(Int exit, Bool complete)
  {
    ran := passed + failed
    dur := StatusBar.fmtDur(elapsed)
    vs  := verifies == null ? "" : " [$verifies.toLocale verifies]"

    if (curPod != null || !failDetails.isEmpty) out.printLine

    // full details for each failure, right above the summary line
    failDetails.each |lines|
    {
      lines.each |s| { out.printLine(s) }
      out.printLine
    }

    Int code := 1
    if (!complete)
      out.printLine(color(Term.red, "✗ fant exited early (exit $exit): ran $ran of ${tests(total)} in $dur"))
    else if (failed > 0)
      out.printLine(color(Term.red, "✗ $failed failed, $passed passed of ${tests(total)}$vs in $dur"))
    else
    {
      msg := passed == 1 ? "1 test passed" : "All $passed tests passed"
      out.printLine(color(Term.green, "✓ $msg$vs in $dur"))
      code = 0
    }

    // one line per failure with how many of its type failed
    w := failures.reduce(0) |Int r, q| { r.max(q.size) }
    failures.each |q|
    {
      type := typeOf(q)
      n := failures.findAll { typeOf(it) == type }.size
      out.printLine(color(Term.red, "✗ ${q.padr(w)} [$n/${typeTotals[type] ?: n} failed]"))
    }
    return code
  }

  private static Str tests(Int n) { n == 1 ? "1 test" : "$n tests" }

  private Str color(Str c, Str s) { tty ? "$c$s$Term.reset" : s }

  private Duration elapsed() { now - start }

  ** Current time; tests may freeze it with 'fixedNow'.
  private Duration now() { fixedNow ?: Duration.now }

  ** Fixed clock for tests.
  internal Duration? fixedNow

//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////

  private OutStream out
  private const Str[] targets
  private const Bool quiet
  private const Int total
  private const Bool tty
  private const Int width
  private const Duration start := Duration.now
  private Int passed
  private Int failed
  private Int? verifies
  private Str? current
  private Duration typeStart := Duration.now
  private Duration methodStart := Duration.now
  private Str[] failures := [,]
  private Str[][] failDetails := [,]
  private Str:Int typeTotals := [:]
  private const Bool multiPod
  private Int nameWidth
  private Int countWidth
  private Str? curPod
  private Str:Int podTotals := [:]
  private Int podPassed
  private Int podFailed
  private Duration podStart := Duration.now
  private Str? curType
  private Int typePassed
  private Int typeFailed
  private Str[] typeFails := [,]
  private Int liveDrawn
  private const Lock lock := Lock.makeReentrant
  private const AtomicBool running := AtomicBool(true)
}