Testing
A Limn screen can be tested the way a user uses it, with no window, no GPU and no fonts. The
limn-test module drives a scene through the same input a window would deliver: a click
lands where the layout put the widget, text arrives one character at a time, and Enter is a
key. What the test then checks is what a user would see.
dependencies { testImplementation("io.github.limn-toolkit:limn-test:1.0.0")}<dependency> <groupId>io.github.limn-toolkit</groupId> <artifactId>limn-test</artifactId> <version>1.0.0</version> <scope>test</scope></dependency>It depends on limn-toolkit and on nothing else. It brings no test framework, so it works
under whichever one your build already runs, or from a main method.
A form, filled in by a test
Section titled “A form, filled in by a test”The screen here is a name field, a Save button and a status line. The two tests type a name, save it once with the button and once with Enter, and read the status line back.
@SuppressWarnings("try") // the runtime is used by being installed, not by namestatic String typeANameAndSave() { try (HeadlessUi ui = new HeadlessUi()) { // the UI thread is this one NameForm form = new NameForm(); Scene scene = new Scene(form.root); scene.setTextRuler(TestRulers.FIXED); // no fonts: every glyph is 10 wide scene.layoutPass(320, 200);
drive(scene).click(form.name).type("Ada").click(form.save); return form.status.text(); // "Saved Ada" }}
@SuppressWarnings("try")static String typeANameAndPressEnter() { try (HeadlessUi ui = new HeadlessUi()) { NameForm form = new NameForm(); Scene scene = new Scene(form.root); scene.setTextRuler(TestRulers.FIXED); scene.layoutPass(320, 200);
drive(scene).click(form.name).type("Grace").press(Keys.ENTER); return form.status.text(); // "Saved Grace" }}Three things make that run anywhere:
HeadlessUiinstalls the UI runtime on the test’s own thread. Widgets can only be touched from the UI thread, and here that thread is the test. Closing it, which thetrydoes, uninstalls it so the next test starts clean.TestRulers.FIXEDmeasures text without fonts. Every character is ten points wide, so a layout comes out the same on every machine.layoutPassgives the scene a size and places every widget. After it, a widget has a position, and that position is whereclickaims.
What the driver does
Section titled “What the driver does”drive(scene) returns a driver for that scene. Each of its gestures is one whole user action,
and they can be chained:
| Gesture | What the scene receives |
|---|---|
click(widget), click(id) or click(x, y) |
the pointer moving there, the left button down and up |
type(text) |
each character as the platform delivers typed text |
press(key) or press(key, modifiers) |
the key down and up, with the Keys.MOD_* bits held |
moveTo(x, y) |
the pointer moving there, which is a hover |
scroll(dx, dy, x, y) |
a wheel or trackpad scroll over that point |
Each gesture ends its input batch, which is when the scene dispatches: handlers have run by
the time the call returns. For a sequence a gesture does not cover, the driver also takes the
raw calls a backend makes, such as keyEvent, mouseButton and windowFocusChanged. Those
queue until inputBatchEnded(), exactly as one frame’s input does.
Keys that are not text go through press: Enter, Tab, the arrows, Escape. Text goes through
type.
A key a widget acts on ends the wheel stream still arriving, so a scroll within 150 ms of the
previous one, after such a key, is dropped as the tail of that gesture. A test that scrolls
after a key builds its scene with a clock it advances, new Scene(root, clock), and moves it
past 150 ms first.
A test that cannot reach a widget, because the screen builds it where the test cannot see, can
name it instead. The screen calls button.setId("save") and the test calls
drive(scene).click("save"). To check a widget afterwards, scene.find("save") returns it,
and scene.find("save", Button.class) returns it already typed. Nobody sees an id, and a screen
reader does not read one; what it reads is the accessible name.
The same id is what a tool that drives the running application from outside finds the widget by: it is published as the automation id on Windows, the accessibility identifier on macOS and the accessible id on Linux. A widget given no id is published under a number that holds while it stays in the scene.
Dialogs, menus and popups
Section titled “Dialogs, menus and popups”A dialog, a menu or a date picker’s calendar may open as a window of its own. With no backend,
there is nowhere for it to open. HeadlessBackend supplies one: every window it creates is a
HeadlessWindow the test can reach, with windows() listing those still open in the order they
opened (a window leaves the list as it closes, so keep a reference to one you look at after it
closed) and scene() answering the scene bound to each. frame() on a window runs one frame, and
key(...) presses a key in that window.
A frame takes a turn of the loop, as on a desktop: frame() first runs what is waiting on the UI
queue, then draws. A desktop drains the queue once and then draws every window; framing several
HeadlessWindows drains once for each, so count frames, not turns, when several windows are open.
That is where the toolkit finishes what it leaves for the next turn.
Under less motion the toolkit’s dialogs and popups open and close without a fade. A test asks for
it the way a user’s system would, through a TestPreferences opened after the HeadlessUi and
closed before it, which puts back what the system answered before:
try (TestPreferences system = new TestPreferences()) { system.reduceMotion(true); // open the dialog, press Escape, frame the windows}Such a test presses Escape and frames the windows, and the dialog’s result is there: it was
completed on the turn after the key. The same object reports a dark appearance
(colorScheme), more contrast (increaseContrast) and access letters underlined at all times
(underlineAccessKeys), so a screen’s test can check that it follows each. A fade of your own
that goes straight to its target, with no window or under less motion, runs its onArrive after
the seconds it asked for, on the clock the runtime’s timers fall due on, which
new HeadlessUi(clock) lets the test move.
An AccessibleHarness keeps one clock for its scene and for the runtime’s timers: frame() takes a
turn as a window’s does, and advanceTime(millis, widget) moves both clocks, taking a turn for each
step. A fade that hides what it faded in its onArrive is hidden after advanceTime has passed its
length, with motion and under less motion alike, and a timer of Ui.postDelayed runs when its delay
has passed, without waiting on the wall clock.
What a screen costs
Section titled “What a screen costs”A test can hold a screen to what it costs without a stopwatch, in counts that are the same on
every machine. window.takeFrameRequests() answers how many frames the scene asked a
HeadlessWindow for since it was last read: frame the window until that is zero, frame it some
more, and a screen nobody touches must still answer zero. scene.metrics().paintedWidgets().last()
is how many widgets the last frame painted, which for a list of a hundred thousand rows is the
rows on screen. AllocationProbe.leastAllocatedBy(step, attempts) is the bytes a step allocates.
window.frame();while (window.takeFrameRequests() > 0) { window.frame();}window.frame();assertEquals(0, window.takeFrameRequests());A transition runs on the wall clock unless the scene was built with a clock of its own, so a screen that animates as it opens needs real time to come to rest.
Accessibility
Section titled “Accessibility”A widget of your own can be held to the same contracts the toolkit’s widgets are. An
AccessibleHarness binds a widget to a scene whose accessibility bridge records every tree it
is handed. tree() and node(name) read what a screen reader would be told, and perform
invokes an action the way a platform does. The contracts in limn.testing.a11y check a whole
shape at once: a toggle, a text field, a list of rows, a grid, a menu.