Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,28 @@ public class TextFieldElement extends VaadinElement implements HasEnabledElement
- Checkboxes: `CHECKBOX`
- Radio buttons: `RADIO`

#### 9. Theme Variant Order
- **Pitfall**: Asserting a single theme variant with `assertTheme("primary")` on
a component that has several. Vaadin combines every applied variant into one
space-separated `theme` attribute, in an order that is not the order the
variants were added (`LUMO_PRIMARY, LUMO_SMALL` renders as `small primary`),
so a whole-attribute match is brittle and often plain wrong.
- **Solution**: `assertTheme(...)` is for the whole attribute. To assert one
variant among several, use `HasThemeElement.assertHasThemeVariant(...)` /
`assertHasNoThemeVariant(...)`, which match the variant as a whole token
regardless of the others. Both are inherited by every element implementing
`HasThemeElement` — never re-implement the token regex per element.

#### 10. Light-DOM Text vs `textContent`
- **Pitfall**: Reading a component's own text with `locator.textContent()` (or
Playwright's `hasText`) when the component also renders content in its shadow
DOM or accepts slotted children. Both traverse the shadow DOM and include
slotted light-DOM text, so `vaadin-badge` with an icon and a number reports
the icon glyph and the number as part of its text.
- **Solution**: Read only the child nodes that carry no `slot` attribute, e.g.
via `evaluate` over `childNodes` (see `BadgeElement.getText()`), or scope a
locator with `> :not([slot])` (see `CardElement.getContentLocator()`).

### Design Patterns

#### 1. Factory Method Pattern
Expand Down
31 changes: 25 additions & 6 deletions docs/specifications/BadgeElement.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,46 @@ A badge can carry all three at once:
| number | `number` property, rendered in the component's shadow DOM | `getNumber()` / `assertNumber(...)` |
| icon | `icon` slot (light DOM) | `getIconLocator()`, `assertHasIcon()` |

`getText()` and `assertText(...)` read the badge's own text content, so the
number is **not** included:
`getText()` and `assertText(...)` read only the child nodes that carry no
`slot` attribute — the badge's own content. Neither the number (shadow DOM) nor
an icon's light-DOM text is included:

```java
// new Badge("unread messages", 5) with BadgeVariant.NUMBER_ONLY
badge.assertText("unread messages");
badge.assertNumber(5);

// new Badge("Verified", new Span("✓")) — the host's textContent is "Verified✓"
badge.assertText("Verified");
```

Note that a raw Playwright text assertion on the host element behaves
differently: `assertThat(badge.getLocator()).hasText("unread messages")` fails,
because Playwright's text assertions traverse the shadow DOM and therefore also
pick up the rendered number. Use `assertText(...)` — it compares the badge's
pick up the rendered number. Use `assertText(...)` — it compares the badge's own
text content exactly, without whitespace normalization.

## `getByText(...)` *does* match the number

The factories are the exception to the rule above. They filter with Playwright's
`hasText`, which uses the same shadow-traversing text as `hasText` assertions, so
the rendered number is part of the matched text:

```java
// new Badge("unread messages", 5), plus a separate badge reading "5 minutes"
BadgeElement.getByText(page, "5"); // the *numbered* badge, not "5 minutes"
BadgeElement.getByText(page, "unread"); // unambiguous
```

Pass some of the badge's own text to avoid matching a number by accident.

## Theme variants combine into one attribute

`Badge.addThemeVariants(...)` appends to a single space-separated `theme`
attribute, so `assertTheme(...)` (from `HasThemeElement`) has to match the whole
value. Use `assertHasThemeVariant(...)` to assert one variant regardless of the
others:
attribute, so `assertTheme(...)` has to match the whole value. Use
`assertHasThemeVariant(...)` to assert one variant regardless of the others.
Both live on `HasThemeElement`, so they are available on every themed element,
not only the badge:

