diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts index 7f2b021..7ffa270 100644 --- a/packages/native/src/lib/ElementAssertion.ts +++ b/packages/native/src/lib/ElementAssertion.ts @@ -2,8 +2,14 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import { get } from "dot-prop-immutable"; import { ReactTestInstance } from "react-test-renderer"; -import { instanceToString, isEmpty, getFlattenedStyle, styleToString } from "./helpers/helpers"; -import { AssertiveStyle } from "./helpers/types"; +import { + instanceToString, + isEmpty, + getFlattenedStyle, + styleToString, + textMatches, +} from "./helpers/helpers"; +import { AssertiveStyle, TestableTextMatcher, TextContent } from "./helpers/types"; export class ElementAssertion extends Assertion { public constructor(actual: ReactTestInstance) { @@ -223,7 +229,7 @@ export class ElementAssertion extends Assertion { const flattenedStyle = getFlattenedStyle(style); const hasStyle = Object.keys(flattenedStyle) - .every(key => flattenedElementStyle[key] === flattenedStyle[key]); + .every(key => flattenedElementStyle[key] === flattenedStyle[key]); const error = new AssertionError({ actual: this.actual, @@ -242,6 +248,88 @@ export class ElementAssertion extends Assertion { }); } + /** + * Check if the element has text content matching the provided string, + * RegExp, or function. + * + * @example + * ``` + * expect(element).toHaveTextContent("Hello World"); + * expect(element).toHaveTextContent(/Hello/); + * expect(element).toHaveTextContent(text => text.startsWith("Hello")); + * ``` + * + * @param text - The text to check for. + * @returns the assertion instance + */ + public toHaveTextContent(text: TestableTextMatcher): this { + const actualTextContent = this.getTextContent(this.actual); + const matchesText = textMatches(actualTextContent, text); + + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to have text content matching '` + + `${text.toString()}'.`, + }); + + const invertedError = new AssertionError({ + actual: this.actual, + message: + `Expected element ${this.toString()} NOT to have text content matching '` + + `${text.toString()}'.`, + }); + + return this.execute({ + assertWhen: matchesText, + error, + invertedError, + }); + } + + private getTextContent(element: ReactTestInstance): string { + if (!element) { + return ""; + } + + if (typeof element === "string") { + return element; + } + + if (typeof element.props?.value === "string") { + return element.props.value; + } + + return this.collectText(element).join(" "); + } + + private collectText = (element: TextContent): string[] => { + if (typeof element === "string") { + return [element]; + } + + if (Array.isArray(element)) { + return element.flatMap(child => this.collectText(child)); + } + + if (element && (typeof element === "object" && "props" in element)) { + const value = element.props?.value as TextContent; + if (typeof value === "string") { + return [value]; + } + + const children = (element.props?.children as ReactTestInstance[]) ?? element.children; + if (!children) { + return []; + } + + return Array.isArray(children) + ? children.flatMap(this.collectText) + : this.collectText(children); + } + + return []; + }; + private isElementDisabled(element: ReactTestInstance): boolean { const { type } = element; const elementType = type.toString(); @@ -250,10 +338,10 @@ export class ElementAssertion extends Assertion { } return ( - get(element, "props.aria-disabled") - || get(element, "props.disabled", false) - || get(element, "props.accessibilityState.disabled", false) - || get(element, "props.accessibilityStates", []).includes("disabled") + get(element, "props.aria-disabled") + || get(element, "props.disabled", false) + || get(element, "props.accessibilityState.disabled", false) + || get(element, "props.accessibilityStates", []).includes("disabled") ); } diff --git a/packages/native/src/lib/helpers/helpers.ts b/packages/native/src/lib/helpers/helpers.ts index 69b8411..dc603a8 100644 --- a/packages/native/src/lib/helpers/helpers.ts +++ b/packages/native/src/lib/helpers/helpers.ts @@ -1,7 +1,7 @@ import { StyleSheet } from "react-native"; import { ReactTestInstance } from "react-test-renderer"; -import { AssertiveStyle, StyleObject } from "./types"; +import { AssertiveStyle, StyleObject, TestableTextMatcher } from "./types"; /** * Checks if a value is empty. @@ -35,6 +35,42 @@ export function instanceToString(instance: ReactTestInstance | null): string { return `<${instance.type.toString()} ... />`; } +/** + * Checks if a text matches a given matcher. + * + * @param text - The text to check. + * @param matcher - The matcher to use for comparison. + * @returns `true` if the text matches the matcher, `false` otherwise. + * @throws Error if the matcher is not a string, RegExp, or function. + * @example + * ```ts + * textMatches("Hello World", "Hello World"); // true + * textMatches("Hello World", /Hello/); // true + * textMatches("Hello World", (text) => text.startsWith("Hello")); // true + * textMatches("Hello World", "Goodbye"); // false + * textMatches("Hello World", /Goodbye/); // false + * textMatches("Hello World", (text) => text.startsWith("Goodbye")); // false + * ``` + */ +export function textMatches( + text: string, + matcher: TestableTextMatcher, +): boolean { + if (typeof matcher === "string") { + return text.includes(matcher); + } + + if (matcher instanceof RegExp) { + return matcher.test(text); + } + + if (typeof matcher === "function") { + return matcher(text); + } + + throw new Error("Matcher must be a string, RegExp, or function."); +} + export function getFlattenedStyle(style: AssertiveStyle): StyleObject { const flattenedStyle = StyleSheet.flatten(style); return flattenedStyle ? (flattenedStyle as StyleObject) : {}; diff --git a/packages/native/src/lib/helpers/types.ts b/packages/native/src/lib/helpers/types.ts index f2263f9..93cc9f9 100644 --- a/packages/native/src/lib/helpers/types.ts +++ b/packages/native/src/lib/helpers/types.ts @@ -1,4 +1,5 @@ import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native"; +import { ReactTestInstance } from "react-test-renderer"; /** * Type representing a style that can be applied to a React Native component. @@ -17,3 +18,17 @@ export type AssertiveStyle = StyleProp