diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index ca29604..09c223c 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -13,7 +13,9 @@ import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** @@ -304,6 +306,124 @@ public List elements() { .collect(Collectors.toList()); } + /** + * Resolves this locator to a {@code } and returns its data rows as + * header-keyed maps — one map per row, keys in column order. + * + *

Headers are taken from the first {@code

} row that has + * {@code } anywhere in the + * table that has {@code }. Whichever row supplies the headers is never + * returned as data. + * + *

All text is trimmed. A row with fewer {@code

+ List theadRows = table.findElements(By.cssSelector("thead tr")); + + if (!theadRows.isEmpty()) { + List theadHeaders = + theadRows.get(0).findElements(By.cssSelector("th")); + + if (!theadHeaders.isEmpty()) { + headerRow = theadRows.get(0); + headerCells = theadHeaders; + } + } + + // 2. No header -> find the first containing as the header + if (headerCells.isEmpty()) { + headerRow = allRows.get(0); + headerCells = headerRow.findElements(By.cssSelector("td")); + } + + // Convert header WebElements into Strings + List headers = headerCells.stream() + .map(WebElement::getText) + .map(String::trim) + .collect(Collectors.toList()); + + if (headers.isEmpty()) { + return new ArrayList<>(); + } + + List> result = new ArrayList<>(); + + // Convert each data row into a Map + for (WebElement row : allRows) { + + // Do not treat the header row as data + if (row.equals(headerRow)) { + continue; + } + + List cells = + row.findElements(By.cssSelector("td")); + + if (cells.isEmpty()) { + continue; + } + + Map rowMap = + new LinkedHashMap<>(); + + for (int i = 0; i < headers.size(); i++) { + String header = headers.get(i); + + String cellText = i < cells.size() + ? cells.get(i).getText().trim() + : ""; + + rowMap.put(header, cellText); + } + + result.add(rowMap); + } + + return result; + } + // ------------------------------------------------------------------ // Internal resolution // ------------------------------------------------------------------ diff --git a/src/test/java/com/seleniumboot/unit/LocatorTest.java b/src/test/java/com/seleniumboot/unit/LocatorTest.java index 086d958..90d234c 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorTest.java @@ -1,18 +1,28 @@ package com.seleniumboot.unit; +import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.driver.DriverManager; +import com.seleniumboot.internal.SeleniumBootContext; import com.seleniumboot.locator.Locator; import com.seleniumboot.locator.LocatorException; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; import static org.testng.Assert.*; /** @@ -21,6 +31,33 @@ */ public class LocatorTest { + private WebDriver mockDriver; + private MockedStatic driverManagerMock; + private MockedStatic contextMock; + + @BeforeMethod + public void setup() { + mockDriver = mock(WebDriver.class); + + driverManagerMock = mockStatic(DriverManager.class); + driverManagerMock.when(DriverManager::getDriver).thenReturn(mockDriver); + + // Supply a config so the auto-wait in element() can read timeouts.explicit + SeleniumBootConfig.Timeouts timeouts = new SeleniumBootConfig.Timeouts(); + timeouts.setExplicit(2); + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setTimeouts(timeouts); + + contextMock = mockStatic(SeleniumBootContext.class); + contextMock.when(SeleniumBootContext::getConfig).thenReturn(config); + } + + @AfterMethod + public void teardown() { + driverManagerMock.close(); + contextMock.close(); + } + // LocatorException thrown when no elements match @Test public void resolve_throwsLocatorException_whenNoElementsFound() { @@ -96,4 +133,211 @@ public void locator_chaining_doesNotMutateOriginal() { assertNotNull(filtered); assertTrue(filtered.toString().contains(".active")); } + + // ══════════════════════════════════════════════════════════════════════════ + // rows() — table → List> + // ══════════════════════════════════════════════════════════════════════════ + + @Test + public void rows_mapsHeaderCellsToDataCells_forTheadTable() { + WebElement headerRow = row(cells("Name", "Email"), Collections.emptyList()); + WebElement dataRow1 = row(Collections.emptyList(), cells("Alice", "alice@test.io")); + WebElement dataRow2 = row(Collections.emptyList(), cells("Bob", "bob@test.io")); + table(Arrays.asList(headerRow, dataRow1, dataRow2), Collections.singletonList(headerRow)); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 2, "header row must not be returned as data"); + assertEquals(rows.get(0).get("Name"), "Alice"); + assertEquals(rows.get(0).get("Email"), "alice@test.io"); + assertEquals(rows.get(1).get("Name"), "Bob"); + assertEquals(rows.get(1).get("Email"), "bob@test.io"); + } + + @Test + public void rows_preservesColumnOrder() { + WebElement headerRow = row(cells("Id", "Name", "Status"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("7", "Widget", "Active")); + table(Arrays.asList(headerRow, dataRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertEquals(Arrays.asList("Id", "Name", "Status"), + Arrays.asList(first.keySet().toArray()), + "keys should keep the header column order"); + } + + @Test + public void rows_trimsHeaderAndCellText() { + WebElement headerRow = row(cells(" Name "), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("\n Alice \t")); + table(Arrays.asList(headerRow, dataRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertTrue(first.containsKey("Name"), "header key should be trimmed"); + assertEquals(first.get("Name"), "Alice"); + } + + @Test + public void rows_usesFirstRowWithTh_whenThereIsNoThead() { + WebElement headerRow = row(cells("Name", "Email"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("Alice", "alice@test.io")); + table(Arrays.asList(headerRow, dataRow), Collections.emptyList()); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 1); + assertEquals(rows.get(0).get("Email"), "alice@test.io"); + } + + @Test + public void rows_usesFirstRowWithTh_whenTheadHasNoHeaderCells() { + // — no with neither } with the given {@code
} cells; failing that, the first {@code
} cells; failing that, the {@code } cells of + * the first {@code
} cells than there are + * headers is padded with {@code ""}; cells beyond the last header are + * dropped. Rows with no {@code } at all — {@code }-only group + * headings, for instance — are skipped. The result is empty when the table + * has no rows, or when no headers could be derived. + * + *

Like every terminal action this waits for the table to be visible, and + * throws {@link LocatorException} if it never matches. Rows and cells are + * matched by descendant search, so a nested table's rows are read as part of + * the outer table. + * + *

+     * List<Map<String, String>> data = $("table#results").rows();
+     * assertEquals(data.get(0).get("Status"), "Active");
+     * 
+ */ + public List> rows() { + WebElement table = element(); + + List allRows = table.findElements(By.cssSelector("tr")); + + // Empty table + if (allRows.isEmpty()) { + return new ArrayList<>(); + } + + WebElement headerRow = null; + List headerCells = new ArrayList<>(); + + // 1. Try to find header cells inside
+ if (headerCells.isEmpty()) { + for (WebElement row : allRows) { + List thCells = + row.findElements(By.cssSelector("th")); + + if (!thCells.isEmpty()) { + headerRow = row; + headerCells = thCells; + break; + } + } + } + + // 3. No at all -> use the first
…
, so the scan falls through + WebElement theadRow = row(Collections.emptyList(), cells("ignored")); + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("Alice")); + table(Arrays.asList(theadRow, headerRow, dataRow), Collections.singletonList(theadRow)); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 2, "the thead row now counts as data"); + assertEquals(rows.get(0).get("Name"), "ignored"); + assertEquals(rows.get(1).get("Name"), "Alice"); + } + + @Test + public void rows_usesFirstRowAsHeader_whenTableHasNoThCells() { + WebElement headerRow = row(Collections.emptyList(), cells("Name", "Email")); + WebElement dataRow = row(Collections.emptyList(), cells("Alice", "alice@test.io")); + table(Arrays.asList(headerRow, dataRow), Collections.emptyList()); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 1, "the first row is consumed as the header"); + assertEquals(rows.get(0).get("Name"), "Alice"); + assertEquals(rows.get(0).get("Email"), "alice@test.io"); + } + + @Test + public void rows_padsMissingCellsWithEmptyString() { + WebElement headerRow = row(cells("Name", "Email", "Role"), Collections.emptyList()); + WebElement shortRow = row(Collections.emptyList(), cells("Alice")); + table(Arrays.asList(headerRow, shortRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertEquals(first.size(), 3, "every header gets a key even when cells are missing"); + assertEquals(first.get("Name"), "Alice"); + assertEquals(first.get("Email"), ""); + assertEquals(first.get("Role"), ""); + } + + @Test + public void rows_ignoresCellsBeyondTheHeaderCount() { + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement wideRow = row(Collections.emptyList(), cells("Alice", "extra", "more")); + table(Arrays.asList(headerRow, wideRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertEquals(first.size(), 1); + assertEquals(first.get("Name"), "Alice"); + } + + @Test + public void rows_skipsRowsWithoutDataCells() { + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement groupRow = row(cells("Group A"), Collections.emptyList()); // th-only sub-header + WebElement dataRow = row(Collections.emptyList(), cells("Alice")); + table(Arrays.asList(headerRow, groupRow, dataRow), Collections.singletonList(headerRow)); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 1, "th-only rows carry no data and are skipped"); + assertEquals(rows.get(0).get("Name"), "Alice"); + } + + @Test + public void rows_returnsEmptyList_whenTableHasNoRows() { + table(Collections.emptyList(), Collections.emptyList()); + + assertTrue(Locator.ofCss("table").rows().isEmpty()); + } + + @Test + public void rows_returnsEmptyList_whenNoHeaderCellsCanBeDerived() { + // a single
nor + WebElement emptyRow = row(Collections.emptyList(), Collections.emptyList()); + table(Collections.singletonList(emptyRow), Collections.emptyList()); + + assertTrue(Locator.ofCss("table").rows().isEmpty()); + } + + @Test + public void rows_returnsEmptyList_whenHeaderRowIsTheOnlyRow() { + WebElement headerRow = row(cells("Name", "Email"), Collections.emptyList()); + table(Collections.singletonList(headerRow), Collections.singletonList(headerRow)); + + assertTrue(Locator.ofCss("table").rows().isEmpty(), "a header-only table has no data rows"); + } + + @Test(expectedExceptions = LocatorException.class) + public void rows_throwsLocatorException_whenTableIsNotFound() { + when(mockDriver.findElements(By.cssSelector("table"))).thenReturn(Collections.emptyList()); + + Locator.ofCss("table").rows(); + } + + @Test + public void rows_resolvesThroughTheChain_whenNthIsApplied() { + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("Second")); + WebElement first = mock(WebElement.class); + WebElement second = tableElement(Arrays.asList(headerRow, dataRow), + Collections.singletonList(headerRow)); + when(mockDriver.findElements(By.cssSelector("table"))) + .thenReturn(Arrays.asList(first, second)); + + List> rows = Locator.ofCss("table").nth(1).rows(); + + assertEquals(rows.size(), 1); + assertEquals(rows.get(0).get("Name"), "Second"); + } + + // ── rows() fixtures ─────────────────────────────────────────────────────── + + /** Mocks a {@code
} and {@code } children. */ + private WebElement row(List thCells, List tdCells) { + WebElement row = mock(WebElement.class); + when(row.findElements(By.cssSelector("th"))).thenReturn(thCells); + when(row.findElements(By.cssSelector("td"))).thenReturn(tdCells); + return row; + } + + /** Mocks a list of cells whose visible text is the given values. */ + private List cells(String... texts) { + return Arrays.stream(texts).map(text -> { + WebElement cell = mock(WebElement.class); + when(cell.getText()).thenReturn(text); + return cell; + }).collect(java.util.stream.Collectors.toList()); + } + + /** Mocks a visible {@code } — without registering it with the driver. */ + private WebElement tableElement(List allRows, List theadRows) { + WebElement table = mock(WebElement.class); + when(table.isDisplayed()).thenReturn(true); + when(table.findElements(By.cssSelector("tr"))).thenReturn(allRows); + when(table.findElements(By.cssSelector("thead tr"))).thenReturn(theadRows); + return table; + } + + /** Mocks a visible {@code
} and makes it the only match for {@code table}. */ + private WebElement table(List allRows, List theadRows) { + WebElement table = tableElement(allRows, theadRows); + when(mockDriver.findElements(By.cssSelector("table"))) + .thenReturn(Collections.singletonList(table)); + return table; + } }