BadgeElement.java

package org.vaadin.addons.dramafinder.element;

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;

import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;

/**
 * PlaywrightElement for {@code <vaadin-badge>}.
 * <p>
 * Wraps the real {@code vaadin-badge} component (server class
 * {@code com.vaadin.flow.component.badge.Badge}, available since Vaadin 25.1).
 * A badge rendered the legacy way — a {@code Span} carrying
 * {@code theme="badge"} — is <em>not</em> a {@code vaadin-badge} and is not
 * matched by this element; use a plain {@link com.microsoft.playwright.Locator}
 * for those.
 * <p>
 * A badge can carry three independent pieces of content:
 * <ul>
 *   <li>text or a component in the default slot — read with {@link #getText()},</li>
 *   <li>a {@code number} property rendered by the component itself — read with
 *       {@link #getNumber()},</li>
 *   <li>a component in the {@code icon} slot — located with
 *       {@link #getIconLocator()}.</li>
 * </ul>
 * {@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 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
 * text and number assertions keep working under those variants.
 */
@PlaywrightElement(BadgeElement.FIELD_TAG_NAME)
public class BadgeElement extends VaadinElement implements HasThemeElement, HasStyleElement {

    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}.
     *
     * @param locator the locator for the {@code <vaadin-badge>} element
     */
    public BadgeElement(Locator locator) {
        super(locator);
    }

    // ── Static Factory Methods ─────────────────────────────────────────

    /**
     * Get the first {@code BadgeElement} on the page.
     *
     * @param page the Playwright page
     * @return the first matching {@code BadgeElement}
     */
    public static BadgeElement get(Page page) {
        return new BadgeElement(page.locator(FIELD_TAG_NAME).first());
    }

    /**
     * Get the first {@code BadgeElement} within a scope.
     *
     * @param locator the scope containing the badge (not the badge itself)
     * @return the first matching {@code BadgeElement}
     */
    public static BadgeElement get(Locator locator) {
        return new BadgeElement(locator.locator(FIELD_TAG_NAME).first());
    }

    /**
     * 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. 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
     * @return the first matching {@code BadgeElement}
     */
    public static BadgeElement getByText(Page page, String text) {
        return new BadgeElement(page
                .locator(FIELD_TAG_NAME, new Page.LocatorOptions().setHasText(text))
                .first());
    }

    /**
     * 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
     * @return the first matching {@code BadgeElement}
     */
    public static BadgeElement getByText(Locator locator, String text) {
        return new BadgeElement(locator
                .locator(FIELD_TAG_NAME, new Locator.LocatorOptions().setHasText(text))
                .first());
    }

    // ── Locators ───────────────────────────────────────────────────────

    /**
     * Locator for the content of the {@code icon} slot.
     *
     * @return the icon slot locator; it matches nothing when no icon is set
     */
    public Locator getIconLocator() {
        return getLocator().locator("> [slot='icon']").first();
    }

    // ── 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.
     *
     * @return the number, or {@code null} when the badge has no number
     */
    public Integer getNumber() {
        Object value = getProperty("number");
        return value == null ? null : ((Number) value).intValue();
    }

    // ── Assertions ─────────────────────────────────────────────────────

    /**
     * Assert the badge's text content.
     * <p>
     * The comparison is exact (no whitespace normalization) and covers only the
     * 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) {
        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. 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) {
        if (number != null) {
            assertThat(getLocator()).hasJSProperty("number", number);
        } else {
            assertThat(getLocator()).not().hasAttribute("has-number", Pattern.compile(".*"));
        }
    }

    /**
     * Assert that the badge has content in its {@code icon} slot.
     */
    public void assertHasIcon() {
        assertThat(getLocator()).hasAttribute("has-icon", "");
    }

    /**
     * Assert that the badge has no content in its {@code icon} slot.
     */
    public void assertHasNoIcon() {
        assertThat(getLocator()).not().hasAttribute("has-icon", Pattern.compile(".*"));
    }
}