diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts index f30d78bc..f322b50d 100644 --- a/packages/native/src/lib/ElementAssertion.ts +++ b/packages/native/src/lib/ElementAssertion.ts @@ -1,9 +1,12 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import { get } from "dot-prop-immutable"; -import { Children } from "react"; import { ReactTestInstance } from "react-test-renderer"; -import { instanceToString } from "./helpers/helpers"; +import { isAncestorDisabled, isElementDisabled, isAncestorNotVisible, isElementVisible } from "./helpers/accesibility"; +import { getFlattenedStyle, styleToString } from "./helpers/styles"; +import { getTextContent, textMatches } from "./helpers/text"; +import { AssertiveStyle, TestableTextMatcher } from "./helpers/types"; +import { isEmpty, instanceToString, isElementContained } from "./helpers/utils"; export class ElementAssertion extends Assertion { public constructor(actual: ReactTestInstance) { @@ -35,7 +38,7 @@ export class ElementAssertion extends Assertion { }); return this.execute({ - assertWhen: this.isElementDisabled(this.actual) || this.isAncestorDisabled(this.actual), + assertWhen: isElementDisabled(this.actual) || isAncestorDisabled(this.actual), error, invertedError, }); @@ -61,7 +64,7 @@ export class ElementAssertion extends Assertion { }); return this.execute({ - assertWhen: !this.isElementDisabled(this.actual) && !this.isAncestorDisabled(this.actual), + assertWhen: !isElementDisabled(this.actual) && !isAncestorDisabled(this.actual), error, invertedError, }); @@ -88,7 +91,7 @@ export class ElementAssertion extends Assertion { }); return this.execute({ - assertWhen: Children.count(this.actual.props.children) === 0, + assertWhen: isEmpty(this.actual.children), error, invertedError, }); @@ -115,49 +118,152 @@ export class ElementAssertion extends Assertion { }); return this.execute({ - assertWhen: this.isElementVisible(this.actual) && this.isAncestorVisible(this.actual), + assertWhen: isElementVisible(this.actual) && !isAncestorNotVisible(this.actual), error, invertedError, }); } - private isElementDisabled(element: ReactTestInstance): boolean { - const { type } = element; - const elementType = type.toString(); - if (elementType === "TextInput" && element?.props?.editable === false) { - return true; - } - - return ( - get(element, "props.aria-disabled") - || get(element, "props.disabled", false) - || get(element, "props.accessibilityState.disabled", false) - || get(element, "props.accessibilityStates", []).includes("disabled") - ); + /** + * Check if an element is contained within another element. + * + * @example + * ``` + * expect(parent).toContainElement(child); + * ``` + * + * @param element - The element to check for. + * @returns the assertion instance + */ + public toContainElement(element: ReactTestInstance): this { + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to contain element ${instanceToString(element)}.`, + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} NOT to contain element ${instanceToString(element)}.`, + }); + + return this.execute({ + assertWhen: isElementContained(this.actual, element), + error, + invertedError, + }); } - private isAncestorDisabled(element: ReactTestInstance): boolean { - const { parent } = element; - return parent !== null && (this.isElementDisabled(element) || this.isAncestorDisabled(parent)); + /** + * Check if the element has a specific property or a specific property value. + * + * @example + * ``` + * expect(element).toHaveProp("propName"); + * expect(element).toHaveProp("propName", "propValue"); + * ``` + * + * @param propName - The name of the prop to check for. + * @param value - The value of the prop to check for. + * @returns the assertion instance + */ + public toHaveProp(propName: string, value?: unknown): this { + const propValue: unknown = get(this.actual, `props.${propName}`, undefined); + const hasProp = propValue !== undefined; + const isPropEqual = value === undefined || propValue === value; + + const errorMessage = value === undefined + ? `Expected element ${this.toString()} to have prop '${propName}'.` + : `Expected element ${this.toString()} to have prop '${propName}' with value '${String(value)}'.`; + + const invertedErrorMessage = value === undefined + ? `Expected element ${this.toString()} NOT to have prop '${propName}'.` + : `Expected element ${this.toString()} NOT to have prop '${propName}' with value '${String(value)}'.`; + + const error = new AssertionError({ actual: this.actual, message: errorMessage }); + const invertedError = new AssertionError({ actual: this.actual, message: invertedErrorMessage }); + + return this.execute({ + assertWhen: hasProp && isPropEqual, + error, + invertedError, + }); } - private isElementVisible(element: ReactTestInstance): boolean { - const { type } = element; + /** + * Asserts that a component has the specified style(s) applied. + * + * This method supports both single style objects and arrays of style objects. + * It checks if all specified style properties match on the target element. + * + * @example + * ``` + * expect(element).toHaveStyle({ backgroundColor: "red" }); + * expect(element).toHaveStyle([{ backgroundColor: "red" }]); + * ``` + * + * @param style - A style object to check for. + * @returns the assertion instance + */ + public toHaveStyle(style: AssertiveStyle): this { + const stylesOnElement: AssertiveStyle = get(this.actual, "props.style", {}); + + const flattenedElementStyle = getFlattenedStyle(stylesOnElement); + const flattenedStyle = getFlattenedStyle(style); + + const hasStyle = Object.keys(flattenedStyle) + .every(key => flattenedElementStyle[key] === flattenedStyle[key]); + + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to have style: \n${styleToString(flattenedStyle)}`, + }); - if (type.toString() === "Modal") { - return Boolean(element.props?.visible); - } + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} NOT to have style: \n${styleToString(flattenedStyle)}`, + }); - return ( - get(element, "props.style.display") !== "none" - && get(element, "props.style.opacity") !== 0 - && get(element, "props.accessibilityElementsHidden") !== true - && get(element, "props.importantForAccessibility") !== "no-hide-descendants" - ); + return this.execute({ + assertWhen: hasStyle, + error, + invertedError, + }); } - private isAncestorVisible(element: ReactTestInstance): boolean { - const { parent } = element; - return parent === null || (this.isElementVisible(parent) && this.isAncestorVisible(parent)); + /** + * 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 = 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, + }); } } diff --git a/packages/native/src/lib/helpers/accesibility.ts b/packages/native/src/lib/helpers/accesibility.ts new file mode 100644 index 00000000..8855c711 --- /dev/null +++ b/packages/native/src/lib/helpers/accesibility.ts @@ -0,0 +1,38 @@ +import { get } from "dot-prop-immutable"; +import { ReactTestInstance } from "react-test-renderer"; + +export function isElementDisabled(element: ReactTestInstance): boolean { + const { type } = element; + const elementType = type.toString(); + if (elementType === "TextInput" && element?.props?.editable === false) { + return true; + } + return ( + get(element, "props.aria-disabled") + || get(element, "props.disabled", false) + || get(element, "props.accessibilityState.disabled", false) + || get(element, "props.accessibilityStates", []).includes("disabled") + ); +} + +export function isAncestorDisabled(element: ReactTestInstance): boolean { + const { parent } = element; + return parent !== null && (isElementDisabled(element) || isAncestorDisabled(parent)); +} +export function isElementVisible(element: ReactTestInstance): boolean { + const { type } = element; + const elementType = type.toString(); + if (elementType === "Modal" && !element?.props?.visible === true) { + return false; + } + return ( + get(element, "props.style.display") !== "none" + && get(element, "props.style.opacity") !== 0 + && get(element, "props.accessibilityElementsHidden") !== true + && get(element, "props.importantForAccessibility") !== "no-hide-descendants" + ); +} +export function isAncestorNotVisible(element: ReactTestInstance): boolean { + const { parent } = element; + return parent !== null && (!isElementVisible(element) || isAncestorNotVisible(parent)); +} diff --git a/packages/native/src/lib/helpers/helpers.ts b/packages/native/src/lib/helpers/helpers.ts deleted file mode 100644 index 631395c9..00000000 --- a/packages/native/src/lib/helpers/helpers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ReactTestInstance } from "react-test-renderer"; - -/** - * Converts a ReactTestInstance to a string representation. - * - * @param instance The ReactTestInstance to convert. - * @returns A string representation of the instance. - */ -export function instanceToString(instance: ReactTestInstance | null): string { - if (instance === null) { - return "null"; - } - - return `<${instance.type.toString()} ... />`; -} diff --git a/packages/native/src/lib/helpers/styles.ts b/packages/native/src/lib/helpers/styles.ts new file mode 100644 index 00000000..3b14ef7d --- /dev/null +++ b/packages/native/src/lib/helpers/styles.ts @@ -0,0 +1,13 @@ +import { StyleSheet } from "react-native"; + +import { AssertiveStyle, StyleObject } from "./types"; + +export function getFlattenedStyle(style: AssertiveStyle): StyleObject { + const flattenedStyle = StyleSheet.flatten(style); + return flattenedStyle ? (flattenedStyle as StyleObject) : {}; +} + +export function styleToString(flattenedStyle: StyleObject): string { + const styleEntries = Object.entries(flattenedStyle); + return styleEntries.map(([key, value]) => `\t- ${key}: ${String(value)};`).join("\n"); +} diff --git a/packages/native/src/lib/helpers/text.ts b/packages/native/src/lib/helpers/text.ts new file mode 100644 index 00000000..6bccbbff --- /dev/null +++ b/packages/native/src/lib/helpers/text.ts @@ -0,0 +1,64 @@ +import { ReactTestInstance } from "react-test-renderer"; + +import { TestableTextMatcher, TextContent } from "./types"; + +function collectText (element: TextContent): string[] { + if (typeof element === "string") { + return [element]; + } + + if (Array.isArray(element)) { + return element.flatMap(child => 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(collectText) + : collectText(children); + } + + return []; +} + +export function getTextContent(element: ReactTestInstance): string { + if (!element) { + return ""; + } + if (typeof element === "string") { + return element; + } + if (typeof element.props?.value === "string") { + return element.props.value; + } + + return collectText(element).join(" "); +} + +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."); +} diff --git a/packages/native/src/lib/helpers/types.ts b/packages/native/src/lib/helpers/types.ts new file mode 100644 index 00000000..69fad07c --- /dev/null +++ b/packages/native/src/lib/helpers/types.ts @@ -0,0 +1,12 @@ +import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native"; +import { ReactTestInstance } from "react-test-renderer"; + +type Style = TextStyle | ViewStyle | ImageStyle; + +export type AssertiveStyle = StyleProp