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
140 changes: 135 additions & 5 deletions packages/native/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { Assertion, AssertionError } from "@assertive-ts/core";
import { get } from "dot-prop-immutable";
import { ReactTestInstance } from "react-test-renderer";

import { instanceToString, isEmpty } from "./helpers/helpers";
import {
instanceToString,
isEmpty,
getFlattenedStyle,
styleToString,
textMatches,
} from "./helpers/helpers";
import { AssertiveStyle, TestableTextMatcher, TextContent } from "./helpers/types";

export class ElementAssertion extends Assertion<ReactTestInstance> {
public constructor(actual: ReactTestInstance) {
Expand Down Expand Up @@ -200,6 +207,129 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
});
}

/**
* 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" }]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we allowing an array of styling objects in here? 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because you can have an array of styles on React Native for styling 😄

Snippet from the Style docs of supported style prop values

import React from 'react';
import {StyleSheet, Text, View} from 'react-native';

const LotsOfStyles = () => {
  return (
    <View style={styles.container}>
      <Text style={styles.red}>just red</Text>
      <Text style={styles.bigBlue}>just bigBlue</Text>
      <Text style={[styles.bigBlue, styles.red]}>bigBlue, then red</Text>
      <Text style={[styles.red, styles.bigBlue]}>red, then bigBlue</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    marginTop: 50,
  },
  bigBlue: {
    color: 'blue',
    fontWeight: 'bold',
    fontSize: 30,
  },
  red: {
    color: 'red',
  },
});

export default LotsOfStyles;

* ```
*
* @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)}`,
});

const invertedError = new AssertionError({
actual: this.actual,
message: `Expected element ${this.toString()} NOT to have style: \n${styleToString(flattenedStyle)}`,
});

return this.execute({
assertWhen: hasStyle,
error,
invertedError,
});
}

/**
* 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();
Expand All @@ -208,10 +338,10 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
}

return (
get(element, "props.aria-disabled")
|| get(element, "props.disabled", false)
|| get(element, "props.accessibilityState.disabled", false)
|| get<ReactTestInstance, string[]>(element, "props.accessibilityStates", []).includes("disabled")
get(element, "props.aria-disabled")
|| get(element, "props.disabled", false)
|| get(element, "props.accessibilityState.disabled", false)
|| get<ReactTestInstance, string[]>(element, "props.accessibilityStates", []).includes("disabled")
);
}

Expand Down
49 changes: 49 additions & 0 deletions packages/native/src/lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { StyleSheet } from "react-native";
import { ReactTestInstance } from "react-test-renderer";

import { AssertiveStyle, StyleObject, TestableTextMatcher } from "./types";

/**
* Checks if a value is empty.
*
Expand Down Expand Up @@ -31,3 +34,49 @@ 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) : {};
}

export function styleToString(flattenedStyle: StyleObject): string {
const styleEntries = Object.entries(flattenedStyle);
return styleEntries.map(([key, value]) => `\t- ${key}: ${String(value)};`).join("\n");
}
34 changes: 34 additions & 0 deletions packages/native/src/lib/helpers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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.
* It can be a style for text, view, or image components.
*/
export type Style = TextStyle | ViewStyle | ImageStyle;

/**
* Type for a style prop that can be applied to a React Native component.
* It can be a single style or an array of styles.
*/
export type AssertiveStyle = StyleProp<Style>;

/**
* Type representing a style object when flattened.
* It is a record where the keys are strings and the values can be of any type.
*/
export type StyleObject = Record<string, unknown>;

/**
* Type representing a matcher for text in tests.
*
* It can be a string, a regular expression, or a function that
* takes a string and returns a boolean.
*/
export type TestableTextMatcher = string | RegExp | ((text: string) => boolean);

/**
* Type representing a value that can be used to match text content in tests.
* It can be a string, a ReactTestInstance, or an array of ReactTestInstances.
*/
export type TextContent = string | ReactTestInstance | ReactTestInstance[];
Loading
Loading