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

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.

Parameters:

  • component (Component)
  • button: (Symbol) (defaults to: :left)


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(button, 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

Parameters:

Returns:



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.

Parameters:

Returns:

  • (String)


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

This method returns an undefined value.

The one component matching the spec — find with count: 1, so it raises rather than returning nil, and raises on an ambiguous spec too.

get(Component::ComboBox, in: sampler.demo_window)

@param klass — see find.

@param in — see find.

@param id — see find.

Parameters:

  • klass (Module) (defaults to: Component)
  • _in: (Component, nil)
  • id: (Symbol, nil) (defaults to: nil)


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

Parameters:

Returns:



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.

Parameters:

Returns:



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.

Parameters:



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