Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions modules/termflow-app/src/test/scala/termflow/tui/TuiRuntimeSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,158 @@ class TuiRuntimeSpec extends AnyFunSuite:
Console.withOut(new PrintStream(new ByteArrayOutputStream())):
TuiRuntime.run(app = App, renderer = renderer, terminalBackend = new TestTerminalBackend, config = TestConfig)
assert(captured.get().nonEmpty, "a throwing view should surface a TermFlowError")

test("a failing FCmd Future surfaces as a TermFlowError without crashing the loop"):
val captured = new AtomicReference[Option[TermFlowError]](None)
val done = scala.concurrent.Promise[Unit]()

object App extends TuiApp[Int, Unit]:
override def init(ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
// A second FCmd, completed once the error banner renders, drives Exit;
// the primary FCmd's Future fails and must surface as a TermFlowError
// rather than dying silently on the execution context.
ctx.publish(Cmd.FCmd(done.future, _ => Cmd.Exit, onEnqueue = None))
Tui(0, Cmd.FCmd(Future.failed(new RuntimeException("future boom")), _ => Cmd.NoCmd, onEnqueue = None))
override def update(model: Int, msg: Unit, ctx: RuntimeCtx[Unit]): Tui[Int, Unit] = Tui(model)
override def view(model: Int): RootNode = RootNode(80, 24, children = List.empty, input = None)
override def toMsg(input: PromptLine): Result[Unit] = Right(())

val renderer = new CapturingRenderer(err =>
if captured.get().isEmpty then
captured.set(Some(err))
done.trySuccess(()): Unit
)
Console.withOut(new PrintStream(new ByteArrayOutputStream())):
TuiRuntime.run(app = App, renderer = renderer, terminalBackend = new TestTerminalBackend, config = TestConfig)

assert(
captured.get().exists {
case TermFlowError.Unexpected(msg, Some(_: RuntimeException)) => msg == "future boom"
case _ => false
},
s"a failing FCmd Future should surface as TermFlowError.Unexpected; saw ${captured.get()}"
)

test("a Sub raising an exception on its own thread does not kill the runtime"):
val subFailed = new java.util.concurrent.CountDownLatch(1)
val backend = new TrackingTerminalBackend

object App extends TuiApp[Int, Unit]:
override def init(ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
// A subscription whose background thread throws. The runtime runs the
// loop on a different thread, so the failure must stay contained. The
// failing thread publishes a follow-up command in a `finally` (so it
// runs even as the exception propagates) which drives a clean Exit,
// proving the bus and loop survived the Sub blowing up.
ctx.registerSub(new Sub[Unit]:
override def isActive: Boolean = true
override def cancel(): Unit = ()
override def start(): Unit =
// Explicit Runnable (not a lambda): the body is `Nothing`-typed
// because it ends in a throw, which a SAM lambda cannot convert to
// void. The finally still runs as the exception propagates.
val t = new Thread(
new Runnable:
override def run(): Unit =
try throw new RuntimeException("sub boom")
finally
subFailed.countDown()
ctx.publish(Cmd.GCmd(()))
)
// Swallow the (deliberately) uncaught exception so it does not
// spam stderr; the latch above already proves the throw happened.
t.setUncaughtExceptionHandler(
new Thread.UncaughtExceptionHandler:
override def uncaughtException(thread: Thread, ex: Throwable): Unit = ()
)
t.setDaemon(true)
t.start()
)
Tui(0, Cmd.NoCmd)
override def update(model: Int, msg: Unit, ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
Tui(model, Cmd.Exit)
override def view(model: Int): RootNode = RootNode(80, 24, children = List.empty, input = None)
override def toMsg(input: PromptLine): Result[Unit] = Right(())

Console.withOut(new PrintStream(new ByteArrayOutputStream())):
TuiRuntime.run(app = App, renderer = new NoopRenderer, terminalBackend = backend, config = TestConfig)

assert(
subFailed.await(2, java.util.concurrent.TimeUnit.SECONDS),
"the Sub thread should have thrown its exception"
)
assert(backend.closed.get(), "runtime should shut down cleanly after a Sub failure")

test("runtime shuts down cleanly with commands still pending in the bus"):
val cancelled = new AtomicBoolean(false)
val updates = new java.util.concurrent.atomic.AtomicInteger(0)
val backend = new TrackingTerminalBackend
val FirstBurst = 25

object App extends TuiApp[Int, Unit]:
override def init(ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
ctx.registerSub(new Sub[Unit]:
override def isActive: Boolean = true
override def cancel(): Unit = cancelled.set(true)
)
// Enqueue a burst, then Exit, then a second burst. Exit is consumed
// before the trailing commands, so the runtime must tear down with
// work still queued — cancelling subs and restoring the terminal
// rather than draining the remainder or hanging.
(1 to FirstBurst).foreach(_ => ctx.publish(Cmd.GCmd(())))
ctx.publish(Cmd.Exit)
(1 to FirstBurst).foreach(_ => ctx.publish(Cmd.GCmd(())))
Tui(0, Cmd.NoCmd)
override def update(model: Int, msg: Unit, ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
updates.incrementAndGet(): Unit
Tui(model, Cmd.NoCmd)
override def view(model: Int): RootNode = RootNode(80, 24, children = List.empty, input = None)
override def toMsg(input: PromptLine): Result[Unit] = Right(())

Console.withOut(new PrintStream(new ByteArrayOutputStream())):
TuiRuntime.run(app = App, renderer = new NoopRenderer, terminalBackend = backend, config = TestConfig)

assert(updates.get() == FirstBurst, s"only the pre-Exit burst should run; saw ${updates.get()}")
assert(cancelled.get(), "subscriptions should be cancelled on shutdown")
assert(backend.closed.get(), "terminal backend should be closed on shutdown")
val printed = backend.out.toString
assert(
printed.contains(ANSI.showCursor) && printed.contains(ANSI.exitAltBuffer),
"terminal state should be restored on shutdown"
)

test("a command burst exceeding the per-frame coalescing cap is fully processed without loss"):
// MaxCoalescedCommandsPerFrame is 4096; exceed it so the drain loop hits
// its cap in one frame and spills the remainder into a later frame. Every
// command must still be handled — backpressure buffers, it does not drop.
val Burst = 5000
val handled = new java.util.concurrent.atomic.AtomicInteger(0)
val frames = new java.util.concurrent.atomic.AtomicInteger(0)

val renderer = new TuiRenderer:
override def render(
textNode: RootNode,
err: Option[TermFlowError],
terminal: TerminalBackend,
renderMetrics: RenderMetrics
): Unit = frames.incrementAndGet(): Unit

object App extends TuiApp[Int, Unit]:
override def init(ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
(1 to Burst).foreach(_ => ctx.publish(Cmd.GCmd(())))
Tui(0, Cmd.NoCmd)
override def update(model: Int, msg: Unit, ctx: RuntimeCtx[Unit]): Tui[Int, Unit] =
val n = handled.incrementAndGet()
// Each handled command schedules a render-triggering NoCmd, so the
// tail of the burst piles up more than 4096 NoCmds and overflows the
// per-frame coalescing cap. The final command tears the loop down.
if n >= Burst then Tui(model, Cmd.Exit) else Tui(model, Cmd.NoCmd)
override def view(model: Int): RootNode = RootNode(80, 24, children = List.empty, input = None)
override def toMsg(input: PromptLine): Result[Unit] = Right(())

Console.withOut(new PrintStream(new ByteArrayOutputStream())):
TuiRuntime.run(app = App, renderer = renderer, terminalBackend = new TestTerminalBackend, config = TestConfig)

assert(handled.get() == Burst, s"every burst command must be handled; saw ${handled.get()}")
assert(frames.get() >= 1, "the burst should coalesce into at least one rendered frame")
assert(frames.get() < Burst, s"thousands of commands must coalesce into few frames; saw ${frames.get()}")
Loading