Phosphor::TestBackend(S, M)
Inherits Phosphor::Backend < Reference < Object
In-memory Backend for specs — no real terminal, no PTY, no ANSI
escape sequences. Records lifecycle calls as flags, applies writes to
an in-memory Buffer instead of a tty, and reads injected Events
from a plain queue instead of STDIN.
This is the Phase 27 generalization of the TestBackend(S) that
first shipped in Phase 10 as spec/support/test_backend.cr — a
strict superset of it, not a replacement with different behavior.
Phase 10's version never touched a Backend at all; it hand-rolled
its own simulation of App#run's event loop (#run, #run_until,
#inject_key, #screen, #state, #app, #terminal_exited?) on
top of Screen#handle/Cmd::Push/Cmd::Pop/Cmd::Replace directly.
Every one of those methods is still here, doing exactly what it did
before — but now built on top of this class's own Backend contract
methods (#read_event, #write, #exit) instead of reimplementing
queue-draining and buffer-swapping separately. spec/support/test_backend.cr
now just requires and re-exports this class under the same name, so
every existing call site (Phosphor::TestBackend(Phosphor::AppState).new(...),
#inject_key, #run, #screen, #state, #app, #last_buffer,
#terminal_exited?) keeps working unchanged.
Generic over S, the same application state type parameter
Screen(S, M)/App(S, M) use — matching Backend's own doc comment on
why Terminal/AnsiBackend don't need this: unlike those, this
class's constructor mounts a real Screen(S, M) and drives it, so it
needs to know S the same way TestBackend(S)'s Phase 10 ancestor
did.
See TASKS.md § "Phase 27: Pluggable backends" for the full rationale.
Constructors
Builds a TestBackend driving screen_class, mounted immediately
with initial_state — matching the Phase 10 constructor exactly
(same parameter names, same order, same defaults), so every
existing call site keeps compiling unchanged.
width/height seed #width/#height (settable afterward — see
their own doc comment). theme is forwarded to the internal App.
Instance methods
The underlying App instance backing this session — exposes its
public port registry (add_port/stop_ports_for/ports_for) so
specs can confirm port lifecycle behavior directly (Phase 10
capability).
Resets @buffer to a fresh, blank Buffer sized to the current
#width/#height — useful between test scenarios that share one
TestBackend, or after changing #width/#height to get a
correctly-sized buffer to write into directly (see #width's own
doc comment on why a resize doesn't do this automatically).
Records that #enter was called — no terminal interaction, no
LibC calls, no escape sequences, unlike AnsiBackend#enter. mouse
is accepted to satisfy Backend#enter's signature but otherwise
ignored — there's no mouse tracking concept for an in-memory buffer.
Whether #enter has been called more recently than #exit —
#exit clears this, mirroring AnsiBackend's real enter/exit
pairing.
Whether every injected event has already been consumed by
#read_event (directly, or via #run/#tick).
Records that #exit was called and clears #entered? — the exact
inverse of #enter, same as AnsiBackend, just without any real
teardown to perform.
Whether #exit has ever been called. Also doubles as the flag
#terminal_exited? (the Phase 10 name) reads — see #tick's doc
comment on why those are the same underlying event here.
Queues event to be returned by a future #read_event call, in
FIFO order.
The accumulated buffer #write applies diffs to, or nil before
anything has ever been written. Declared nilable to match the
Phase 10 accessor's own type (Buffer?) even though this class
always has a real, non-nil Buffer from construction onward — see
#initialize.
Shifts and returns the oldest queued event, or nil if
#event_queue_empty?. Never blocks — matches Port#poll's
contract, since a real Port would call this in its own poll loop.
Reconstructs row's text content from @buffer's cells, left to
right, skipping Cell::CONTINUATION slots (a wide character's
second cell contributes nothing of its own — its primary cluster
already covers both columns). Trailing blank cells are included as
literal spaces, same as what a real terminal would show.
A convenience for readable assertions (backend.rendered_text(0).should contain("Hello")) over inspecting @buffer's cells one at a time.
Runs the drain-dispatch-handle-render cycle frames times — Phase
10 capability, unrelated to the Backend contract itself (nothing
in App#run calls this; it's this class's own alternative to
driving a real App#run loop for a spec).
Runs the cycle up to max_frames times, stopping early when
condition returns true — same rationale as #run.
The currently active screen. Changes across Cmd::Push, Cmd::Pop,
and Cmd::Replace, mirroring App#run's own screen local (Phase
10 capability).
The current application state after the most recent #tick (Phase
10 capability — unrelated to the Backend contract itself).
Phase 10 name for #exited? — Key::CtrlC and an empty-stack
Cmd::Pop both call #exit (see #tick's doc comment), so these
two accessors read the exact same @exited flag under different
names; kept as a distinct method (not a def_equals-style rename)
purely so the existing spec call site
(backend.terminal_exited?.should be_true) keeps compiling
unchanged.
The simulated output target's current dimensions — #size reads
these fresh on every call (never cached, per Backend#size's own
contract). Settable directly, unlike the real AnsiBackend (whose
dimensions come from the kernel), so specs can exercise resize
behavior without a real terminal to resize.
Changing these does not resize @buffer in place — Buffer
has no such operation, and #write's diff-application would
silently corrupt a size-mismatched buffer. #run/#tick detect
the mismatch and fall back to a full redraw automatically (the same
thing Renderer#draw does after Renderer#invalidate); a spec
driving #write directly after a resize should call #clear_buffer
first to get a freshly, correctly-sized buffer.
The simulated output target's current dimensions — #size reads
these fresh on every call (never cached, per Backend#size's own
contract). Settable directly, unlike the real AnsiBackend (whose
dimensions come from the kernel), so specs can exercise resize
behavior without a real terminal to resize.
Changing these does not resize @buffer in place — Buffer
has no such operation, and #write's diff-application would
silently corrupt a size-mismatched buffer. #run/#tick detect
the mismatch and fall back to a full redraw automatically (the same
thing Renderer#draw does after Renderer#invalidate); a spec
driving #write directly after a resize should call #clear_buffer
first to get a freshly, correctly-sized buffer.
Applies diff to @buffer cell by cell via Buffer#set_cell,
translating each Diff's absolute position to @buffer-local
coordinates the same way Buffer#set_string/#diff do internally
(subtracting the buffer's own frame origin — always (0, 0) here,
but written the general way regardless, matching the rest of the
codebase's convention rather than assuming the zero case).
Doesn't validate diff's positions against @buffer's current
dimensions — a caller writing a diff computed against a different
size than @buffer's current one is a caller bug, the same
precondition Buffer#diff itself documents ("both buffers must
have the same dimensions") rather than defends against.