class

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

new(screen_class : Screen(S, M).class, initial_state : S, width : Int32 = 80, height : Int32 = 24, theme : Theme = Theme.new)

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.

Source

Instance methods

app

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).

Source
clear_buffer

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).

Source
enter(mouse : Bool = false) : Void

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.

Source
entered?

Whether #enter has been called more recently than #exit#exit clears this, mirroring AnsiBackend's real enter/exit pairing.

Source
event_queue_empty?

Whether every injected event has already been consumed by #read_event (directly, or via #run/#tick).

Source
exit

Records that #exit was called and clears #entered? — the exact inverse of #enter, same as AnsiBackend, just without any real teardown to perform.

Source
exited?

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.

Source
height
Source
height=(height : Int32)
Source
inject_event(event : Event) : Void

Queues event to be returned by a future #read_event call, in FIFO order.

Source
inject_key(key : Key) : Void

Convenience for the common case: queues a KeyEvent for key.

Source
last_buffer

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.

Source
read_event

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.

Source
rendered_text(row : Int32) : String

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.

Source
run(frames : Int32 = 1) : Void

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).

Source
run_until(max_frames : Int32 = 100, &condition : -> Bool) : Void

Runs the cycle up to max_frames times, stopping early when condition returns true — same rationale as #run.

Source
screen

The currently active screen. Changes across Cmd::Push, Cmd::Pop, and Cmd::Replace, mirroring App#run's own screen local (Phase 10 capability).

Source
size

Returns {#width, #height} — see their own doc comment.

Source
state

The current application state after the most recent #tick (Phase 10 capability — unrelated to the Backend contract itself).

Source
terminal_exited?

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.

Source
width

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.

Source
width=(width : Int32)

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.

Source
write(diff : Array(Diff)) : Void

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.

Source