```java
// badge.addThemeVariants(BadgeVariant.SUCCESS, BadgeVariant.SMALL)
Expand Down
4 changes: 2 additions & 2 deletions skills/vaadin-playwright-test/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,6 @@ PlaywrightElement for <vaadin-badge>.
- `void assertNumber(Integer number)` — Assert the badge's number.
- `void assertHasIcon()` — Assert that the badge has content in its icon slot.
- `void assertHasNoIcon()` — Assert that the badge has no content in its icon slot.
- `void assertHasThemeVariant(String variant)` — Assert that the badge's theme attribute contains the given variant, ignoring any other variants that are also applied.
- `void assertHasNoThemeVariant(String variant)` — Assert that the badge's theme attribute does not contain the given variant.

### BigDecimalFieldElement `<vaadin-big-decimal-field>`

Expand Down Expand Up @@ -1986,6 +1984,8 @@ Mixin for components that support the theme attribute.

- `String getTheme()` — Get the current theme attribute value.
- `void assertTheme(String theme)` — Assert that the theme attribute matches, or is absent when null.
- `void assertHasThemeVariant(String variant)` — Assert that the theme attribute contains the given variant, ignoring any other variants that are also applied.
- `void assertHasNoThemeVariant(String variant)` — Assert that the theme attribute does not contain the given variant.

### HasTooltipElement

Expand Down
106 changes: 61 additions & 45 deletions src/main/java/org/vaadin/addons/dramafinder/element/BadgeElement.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package org.vaadin.addons.dramafinder.element;

import java.util.Objects;
import java.util.regex.Pattern;

import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.PlaywrightException;
import org.vaadin.addons.dramafinder.element.shared.HasStyleElement;
import org.vaadin.addons.dramafinder.element.shared.HasThemeElement;

Expand All @@ -28,15 +28,17 @@
* <li>a component in the {@code icon} slot — located with
* {@link #getIconLocator()}.</li>
* </ul>
* The number lives in the component's shadow DOM, so it is <strong>not</strong>
* part of {@link #getText()}.
* {@link #getText()} reads only the badge's own, non-slotted content, so
* neither the number (rendered in the shadow DOM) nor the icon (in the
* {@code icon} slot) is part of it.
* <p>
* Theme variants ({@code success}, {@code error}, {@code contrast},
* {@code warning}, {@code small}, {@code filled}, {@code dot},
* {@code icon-only}, {@code number-only}) are combined into a single
* space-separated {@code theme} attribute. Use
* {@link #assertHasThemeVariant(String)} to assert one variant among several,
* or {@link HasThemeElement#assertTheme(String)} for the whole attribute.
* {@link HasThemeElement#assertHasThemeVariant(String)} to assert one variant
* among several, or {@link HasThemeElement#assertTheme(String)} for the whole
* attribute.
* <p>
* The {@code dot}, {@code icon-only} and {@code number-only} variants hide
* content visually while keeping it in the DOM for screen readers, so the
Expand All @@ -47,6 +49,17 @@ public class BadgeElement extends VaadinElement implements HasThemeElement, HasS

public static final String FIELD_TAG_NAME = "vaadin-badge";

/**
* Concatenate the text of the child nodes that are not assigned to a named
* slot, i.e. the badge's own content. Text nodes have no {@code slot}
* attribute, so only slotted elements — the icon — are skipped.
*/
private static final String OWN_TEXT_JS = """
el => Array.from(el.childNodes)
.filter(node => !(node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('slot')))
.map(node => node.textContent)
.join('')""";

/**
* Create a new {@code BadgeElement}.
*
Expand Down Expand Up @@ -82,8 +95,12 @@ public static BadgeElement get(Locator locator) {
* Get a {@code BadgeElement} by its text content.
* <p>
* The match is a case-insensitive substring match on the badge's text, so
* pass enough of the text to be unambiguous. The {@code number} is rendered
* in the shadow DOM and is not part of the matched text.
* pass enough of the text to be unambiguous. Playwright's {@code hasText}
* filter traverses the shadow DOM, so — unlike {@link #getText()} — the
* matched text also contains the rendered {@code number}: on a page with
* {@code new Badge("unread messages", 5)}, {@code getByText(page, "5")}
* matches that badge. Pass some of the badge's own text to avoid matching a
* number by accident.
*
* @param page the Playwright page
* @param text the text the badge contains
Expand All @@ -97,6 +114,9 @@ public static BadgeElement getByText(Page page, String text) {

/**
* Get a {@code BadgeElement} by its text content within a scope.
* <p>
* As with {@link #getByText(Page, String)}, the matched text includes the
* {@code number} the component renders in its shadow DOM.
*
* @param locator the scope containing the badge
* @param text the text the badge contains
Expand All @@ -121,6 +141,21 @@ public Locator getIconLocator() {

// ── Properties ─────────────────────────────────────────────────────

/**
* Get the badge's own text content.
* <p>
* Only child nodes that are not assigned to a named slot are read, so the
* text of an icon that renders a glyph in the light DOM is excluded. The
* {@code number} lives in the shadow DOM and is excluded as well; read it
* with {@link #getNumber()}.
*
* @return the badge's own text content, or an empty string when it has none
*/
@Override
public String getText() {
return (String) getLocator().evaluate(OWN_TEXT_JS);
}

/**
* Get the number displayed by the badge.
*
Expand All @@ -137,26 +172,38 @@ public Integer getNumber() {
* Assert the badge's text content.
* <p>
* The comparison is exact (no whitespace normalization) and covers only the
* badge's own text content, so the {@code number} — which the component
* renders in its shadow DOM — is excluded. Playwright's
* {@code hasText} assertion does traverse the shadow DOM, so asserting on
* {@link #getLocator()} directly would also match the number.
* text {@link #getText()} returns, so neither the {@code number} nor an
* icon's light-DOM text is included. Playwright's {@code hasText} assertion
* does traverse the shadow DOM, so asserting on {@link #getLocator()}
* directly would also match the number.
*
* @param text the expected text, or {@code null} to assert the badge has no
* text content
*/
public void assertText(String text) {
assertThat(getLocator()).hasJSProperty("textContent", text == null ? "" : text);
String expected = text == null ? "" : text;
try {
locator.page().waitForCondition(() -> expected.equals(getText()));
} catch (PlaywrightException e) {
throw new AssertionError("Badge text mismatch. Expected: <" + expected
+ ">, actual: <" + getText() + ">", e);
}
}

/**
* Assert the badge's number.
*
* @param number the expected number, or {@code null} to assert the badge has
* no number
* no number. The component toggles the {@code has-number}
* attribute exactly when {@code number != null}, so absence is
* asserted on that attribute.
*/
public void assertNumber(Integer number) {
locator.page().waitForCondition(() -> Objects.equals(number, getNumber()));
if (number != null) {
assertThat(getLocator()).hasJSProperty("number", number);
} else {
assertThat(getLocator()).not().hasAttribute("has-number", Pattern.compile(".*"));
}
}

/**
Expand All @@ -172,35 +219,4 @@ public void assertHasIcon() {
public void assertHasNoIcon() {
assertThat(getLocator()).not().hasAttribute("has-icon", Pattern.compile(".*"));
}

/**
* Assert that the badge's {@code theme} attribute contains the given
* variant, ignoring any other variants that are also applied.
*
* @param variant the theme variant name, e.g. {@code success}
*/
public void assertHasThemeVariant(String variant) {
assertThat(getLocator()).hasAttribute("theme", themeVariantPattern(variant));
}

/**
* Assert that the badge's {@code theme} attribute does not contain the given
* variant.
*
* @param variant the theme variant name, e.g. {@code success}
*/
public void assertHasNoThemeVariant(String variant) {
assertThat(getLocator()).not().hasAttribute("theme", themeVariantPattern(variant));
}

/**
* Build a pattern matching {@code variant} as a whole token inside a
* space-separated attribute value. The escaping is done by hand because
* Playwright compiles the pattern into a JavaScript {@code RegExp}, which
* does not understand {@link Pattern#quote(String)}'s {@code \Q...\E}.
*/
private static Pattern themeVariantPattern(String variant) {
String quoted = variant.replaceAll("[\\\\^$.|?*+()\\[\\]{}]", "\\\\$0");
return Pattern.compile("(^|\\s)" + quoted + "($|\\s)");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

/**
* Mixin for components that support the {@code theme} attribute.
* <p>
* Vaadin combines every applied theme variant into a single space-separated
* {@code theme} attribute, so {@link #assertTheme(String)} — which matches the
* whole attribute — needs every variant to be listed in the right order. Use
* {@link #assertHasThemeVariant(String)} to assert a single variant regardless
* of the others.
*/
public interface HasThemeElement extends HasLocatorElement {

Expand All @@ -23,4 +29,35 @@ default void assertTheme(String theme) {
}
}

/**
* Assert that the {@code theme} attribute contains the given variant,
* ignoring any other variants that are also applied.
*
* @param variant the theme variant name, e.g. {@code success}
*/
default void assertHasThemeVariant(String variant) {
assertThat(getLocator()).hasAttribute("theme", themeVariantPattern(variant));
}

/**
* Assert that the {@code theme} attribute does not contain the given
* variant. Also passes when the attribute is absent altogether.
*
* @param variant the theme variant name, e.g. {@code success}
*/
default void assertHasNoThemeVariant(String variant) {
assertThat(getLocator()).not().hasAttribute("theme", themeVariantPattern(variant));
}

/**
* Build a pattern matching {@code variant} as a whole token inside a
* space-separated attribute value. The escaping is done by hand because
* Playwright compiles the pattern into a JavaScript {@code RegExp}, which
* does not understand {@link Pattern#quote(String)}'s {@code \Q...\E}.
*/
private static Pattern themeVariantPattern(String variant) {
String quoted = variant.replaceAll("[\\\\^$.|?*+()\\[\\]{}]", "\\\\$0");
return Pattern.compile("(^|\\s)" + quoted + "($|\\s)");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class BadgeViewIT extends SpringPlaywrightIT implements HasTestView {
Expand Down Expand Up @@ -49,6 +51,17 @@ public void testGetByText() {
badge.assertText("Draft");
}

@Test
public void testGetByTextMatchesShadowNumber() {
// Playwright's hasText filter traverses the shadow DOM, so the rendered
// number is part of the matched text: "5" resolves to the numbered badge
// even though its own text is "unread messages", and not to the badge
// that literally reads "5 minutes".
BadgeElement badge = BadgeElement.getByText(page, "5");
badge.assertText("unread messages");
badge.assertNumber(5);
}

@Test
public void testGetByTextScoped() {
BadgeElement badge = BadgeElement.getByText(page.locator("#badge-container"), "Scoped");
Expand Down Expand Up @@ -79,6 +92,15 @@ public void testTextIsUpdated() {
badge.assertHasThemeVariant("success");
}

@Test
public void testTextExcludesIconSlotText() {
// The icon is a Span("\u2713") in the light DOM, so the host's textContent
// is "Verified\u2713" — only the badge's own text is asserted.
BadgeElement badge = new BadgeElement(page.locator("#badge-with-text-icon"));
badge.assertText("Verified");
assertEquals("Verified", badge.getText());
}

@Test
public void testNoText() {
BadgeElement badge = new BadgeElement(page.locator("#badge-dot"));
Expand All @@ -96,6 +118,25 @@ public void testNumber() {
badge.assertText("unread messages");
}

@Test
public void testNumberMismatchReportsExpectedAndActual() {
BadgeElement badge = new BadgeElement(page.locator("#badge-with-number"));
AssertionError error = assertThrows(AssertionError.class,
() -> badge.assertNumber(9));
assertTrue(error.getMessage().contains("9"), error.getMessage());
assertTrue(error.getMessage().contains("5"), error.getMessage());
}

@Test
public void testTextMismatchReportsExpectedAndActual() {
page.setDefaultTimeout(1000);
BadgeElement badge = new BadgeElement(page.locator("#badge-plain"));
AssertionError error = assertThrows(AssertionError.class,
() -> badge.assertText("Nope"));
assertTrue(error.getMessage().contains("Nope"), error.getMessage());
assertTrue(error.getMessage().contains("Pending"), error.getMessage());
}

@Test
public void testNoNumber() {
BadgeElement badge = new BadgeElement(page.locator("#badge-plain"));
Expand Down
Loading
Loading