Module: Tuile::Testing
- Defined in:
- lib/tuile/testing.rb,
lib/tuile/testing/gestures.rb,
sig/tuile.rbs
Overview
Finds a component in the tree, so a spec can drive the UI it built four layers down:
Testing.get(id: :save).handle_key?(Keys::ENTER)
Testing.get(Component::TextField, id: :name).value = "Zaphod"
Testing.find(Component::Checkbox, in: pane, count: 3)
Testing.get demands exactly one match and raises with a Testing.dump of the tree it
searched; Testing.find returns every match and takes an optional count:. Both
search Screen's whole tree by default — popups included, since they live
under the same ScreenPane as the content — or the subtree given as in:.
The handles are structural — a class, an Component#id, a subtree — never
what a component says. There is no caption: filter: chrome text is UI
copy, and a lookup keyed to it makes a rewording break a spec that tests
nothing about the wording. Where a spec does want it, the block says so
per-class and needs nothing from the framework:
Testing.get(Component::Button) { _1.caption.to_s == "Save" }
See D_component_lookup.
Call these qualified, as above: find and get collide with names a
spec suite is likely to have already (Capybara's find), so there is no
Component#get and mixing this module in is not recommended. Tuile's own
specs sit inside module Tuile and so need no include.
A hidden component is never found: these simulate a user, and a spec that drove a hidden Component::Button would pass against a form nobody can operate. Both walk Component#walk_shown_tree; a failed lookup says how many hidden components would have matched, and Testing.dump shows them.
Testing.find(Component::TextField, count: 0) # the user can't reach it
refute field.visible? # it is hidden
There is deliberately no visible: filter handing one back to drive
(D_visibility).
Setup
A spec dispatches no event, so nothing lays out what a mutation left
pending: call Component#flush_layout before reading a rect — it settles
the whole tree, from any component in it. Testing.place sizes a component the
way its parent would (rect= raises outside the parent's relayout) and
settles on its own:
Testing.place(field, Rect.new(0, 0, 20, 1)) # already settled
field.visible = false
field.flush_layout # before reading any rect
place never attaches: that is the spec's own step. The pane hands its
content the whole screen, so to attach at a size put an Absolute between:
holder = Component::Layout::Absolute.new
Screen.instance.content = holder
holder.add(field, Rect.new(0, 0, 20, 1)) # attached, at that rect
The terminal is 160×50; pick another size with Screen.fake(width:, height:),
or resize mid-example as the terminal would report it:
Screen.instance.resize_terminal(80, 24) # FakeScreen#resize_terminal
Gestures
Drive what you found as a user would: Testing.click routes a real press and
Testing.set_value refuses a field the keyboard cannot reach, where a bare
handle_key? or value= on a handle asserts neither.
Gestures gives them receiver syntax:
using Tuile::Testing::Gestures
Testing.get(Component::TextField, id: :name)._value = "Zaphod"
Testing.get(Component::Button, id: :save)._click
Painting
Testing.paint returns what one component paints, in a Buffer of its own whose
(0, 0) is the component's top-left. For what the user sees, popups
included, call Screen#repaint and read Screen#buffer at the component's
Component#absolute_rect.
See book ch8 for the worked usage, and design/decisions.md
D_component_lookup / D_test_gestures for the design.
Defined Under Namespace
Modules: Gestures Classes: AssertionError
Class Method Summary collapse
-
.click(component, button: :left) ⇒ void
Clicks
componentas the terminal would: a press and a release at the top-left cell of its Component#absolute_extent_rect, posted through Screen#handle_mouse, so it focuses and dismisses popups exactly as a real click does. -
.component_path_at(point) ⇒ ::Array[Component]
The shown components under
point, outermost first — the descent Mouse::Router makes when the terminal reports a press there. -
.dump(scope, marked = [], excluded = []) ⇒ String
The searched tree, one component per row, indented by depth and with the
Tuile::namespaces stripped so a fifty-row dump stays readable (an app's own component classes keep their full name):. -
.find(klass = Component, in: nil, id: nil, count: nil, &predicate) ⇒ Object
Every component in the searched tree matching the spec, in pre-order.
-
.get(klass = Component, in: nil, id: nil, &predicate) ⇒ void
The one component matching the spec — Testing.find with
count: 1, so it raises rather than returning nil, and raises on an ambiguous spec too. -
.paint(component) ⇒ Buffer
Paints
componentand its subtree, attached or not, into a Buffer of its own size whose(0, 0)is the component's top-left:. -
.place(component, rect) ⇒ Component
Moves
componenttorectwithin the parent it already has, through whatever places it there, since Component#rect= raises outside the parent'srelayout. -
.set_value(component, value) ⇒ void
Sets a field's value as a user who could reach it would.
Class Method Details
.click(component, button: :left) ⇒ void
This method returns an undefined value.
Clicks component as the terminal would: a press and a release at the
top-left cell of its Component#absolute_extent_rect, posted through
Screen#handle_mouse, so it focuses and dismisses popups exactly as a
real click does.
Testing.click(Testing.get(Component::Button, id: :save))
It does not raise when the press lands and nobody claims it: a user really can click a Component::Label and have nothing happen, so this asserts the click was possible, not that it achieved something.
@param component
@param button — :left, :middle or :right.
197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/tuile/testing.rb', line 197 def click(component, button: :left) point = gesture_point(component) path = component_path_at(point) unless path.include?(component) reached = path.empty? ? "nothing — a modal popup is open" : brief(path.last) raise AssertionError, "#{brief(component)} is not clickable at #{point.x},#{point.y}: " \ "a press there reaches #{reached}\n" \ "searched:\n#{dump(Screen.instance.pane, [component])}" end Screen.instance.handle_mouse(Mouse::DownEvent.new(, point.x, point.y)) Screen.instance.handle_mouse(Mouse::UpEvent.new(point.x, point.y)) end |
.component_path_at(point) ⇒ ::Array[Component]
The shown components under point, outermost first — the descent
Mouse::Router makes when the terminal reports a press there.
A deliberate copy of the router's private walk, kept honest by
testing_spec's pin against where a press is really delivered. When
that pin gets hard to keep green the two have diverged for a reason:
move the walk onto Mouse::Router and delete this.
@param point
330 331 332 333 334 335 336 337 338 339 340 341 342 |
# File 'lib/tuile/testing.rb', line 330 def component_path_at(point) settle_layout path = [] component = Screen.instance.pane.mouse_root_at(point) while component&.visible? && component.rect.contains?(point) # Rebase into the component's own coordinates as the router does: # a rect is parent-relative (`D_relative_rect`). point = Point.new(point.x - component.rect.left, point.y - component.rect.top) path << component component = component.children.find { _1.visible? && _1.rect.contains?(point) } end path end |
.dump(scope, marked = [], excluded = []) ⇒ String
The searched tree, one component per row, indented by depth and with
the Tuile:: namespaces stripped so a fifty-row dump stays readable
(an app's own component classes keep their full name):
#<ScreenPane rect=(0,0 160x50)>
#<Window rect=(0,0 40x10) caption="Settings">
#<Layout::Vertical rect=(1,1 38x8)>
→ #<Button id=:save rect=(1,1 38x1) caption="Save">
@param scope — root of the tree to dump.
@param marked — components to flag with a leading arrow — the matches, when a lookup found the wrong number of them.
@param excluded — components to flag with ⊘ — ones that matched the spec but were skipped for being hidden.
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/tuile/testing.rb', line 165 def dump(scope, marked = [], excluded = []) settle_layout base = scope.depth rows = [] # walk_tree, not walk_shown_tree: a reader looks here to find out where # their component went, so the ones the search skipped are the point. scope.walk_tree do |c| mark = if marked.any? { _1.equal?(c) } then "→" elsif excluded.any? { _1.equal?(c) } then "⊘" else " " end row = brief(c) rows << "#{mark} #{" " * (c.depth - base)}#{row}" end rows.join("\n") end |
.find(klass = Component, in: nil, id: nil, count: nil, &predicate) ⇒ Object
Every component in the searched tree matching the spec, in pre-order.
find(Component::Button) # every button on screen
find(Component::HasBadInput, in: form) # a mixin works too
find(Component::Label) { _1.text.to_s.start_with?("Total") }
find(Component::Popup, count: 1..) # assert at least one
@param klass — matched with is_a?, so a mixin (Component::HasValue) finds every field that includes it.
@param in — root of the subtree to search, itself included. Defaults to Screen.instance.pane — the whole UI.
@param id — matched against Component#id.
@param count — how many matches are expected; any number when nil.
121 122 123 124 125 126 127 128 129 130 |
# File 'lib/tuile/testing.rb', line 121 def find(klass = Component, in: nil, id: nil, count: nil, &predicate) # `in` is a Ruby keyword, so the local it binds is unreachable by name. scope = binding.local_variable_get(:in) || Screen.instance.pane spec = ->(c) { matches_spec?(c, klass, id, predicate) } matches = [] scope.walk_shown_tree { |c| matches << c if spec.call(c) } return matches if count.nil? || spec_match?(count, matches.size) raise AssertionError, failure(klass, id, predicate, count, matches, scope, spec) end |
.get(klass = Component, in: nil, id: nil, &predicate) ⇒ void
145 146 147 148 |
# File 'lib/tuile/testing.rb', line 145 def get(klass = Component, in: nil, id: nil, &predicate) scope = binding.local_variable_get(:in) find(klass, in: scope, id:, count: 1, &predicate).first end |
.paint(component) ⇒ Buffer
Paints component and its subtree, attached or not, into a Buffer of
its own size whose (0, 0) is the component's top-left:
Testing.place(window, Rect.new(0, 0, 14, 4))
buffer = Testing.paint(window)
buffer.text # => ["┌Caption─────┐", "│ alpha │", …]
buffer.cell(1, 1).style # component coordinates, no absolute_rect
What the component paints, not what the user sees: popups over it don't show, and ancestors don't clip it — only their background shows through. Screen#buffer is left untouched.
Implementation details
A Component#repaint paints only its own ink and queues its children, so this walks the subtree in Screen#repaint's order: pre-order, shown components only, nothing under an empty rect. The queued children stay on the screen's queue, harmlessly: a detached one is dropped, an attached one repaints unchanged.
@param component
308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/tuile/testing.rb', line 308 def paint(component) component.flush_layout raise AssertionError, "#{brief(component)} is hidden, so it paints nothing" unless component.visible? if component.rect.empty? raise AssertionError, "#{brief(component)} has no cell to paint: place it first, " \ "or it is deliberately collapsed" end buffer = Buffer.new(component.rect.size) paint_subtree(component, component, buffer) buffer end |
.place(component, rect) ⇒ Component
Moves component to rect within the parent it already has, through
whatever places it there, since Component#rect= raises outside the
parent's relayout. It never changes the tree:
Testing.place(label, Rect.new(0, 0, 10, 1)) # a parentless root
Testing.place(field, Rect.new(2, 1, 20, 1)) # moves it in its Layout::Absolute
Testing.place(popup, Rect.new(5, 5, 30, 10)) # an open overlay: At[rect]
A parentless root is sized in a throwaway Component::Layout::Absolute and released again, so it stays unattached.
@param component
@param rect — in the parent's coordinates (the screen's, for an overlay).
@return — component, settled.
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/tuile/testing.rb', line 262 def place(component, rect) parent = component.parent case parent when nil holder = Component::Layout::Absolute.new holder.add(component, rect) holder.flush_layout holder.remove(component) when Component::Layout::Absolute parent.constrain(component, rect) when ScreenPane unless component.is_a?(Component::Overlay) raise ArgumentError, "place: the pane gives its content, #{component}, the whole screen; " \ "put a Layout::Absolute in between, or resize the terminal" end component.placement = Component::Overlay::At[rect] else raise ArgumentError, "place: #{parent} places #{component} itself; constrain it there" end component.tap(&:flush_layout) end |
.set_value(component, value) ⇒ void
This method returns an undefined value.
Sets a field's value as a user who could reach it would.
Testing.set_value(Testing.get(Component::IntegerField, id: :age), 25)
Moves no focus — no keystroke is involved — and writes through
Component::HasValue#set_value with from_user: true, so it is the
value-level shortcut rather than a simulation of typing: the editor's
insert_text and its input filters never run, yet a listener reading
Component::HasValue::ValueChangeEvent#from_user? sees the user's edit.
The reachability checks below are what keep that claim honest.
@param component
@param value — whatever the field's Component::HasValue#set_value takes.
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'lib/tuile/testing.rb', line 227 def set_value(component, value) unless component.is_a?(Component::HasValue) raise AssertionError, "#{brief(component)} is not a field: set_value needs a Component::HasValue" end unless component.focusable? raise AssertionError, "#{brief(component)} is not focusable, so a user could never edit it" end scope = Screen.instance.pane.key_scope unless reachable?(component, scope) where = scope.nil? ? "the pane has no content" : "the key scope is #{brief(scope)}" raise AssertionError, "#{brief(component)} is hidden or out of reach: #{where}\n" \ "searched:\n#{dump(Screen.instance.pane, [component])}" end component.set_value(value, from_user: true) end |