Tables
A table is a grid's rows and columns in the words a test uses: find the row whose customer number is 12345 and whose status is Active, then act in that row. This page is the guide for anyone whose tests read or act in grids, tables and lists; the exact signatures are in api.md.
const main = app.window();
const customers = main.table("Customers");
const row = await customers
.row()
.where("Customer number").equals("12345")
.and("Status").equals("Active")
.find();
await row.getByColumn("Actions").getByRole("button", { name: "Edit" }).click();
await expectDesk(row.getByColumn("Status")).toHaveText("Active");
A table, its row query and a found row are all lazy: nothing is looked up
until find(), all(), count() or an action on a row or cell runs, and a
found row is a locator that resolves by the same conditions whenever it is
used, so it is never stale.
Finding a table
const main = app.window();
main.table("Customers"); // the grid or table with that accessible name
main.table({ root: main.getByAutomationId("customGrid") }); // any locator that holds the rows
main.getByRole("dialog", { name: "Invoice" }).table("Lines"); // a table inside another control
main.table({ name: "Orders", columns: ["Order number", "Customer", "Placed"] }); // with the column names the test knows
The name is matched like a getByRole name: a case-insensitive substring,
or a RegExp. A table query also matches a control reported as a grid:
a WPF DataGrid is one, a Windows Forms DataGridView the other, and they
hold the same rows and cells. When a table and a grid share the name, the
table is meant. Where no table or grid has the name, a list that
holds column headers does: a list view in report mode (Win32, Delphi VCL, SWT,
wxWidgets), whose items are its rows and their texts its cells.
A list view of a class of its own (Delphi's TListView), whose items UI
Automation reports as their first column alone, is read through its own
window, every column of it.
JavaFX reports each column header after the first as a row on the header's
line; such rows are headers.
An empty column header with nothing under it (Avalonia's filler past the last
column) is no column. Given a root, the table is what that control
holds, laid out by the first shape that fits (see
below).
columns names the columns. They become the only names where() and
getByColumn() accept, at compile time (Table<"Order number" | "Customer" | "Placed">)
and at run time, so a typo fails before anything resolves rather than as a
row that is never found. Without columns, any name goes, and it is checked
against the headers the grid shows when the query resolves. columns may
also say how each column is found, as a mapping (see
below):
const main = app.window();
main.table({ name: "Orders", columns: { "Order number": { header: "No." } } }); // the grid heads it "No."
A table by what holds it
const main = app.window();
main.table({ root: main.getByAutomationId("orderBoard") });
A root that is, or holds one, grid the tree reports is that grid, read as the tree reports it. Otherwise its rows are inferred from what it holds, by the first of these that fits:
| What the root holds | Laid out as |
|---|---|
| a list whose items each hold a text per column (a list view in report mode) | the items as rows, their texts as cells, named by the list's header |
| siblings of one role that repeat, each holding as many cells (two or more) | those siblings as rows, the parts that bear text, a value or an area as cells; a sibling before them whose parts are texts, or a header the tree reports, names the columns |
| texts that line up in three or more columns under a header row | the header row's texts as column headers, each later row as a row of cells |
| nothing but scroll bars, or nothing at all | a grid laid out from the screen: from the lines it draws, else from its text (as a Delphi grid is) |
A root that fits none fails to find a row with Laid out: not laid out: nothing in it repeats as rows; define its rows and cells, and a definition
then says what the rows are. How the table was laid out is on the Laid out:
line of every miss. A root over a Windows Forms grid or a Win32 list view
keeps the helper's fast reads of those.
A table defined from locators
When the rows are known by something no inference sees — a class name, an AutomationId pattern, a position — say what they are:
const main = app.window();
const orderBoard = main.table({
root: main.getByAutomationId("orderBoard"),
rows: (root) => root.getByClassName("OrderRow"),
cells: (row) => row.getByRole("text"),
header: (root) => root.getByClassName("HeaderRow").getByRole("text"),
columns: {
Placed: { index: 0 },
Customer: { index: 1 },
Warehouse: { header: "Whs" }, // headed "Whs" by the application
Open: { locator: (row) => row.getByRole("button", { name: "Open" }) },
},
});
const row = await orderBoard.row().where("Customer").equals("Acme Ltd").find();
await row.getByColumn("Open").click();
-
rowslocates the rows inside the root,cellsa row's cells (in column order) inside the row, andheaderthe column headers' texts inside the root. Each is given what it locates from, and must locate from it: a definition that returnsmain.getBy…instead ofroot.getBy…is refused with aTypeErrorat once. Every locator works inside a definition:getByClassName,filter,nth, relative locators. -
Without
cells, a row's cells are itscells, else everything it holds. -
columnsas a list names the columns by place; as a mapping each column is{ index }(its place among the cells),{ locator }(what a locator finds in the row, wherever it sits) or{ header }(the column the application heads with that text). Columns not named keep the header's text, or none. -
Each column in a mapping may say where its cells take their text, with
value:"checked"for a column of check boxes ("true"/"false"),"name"for a status shown as an icon with a name,"value"or"text".where(),read()andgetByColumn()then see that text. It works on a grid the tree reports as well as on a table laid out from a definition:const main = app.window();const orders = main.table({ name: "Orders", columns: { Id: { header: "Id" }, Paid: { header: "Paid", value: "checked" } } });const unpaid = await orders.row().where("Paid").equals("false").find(); -
The minimum for a control that exposes nothing standard is
rootplusrowspluscellsor alocatorper column. Headers are optional: the column names then belong to the test. -
pageDownlocates what shows the next page of rows (a "Next" button), for a table that pages by a control of its own rather than by scrolling.
The definition is evaluated once, when the table is made, and kept as the steps it located with, so it is described as written in records and errors:
table({ root: getByAutomationId("orderBoard"), rows: getByClassName("OrderRow"), columns: { "Placed": 0, "Warehouse": header "Whs" } })
>> getByRole("row").filter({ has: getByColumn("Warehouse").filter({ hasText: "A2", exact: true }) })
At match time the root is laid out as a grid of a header and rows of cells:
the rows and cells keep their identity, so acting on a cell acts on the
control the definition found, and what a row holds besides its cells stays in
it (row.getByRole("button")). A row that lacks a cell keeps its other cells
in their columns. Such a table is observed through the tree, the root alone
once paging has found it, and is read off the screen only for cells whose
text the tree does not report.
Lists
A list is rows without columns: the same skeleton, for list boxes, ARIA listboxes and anything whose items are told apart by what they say.
const main = app.window();
const templates = main.list("Templates");
const item = await templates.item().named("Template 150").find(); // pages a virtualised list to it
await item.dblclick();
await templates.item().containing("urgent").count();
await templates.item().matches(/^Template 1\d\d$/).all();
console.log(await templates.read()); // the items shown, as text
const cards = main.list({ root: main.getByAutomationId("board"), items: (root) => root.getByClassName("CardView") });
named(text) is the item's whole text ({ exact: false } for a
case-insensitive substring), containing(text) a substring and matches(re)
a pattern. find(), all(), count(), isEmpty(), first(), nth() and
last() behave as they do on a table's rows:
read({ scroll: true })reads a virtualised list to its end, page by page, withmaxRows(items) andmaxPagesto stop early. Pages are joined by the items' places where the backend knows them (a list view read through the helper), else by what they show, which the record then says: two items with the same text at a page's edge may be read as one.count({ pages: "all" }),all({ pages: "all" })andfind({ strict: "all-pages" })look at every page of the list, as a table's rows do:allgives each matching item as a locator by its whole text, which an action pages the list back to.expectDesk(list)asserts on the items shown, retrying:toHaveItems([...])(these, in order),toContainItems([...])(these among others, each its own),toHaveItemCount(n)andtoBeEmpty(), each with.notandpages: "all"for the whole list.expectDesk(list.item().containing("Shipping")).toHaveCount(2)waits for a number of matching items.
const main = app.window();
await expectDesk(main.list("Templates")).toContainItems(["Quotation", /^Shipping/]);
const everything = await main.list("Templates").read({ scroll: true, maxRows: 1000 });
Rows by what their cells hold
const rows = customers.row();
rows.where("Status").equals("Active"); // the cell's whole text (whitespace-normalised, case-sensitive)
rows.where("Country").contains("nether"); // a case-insensitive substring
rows.where("Customer number").matches(/^12/); // a regular expression
rows.where("Status").equals("Active").and("Country").equals("NL"); // every condition must hold
customers.row({ "Customer number": "12345", Status: "Active" }); // the same, from data: strings are equals, RegExps matches
orders.row().where("Total").greaterThan(1000); // the number the cell shows ("$1,250.50" is 1250.5)
orders.row().where("Total").between(100, 500); // both bounds included; also lessThan(n) and equals(n)
orders.row().where("Total").equals(1250, { locale: "nl-NL" }); // "1.250,00": the locale's separators
files.row().where("Selected").has((cell) => cell.getByRole("checkbox", { checked: true })); // what the cell holds
A condition looks at the cell under the named column: its name or value, or
that of anything the cell holds (a text block, a combo box), so a WPF cell
named by its text, a Windows Forms cell whose value is its text and a cell
read off the screen are all matched the same way. Text read off the screen
is compared with what readers confuse set aside (1/I/l, 0/O), as
everywhere else. where and and are the same method; use whichever reads
well.
A number condition (greaterThan, lessThan, between, equals(n)) reads
the one number the cell shows: its group separators left out, its decimal
separator taken as one, a currency sign, unit or percent sign around it set
aside. A cell that shows no number, or more than one ("3 of 5", a date), does
not match. The locale says how the cell writes numbers ("en-US" by
default); a space of any width groups thousands where the locale groups them
with one. has((cell) => …) matches a row by a control its cell holds, for a
state rather than a text: the locator must start from the cell it is given.
An expected row of an assertion takes a number too: toContainRows([{ Total: 1250.5 }]) compares the cell's number, read as en-US.
Resolving
| Operation | Waits | Pages a virtualised grid | Several rows match |
|---|---|---|---|
find() | until one row matches, or the timeout | yes (scroll: false looks only at the rows shown) | StrictModeViolationError: add a condition, or take first() |
first().find(), nth(i).find(), last().find() | the same | yes | picks the one at that position, in document order |
all() | no | no | one Row per match, in document order |
all({ pages: "all" }) | no | every page, once | one Row per match in the whole grid, each found again by its cells |
count() | no | no | the number |
count({ pages: "all" }) | no | every page, once | the number in the whole grid |
find({ strict: "all-pages" }) | as find() | yes, then every page once | StrictModeViolationError when another row of the whole grid matches |
find() waits as an action waits: a grid that creates only the rows on
screen (WPF, WinUI) is taken to its top and paged, one page per attempt, until
the row exists or the view stops moving. A grid that creates only the columns
in view (WPF's EnableColumnVirtualization) is first paged sideways to a
column the query asks for and the grid does not show, right and then back
left. See dotnet-apps.md
for what a page costs. all() and count() observe once and never page or
wait, like all() and count() on a locator: on a virtualised grid they see
the rows it shows now; when the grid says how many it holds, the record of
count() says "4 rows shown of 10000 the grid holds".
To wait for a number of rows, assert on the query:
expectDesk(query).toHaveCount(3), which asserts as its locator.
count({ pages: "all" }) takes the grid to its top and pages it to its end,
as read({ scroll: true }) does, and counts each matching row once: by its
place in the grid where the backend reads it, else by what it shows.
find({ strict: "all-pages" }) finds the row as find() does, then looks at
every page for another, and brings the row back into view.
all({ pages: "all" }) gives every matching row of the grid as a Row for
that row by every cell it showed under a header named once: an action on it
pages the grid back to the row (a read, such as textContent(), sees it only
where the grid shows it: scrollIntoView() it first), and two rows that show the same cells are one
locator, which an action on it says (strict). Both take
maxPages, and count a timeout for the paging. A grid that the
backend reads whole at once (a Windows Forms grid or a list view through the
helper, whose rows each carry their place) is not paged at all: its rows,
however many, are counted from one read.
A row find() returns is a locator for the row, so everything a locator does
applies to it: row.getByRole("button", { name: "Delete" }).click(),
row.getByText("Overdue"), expectDesk(row).toBeVisible(). Its
getByColumn(column) is the cell under that column. An action on a cell
scrolls it into view, or pages the grid again if the view has moved.
Cells by their column
const main = app.window();
row.getByColumn("Email"); // the row's cell under "Email"
customers.row().where("Id").equals("7").getByColumn("Email") // the same, from the query, without find()
customers.getByColumn("Status"); // every cell of the column, top to bottom
await customers.getByColumn("Total").allTextContents();
await customers.columns(); // the header texts, left to right
main.getByRole("row").first().getByColumn("Name") // on any locator
A column name is compared with the whole header text, whitespace-normalised
and case-insensitively ("Customer\nnumber" reads as "Customer number");
{ exact: true } makes it case-sensitive, and a RegExp is tested as given.
So getByColumn("Name") is not also the "First name" column, and neither is
getByRole("cell", { column: "Name" }), which follows the same rule
(column: /name/i asks for both). Which header a cell sits under comes from the grid's tree: the header
over the cell's place on screen, or, for a row reported off screen without
bounds, the header at the same place among the row's cells. A header's text
is read whitespace-normalised, so a header that says "Name " is the column
"Name" in a record, an assertion and sortBy alike.
A query's getByColumn is the cell in the row the query finds, lazy and
strict as the row is: several matching rows fail an action on it.
Editing a cell
A cell the grid lets a user edit is filled as a field is: fill sets its
value through its Value pattern where it has one, else types into it. Most
grids commit an edit when the cell is left, so end it as a user does:
const total = customers.row().where("Id").equals("7").getByColumn("Total");
await total.fill("35.50");
await total.press("Enter"); // or "Tab" to the next cell
await expectDesk(total).toHaveText("35.50");
A Windows Forms grid read through the helper sets the cell's value through its MSAA object, and the row it acts on is the row read.
Reading a table
const rows = await customers.read();
// [{ "Customer number": "12345", Name: "Acme Ltd", Status: "Active", Country: "NL" }, …]
expect(rows).toEqual(expectedCustomers);
await writeFile("customers.json", JSON.stringify(rows, null, 2));
const everything = await orders.read({ scroll: true, timeout: 120_000 }); // the whole grid, page by page
read() is the table as data: one record per row, each cell's text keyed by
its column's header, from one observation of the rows the grid shows. It
waits for the table to exist first, so a table that never appears is a
TimeoutError, never an empty reading; then it reads once and never pages,
like all() and count(). A cell's text is its value, else its name, else
what it holds (a text block, the captions of buttons). A cell under no named
header is keyed by its position ("3"), and a column whose header repeats
another's by that header and its rank ("Name (2)"), so nothing is silently
merged. columns() gives the headers in order, once the table is there: it
waits for the table as read() does, so an empty list means a table without
headers, never one that has not appeared yet.
read({ scroll: true }) reads a virtualised grid whole: it takes the grid to
its top (a grid that cannot be taken there is read from where it is, its
first view included) and pages it to its end the way an action pages it (the mouse wheel
in the foreground, the scroll pattern in the background, the scroll bar of a
painted grid), reading after every page and joining the pages where they
overlap, by the rows' texts. It costs one observation per page (see
dotnet-apps.md),
and when the end is not reached within timeout it fails rather than hand
back part of the grid: raise timeout for a long grid, or prefer the
application's own filter. Rows whose place in the grid the backend reads
(Windows Forms grids and list views read through the helper) are joined by
it, so two rows that show the same cells stay two. Other rows are joined by
what they show, and the operation's record says so ("N rows at page edges
counted once by what they show"): two different rows with the same cells at a
page's edge cannot be told from one row seen twice.
maxRows stops the read once that many rows are read, and maxPages after
that many pages, both said in the record: read({ scroll: true, maxRows: 500 }).
Every read() is recorded as a read operation with the rows and columns
read, and what paging did.
Asserting on a table
await expectDesk(customers).toHaveRows([
{ "Customer number": "12345", Status: "Active" },
{ "Customer number": "12346", Status: /^(In)?active$/ },
]);
await expectDesk(customers).toContainRows([{ Name: "Acme Ltd" }]);
await expectDesk(customers).not.toContainRows([{ Status: "Deleted" }]);
await expectDesk(customers).toHaveRowCount(2);
await expectDesk(results).toBeEmpty();
await expectDesk(customers).toHaveColumns(["Customer number", "Name", "Status", /^Country/]);
await expectDesk(points).toBeSortedBy("Value", { descending: true });
await expectDesk(customers).toHaveColumnValues("Status", ["Active", "Active", /^In/]);
expectDesk(table) reads the rows the table shows, again and again, until
the expectation holds or the timeout elapses, like every assertion. With
{ pages: "all" } each attempt reads the whole grid, page by page, as
read({ scroll: true }) does (a grid the helper reads whole is read once an
attempt), and maxPages bounds it:
expectDesk(orders).toHaveRowCount(2_000, { pages: "all", timeout: 120_000 }).
toHaveRows expects exactly these rows in this order; toContainRows
expects each of them to match a row of its own, in any order, among any
others. toHaveRowCount(n) expects that many rows, and toBeEmpty() none;
both count the rows the grid shows unless pages: "all" asks for every
page; when the grid holds more, a failure says "N rows shown of M the grid
holds", as count()'s record does. row()...isEmpty() tells whether no row matches a query: it waits for
the table (exactly one, so a table that is not there or a wrong name is a
TimeoutError, never "empty"), not for its rows. An expected row names only
the columns it cares about; a string is
the cell's whole text (whitespace-normalised, case-sensitive; read off the
screen, once what readers confuse is set aside) and a RegExp
is tested as given. A failure lists the rows expected, the rows last read
(in the columns named), and the first difference:
AssertionError
expectDesk(table("Customers")).toHaveRows
Expected: 2 rows:
1) { "Customer number": "12345", "Status": "Active" }
2) { "Customer number": "12346", "Status": /^(In)?active$/ }
Actual: 2 rows (columns "Customer number", "Name", "Status", "Country"):
1) { "Customer number": "12345", "Status": "Inactive" }
2) { "Customer number": "12346", "Status": "Active" }
Last observed state: row 1: Status is "Inactive", expected "Active"
toHaveColumns reads the headers alone, left to right, each a whole text
or a RegExp; none of the rows is read. toBeSortedBy tells an order as
sortBy does (numbers as numbers, text in natural order, collated, or
character code by character code, equal neighbours allowed) and needs two
rows with a value under the column; a failure names the first two rows out
of order. toHaveColumnValues is one column's cells in the rows' order,
each a text, a RegExp or a number.
A column an assertion names that the grid does not have passes neither the
assertion nor its negation: not.toContainRows([{ Stauts: "Deleted" }])
fails, saying the columns there are. With pages: "all" and maxPages, a
read stopped before the grid's end decides only what its pages can (a row
found, a count already passed, a row that differs); otherwise the assertion
and its negation both fail, saying the read stopped at maxPages.
To wait for a number of matching rows, assert on the row query:
expectDesk(table.row().where("Status").equals("Open")).toHaveCount(3).
When a row is not found
The error says what was seen, so a query with several conditions says which one failed:
TimeoutError
Timeout 5000ms exceeded waiting for table("Customers").row().where("Customer number").equals("12345").and("Status").equals("Active") to exist.
Last observed state: not found; searched every page of grid "Customers" (paged grid "Customers" 5 times with its scroll pattern)
Table: table("Customers")
DesktopLocator: getByRole("table", { name: "Customers" }) >> getByRole("row").filter({ has: getByColumn("Customer number").filter({ hasText: "12345", exact: true }) }).filter({ has: getByColumn("Status").filter({ hasText: "Active", exact: true }) })
Laid out: from the tree
Columns: [0] Customer number [1] Name [2] Status [3] Country
Rows shown: 18 (paged grid "Customers" 5 times with its scroll pattern)
Conditions:
Customer number = "12345" → 1 row
Status = "Active" → 11 rows
Closest: the rows satisfying every condition but "Status" read "Inactive" there
Where the grid reports it through UI Automation's grid and table patterns
(WPF, Windows Forms, WinUI, read through the helper), the error also says how
many rows the grid holds beyond those shown (Rows shown: 18 of 2000 the grid holds) and, when columns are scrolled out of view, every column it holds
(Columns the grid holds: …).
The counts come from the tree the last attempt observed, without observing
again; when the grid's rows were not in it (a Windows Forms grid read through
the helper, a grid laid out from the screen), each question is asked of the
resolver once more, on the failure path only. Every find() is recorded in
desktop.actions as a find operation, with the query as written as its
locator.
A grid laid out from the screen also says how sure the reading was:
Read from the screen: 1 of 9 cells read nothing, 1 line(s) below the minimum confidence 0.5 left out. Its cells count only the columns that show text in
some row; a cell that read nothing is left empty, never guessed. The OCR
resolver's record carries the same counts.
What a table compiles to
The table API adds no way of resolving anything. Every backend reports a
grid in one shape, a grid or table holding a header of
columnheaders and rows of cells (see dotnet-apps.md),
whether the grid is reported by UI Automation, read through the helper, laid
out from the lines it draws and the text read in it, or from plain text that
lines up under a header. A row query is those locators:
table("Customers").row().where("Status").equals("Active")
= getByRole("table", { name: "Customers" })
>> getByRole("row").filter({ has: getByColumn("Status").filter({ hasText: "Active", exact: true }) })
query.locator is that locator, for anything the table API does not offer.
The same resolution, paging, strictness, records and diagnostics apply, and
the fast paths stay in use: a Windows Forms grid is still read through the
helper, a Win32 list view through its messages. A table defined from
locators, or by its root, starts with a table(…) step instead, which lays
the root out as that same shape; every step after it is one of the above.
Some backends report a grid in other words, and are given the same shape:
a Win32 list view in report mode, read through its window, reports its
column headers as a list view's header does; a Java JTable's cells, which
the Java Access Bridge reports one after another, are grouped into rows of
as many cells as it has column headers.
Tables in snapshots and for agents
A snapshot gives every grid's columns and every row's cells, aligned to
the columns by the rule a locator pairs a cell with its header by (an empty
string where a row has no cell under a header). A table defined from
locators is laid out in a snapshot it is given:
const { controls } = await app.snapshot({ tables: [orderBoard] });
The agent layer shows a row as one line of its cells, reads a grid page by
page with desktop_read_table (spart-desk-cli table <ref>), and takes
table definitions in the locator string grammar, per snapshot or for the
session (see agentic-mcp.md). A cell's suggested locator,
and so a recorded read of one, is its column in its row:
getByRole("row").filter({ hasText: "Order 42", exact: true }).getByColumn("Email").
Sorting, header rows and rows that nest
table.sortBy(column, { descending? }) sorts the grid as a user does: it
clicks the column's header with the real mouse until the rows shown are in
that order, at most three times (a header that sorts one way, the other,
then not at all), and leaves rows already in that order alone. It reads only the rows the grid
shows, and needs two of them to tell an order. The order is
read from the rows: numbers as numbers, text in natural order, collated, or
character code by character code, the ways grids sort text; a value with
letters in it ("P4991") is text. A column that does not come into order is a
not_supported error. Filtering is the application's own: a filter row, a
search box or a dialog, reached with ordinary locators.
A grid with a header row over its column headers (<th colspan="2"> over
two headers) pairs each cell with the header right over it, the narrowest
one, and names a column whose header text repeats under another group by
both: "Sales › Q1". A condition on the leaf alone (where("Q1")) matches
the cells of every group that holds one. Chromium's second header row, which
it reports as a row of cells, is taken for headers when a header reaches over
it or spans its cells.
An ARIA tree grid's rows, which Chromium reports as tree items, are the
table's rows in the order the tree shows them, each with its level;
row.expand() and row.collapse() open and close one.
Notes
- Strictness,
count(),read()and the assertions see one view of a virtualised grid by default: two rows on different pages that both satisfy the conditions are not both seen, andfind()returns the first page's.read({ scroll: true }),count({ pages: "all" }),all({ pages: "all" }),find({ strict: "all-pages" })and the assertions withpages: "all"look at the whole grid, a page per observation. find()reaches a record thousands of rows down in a few looks when the rows are in order by the column it asks for (where("Id").equals("7842")on a grid sorted by Id): the view is set to where the row should be and adjusted by what shows, as a user drags the scroll bar. A grid that is not in order by that column is paged a view at a time from its top, which takes a page per attempt: prefer the application's own search or filter there, or ask for the row by a column the grid is sorted by.- A XAML grid of more than 200 rows (the Community Toolkit's
DataGridin a WinUI 3 or UWP application) is read by the rows in its view, through its grid pattern: listing every row of a very large grid is slow for the application, so scope a locator into the grid and let an action page it, or use the application's own filter. Its columns are those shown whole, hidden and moved ones under their own headers; a column wider than the view is not read. A look that reads no row of a grid holding rows says why, and no assertion passes on it. Rows of different heights are followed from the grid's last read, so that a paged read loses none; a view moved by hand past them, with no read in between, may be moved by part of a view by the first read. - A Java
JTable's rows and cells are placed from the table's height shared among its rows, and its columns from even shares of its width where Swing's header entries give no place of their own: a column resized by hand is placed where an even share would be. A row out of view is brought into view by the table's scroll bar, a plain click selects it through its first cell, and a double or right click is real input on that cell. The bridge reads a table's first 1,000 cells: the rows after those are out of reach. One that shows no header is grouped into rows by the column count itsAccessibleTablereports, its cells keyed by place. - A table that exposes neither rows and cells, lines, nor text that lines up under a header, and whose root holds something, is not inferred: define its rows and cells.
- Filtering controls are never operated: a query matches the current state
of the grid.
sortByreads the order from the rows shown, not from a sort glyph. - A cell's text comes from the source its column names (
value:"text", the default, else"value","name"or"checked"): a column of check boxes defined withvalue: "checked"reads"true"or"false", sowhere("Paid").equals("true")matches it. A state can also be matched through the cell's own control,where("Paid").has((cell) => cell.getByRole("checkbox", { checked: true })), or read withgetByColumn("Paid").getByRole("checkbox")andtoBeChecked(). - A table whose rows nest is read as one row per line, in the order the tree shows them: a record holds no level, and a row's children are not told from the rows after it.