UNPKG

@serenity-js/core

Version:

The core Serenity/JS framework, providing the Screenplay Pattern interfaces, as well as the test reporting and integration infrastructure

685 lines (652 loc) 26.6 kB
import { ensure, isNumber, isString, type Predicate } from 'tiny-types'; import type { UsesAbilities } from '../abilities'; import type { Answerable } from '../Answerable'; import type { QuestionAdapter } from '../Question'; import { Question } from '../Question'; import type { AnswersQuestions } from './AnswersQuestions'; import type { MetaQuestion } from './MetaQuestion'; import { the } from './tag-functions'; /** * Provides methods to perform calculations on numeric values returned by other [questions](https://serenity-js.org/api/core/class/Question/). * * ## Example * * The examples in this section demonstrate interacting with an HTML widget representing a price list. * * ```html * <ul id="price-list"> * <li class="item"> * <span class="name">apples</span> * <span class="quantity">5</span> * <span class="price">£2.25</span> * </li> * <li class="item"> * <span class="name">apricots</span> * <span class="quantity">4</span> * <span class="price">£3.70</span> * </li> * <li class="item"> * <span class="name">bananas</span> * <span class="quantity">2</span> * <span class="price">£1.50</span> * </li> * </ul> * ``` * * ### Lean Page Objects * * To represent our example HTML widget, * we follow the [Lean Page Objects pattern](https://serenity-js.org/handbook/web-testing/page-objects-pattern/) * to define the `PriceList` and `PriceListItem` classes * and use the Serenity/JS [Page Element Query Language (PEQL)](https://serenity-js.org/handbook/web-testing/page-element-query-language/) * to identify the elements of interest. * * ```ts * // ui/price-list.ts * * import { PageElement, PageElements, By } from '@serenity-js/web' * * export class PriceList { * static container = () => * PageElement.located(By.id('price-list')) * .describedAs('price list') * * static items = () => * PageElements.located(PriceListItem.containerSelector()) * .of(this.container()) * .describedAs('items') * } * * export class PriceListItem { * static containerSelector = () => * By.css('.item') * * static container = () => * PageElement.located(this.containerSelector()) * .describedAs('item') * * static name = () => * PageElement.located(By.css('.name')) * .of(this.container()) * .describedAs('name') * * static quantity = () => * PageElement.located(By.css('.quantity')) * .of(this.container()) * .describedAs('quantity') * * static price = () => * PageElement.located(By.css('.price')) * .of(this.container()) * .describedAs('price') * } * ``` * * @group Questions */ export class Numeric { /** * Returns a [`Question`](https://serenity-js.org/api/core/class/Question/) that sums up the values provided * and throws if any of the values is not a `number`. * * #### Adding static numbers * * The most basic example of using the `Numeric.sum` method is to add up a few numbers. * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum(1, 2, 3), * equals(6), * ) * ) * ``` * * The numbers can be provided individually, as an array, or as a combination of both. * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum([ 1, 2 ], 3, [ 4, 5 ]), * equals(15), * ) * ) * ``` * * #### Adding numbers returned by other questions * * Apart from adding static numbers, you can also add numbers returned by other questions. * This is particularly useful when you need to calculate the sum of numbers extracted from a list of UI elements * or from an API response. * * ```ts * import { actorCalled, Numeric, Question } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * const myNumber = () => * Question.about('my number', actor => 42) * * const myArrayOfNumbers = () => * Question.about('my array of numbers', actor => [ 1, 2, 3 ]) * * const myObjectWithNumbers = () => * Question.about('my object with numbers', actor => ({ a: 1, b: 2, c: 3 })) * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum( * myNumber(), // a question returning a number * myArrayOfNumbers(), // a question returning an array of numbers * ), * equals(48), * ), * Ensure.that( * Numeric.sum( * myObjectWithNumbers() // a question returning an object with numbers * .as(Object.values), // from which we extract the numeric values * ), * equals(6), * ), * ) * ``` * * Of course, you can also mix and match static numbers with numbers returned by questions. * * ```ts * import { actorCalled, Numeric, Question } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * const myObjectWithNumbers = () => * Question.about('my object with numbers', actor => ({ a: 1, b: 2, c: 3 })) * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum( * myObjectWithNumbers().as(Object.values), * [ 4, 5 ], * 6, * ), * equals(21), * ), * ) * ``` * * #### Adding numbers extracted from a UI * * To add numbers extracted from a UI: * - use the [`PageElement`](https://serenity-js.org/api/web/class/PageElement) and [`PageElements`](https://serenity-js.org/api/web/class/PageElements) classes to identify the elements of interest, * - use the [`Text.of`](https://serenity-js.org/api/web/class/Text/) or [`Text.ofAll`](https://serenity-js.org/api/web/class/Text/) questions to extract the text of the element or elements, * - and then interpret this text as number using either the [`.as(Number)`](https://serenity-js.org/api/core/class/Question/#as) mapping function, * or the [`Numeric.intValue()`](https://serenity-js.org/api/core/class/Numeric/#intValue) or [`Numeric.floatValue()`](https://serenity-js.org/api/core/class/Numeric/#floatValue) meta-questions. * * For example, we could calculate the sum of quantities of items in our example price list by specifying each element explicitly * and mapping its text to [`Number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number): * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum( * Text.of(PriceListItem.quantity().of(PriceList.items().first())).as(Number), * Text.of(PriceListItem.quantity().of(PriceList.items().at(1))).as(Number), * Text.of(PriceListItem.quantity().of(PriceList.items().last())).as(Number), * ), * equals(11), * ), * ) * ``` * * A more elegant approach would be to use the Serenity/JS [Page Element Query Language (PEQL)](https://serenity-js.org/handbook/web-testing/page-element-query-language/#mapping-page-elements-in-a-collection) * to map each item in the collection to its quantity and then calculate the sum. * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum( * PriceList.items() // get all the li.item elements * .eachMappedTo( * Text.of(PriceListItem.quantity()) // extract the text of the .quantity element * ) * .eachMappedTo( // interpret the quantity as an integer value * Numeric.intValue(), * ), * ), * equals(11), // 5 apples, 4 apricots, 2 bananas * ) * ) * ``` * * Using PEQL allows us to express the intent more concisely and, for example, * introduce helper functions that limit the scope of the operation to a subset of elements. * * ```ts * import { actorCalled, Expectation, Numeric, the } from '@serenity-js/core' * import { Ensure, equals, startsWith } from '@serenity-js/assertions' * import { PriceList, PriceListItem } from './ui/price-list' * * const quantitiesOfItemsWhichName = (expectation: Expectation<string>) => * PriceList.items() // get all the li.item elements * .where( // leave only those which name matches the expectation * Text.of(PriceListItem.name()), * expectation * ) * .eachMappedTo( * Text.of(PriceListItem.quantity()) // extract the text of the .quantity element * ) * .eachMappedTo( // interpret the quantity as an integer value * Numeric.intValue(), * ) * .describedAs(the`quantities of items which name does ${ expectation }`) * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.sum( * quantitiesOfItemsWhichName(startsWith('ap')), // apples and apricots * ), * equals(9), // 5 apples, 4 apricots * ) * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. * * @param values */ static sum(...values: Array<Answerable<number | number[]>>): QuestionAdapter<number> { return Question.about<number>(the`the sum of ${ values }`, async actor => { const numbers = await actor.answer(this.flatten(values, isNumber())); return numbers.sort() .reduce((acc, current) => acc + current, 0); }); } /** * Returns a [`Question`](https://serenity-js.org/api/core/class/Question/) that calculates the difference between * two numbers and throws if any of the values is not a `number`. * * #### Subtracting numbers * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.difference( * Text.of(PriceListItem.quantity().of(PriceList.items().first())).as(Number), // 5 (apples) * 2, // - 2 * ), * equals(3), * ), * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. * * @param minuend * @param subtrahend */ static difference(minuend: Answerable<number>, subtrahend: Answerable<number>): QuestionAdapter<number> { return Question.about<number>(the`the difference between ${ minuend } and ${ subtrahend }`, async actor => { const minuendValue = await actor.answer(minuend); const subtrahendValue = await actor.answer(subtrahend); return ensure(this.descriptionOf(minuendValue), minuendValue, isNumber()) - ensure(this.descriptionOf(subtrahendValue), subtrahendValue, isNumber()); }); } /** * Returns a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that calculates * the ceiling of a number and throws if the value is not a `number`. * * #### Calculating the ceiling of a number * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.ceiling().of( * Text.of(PriceListItem.price().of(PriceList.items().first())) // '£2.25' (apples) * .replace('£', '') // '2.25' * .as(Number.parseFloat), // 2.25 * ), * equals(3), * ), * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. */ static ceiling(): MetaQuestion<number, QuestionAdapter<number>> { return { of: (value: Answerable<number>) => Question.about(the`the ceiling of ${ value }`, async (actor: AnswersQuestions & UsesAbilities) => { const answer = await actor.answer(value); return Math.ceil(ensure(this.descriptionOf(answer), answer, isNumber())); }), }; } /** * Returns a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that calculates * the floor of a number and throws if the value is not a `number`. * * #### Calculating the floor of a number * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.floor().of( * Text.of(PriceListItem.price().of(PriceList.items().first())) // '£2.25' (apples) * .replace('£', '') // '2.25' * .as(Number.parseFloat), // 2.25 * ), * equals(2), * ), * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. */ static floor(): MetaQuestion<number, QuestionAdapter<number>> { return { of: (value: Answerable<number>) => Question.about(the`the floor of ${ value }`, async (actor: AnswersQuestions & UsesAbilities) => { const answer = await actor.answer(value); return Math.floor(ensure(this.descriptionOf(answer), answer, isNumber())); }), } } /** * Returns a [`Question`](https://serenity-js.org/api/core/class/Question/) that calculates * the maximum value in the lists of numbers provided and throws if any of the values is not a `number`. * * #### Calculating the maximum value * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.max( * PriceList.items() // get all the li.item elements * .eachMappedTo( * Text.of(PriceListItem.quantity()) // extract the text of the .quantity element * ) * .eachMappedTo( // interpret the quantity as an integer value * Numeric.intValue(), * ), * ), * equals(5), // 5 (apples) * ) * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. * * @param values */ static max(...values: Array<Answerable<number | number[]>>): QuestionAdapter<number> { return Question.about<number>(the`the max of ${ values }`, async actor => { const numbers = await actor.answer(this.flatten(values, isNumber())); return numbers.sort().pop(); }); } /** * Returns a [`Question`](https://serenity-js.org/api/core/class/Question/) that calculates * the minimum value in the lists of numbers provided and throws if any of the values is not a `number`. * * #### Calculating the minimum value * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.min( * PriceList.items() // get all the li.item elements * .eachMappedTo( * Text.of(PriceListItem.quantity()) // extract the text of the .quantity element * ) * .eachMappedTo( // interpret the quantity as an integer value * Numeric.intValue(), * ), * ), * equals(2), // 2 (bananas) * ) * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. * * @param values */ static min(...values: Array<Answerable<number | number[]>>): QuestionAdapter<number> { return Question.about<number>(the`the min of ${ values }`, async actor => { const numbers = await actor.answer(this.flatten(values, isNumber())); return numbers.sort().shift(); }); } /** * Returns a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that parses a string `value` * and returns an integer of the specified `base`. * Leading whitespace in the value to parse argument is ignored. * * #### Parsing a string as an integer * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.intValue().of( * Text.of( // '5' (apples) * PriceListItem.quantity().of( * PriceList.items().first() * ) * ) * ), * equals(5), * ), * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. * * @param base * An integer between 2 and 36 that represents the base in mathematical numeral systems of the string. * If base is undefined or 0, it is assumed to be 10 except when the number begins with the code unit pairs 0x or 0X, in which case a radix of 16 is assumed. */ static intValue(base?: Answerable<number>): MetaQuestion<string, QuestionAdapter<number>> { return { /** * @param value * The value to parse, coerced to a string. Leading whitespace in this argument is ignored. */ of: (value: Answerable<string>) => Question.about<Promise<number>>(the`the integer value of ${ value }`, async actor => { const description = this.descriptionOf(value); const stringValue = ensure(description, await actor.answer(value), isString()); const maybeBase = await actor.answer(base) const radix = maybeBase !== undefined && maybeBase !== null ? ensure(`base ${ this.descriptionOf(base) }`, maybeBase, isNumber()) : undefined; const parsed = Number.parseInt(stringValue, radix); if (Number.isNaN(parsed)) { throw new TypeError(`Parsing ${ description } as an integer value returned a NaN`); } return parsed; }), } } /** * Returns a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that parses a string `value` * and returns a [`BigInt`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt). * Leading whitespace in the value to parse argument is ignored. * * #### Parsing a string as a bigint * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.bigIntValue().of( * Text.of( // '5' (apples) * PriceListItem.quantity().of(PriceList.items().first()) * ) * ), * equals(BigInt('5')), * ), * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. */ static bigIntValue(): MetaQuestion<string, QuestionAdapter<bigint>> { return { /** * @param value * The value to parse, coerced to a string. Leading whitespace in this argument is ignored. */ of: (value: Answerable<string>) => Question.about<Promise<bigint>>(the`the bigint value of ${ value }`, async actor => { const description = this.descriptionOf(value); const stringValue = ensure(description, await actor.answer(value), isString()); try { return BigInt(stringValue); } catch(error) { throw new TypeError(`Parsing ${ description } as a bigint value returned an error: ${ error.message || error }`); } }), } } /** * Returns a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that parses a string `value` * and returns a floating-point number. * * #### Parsing a string as a floating point number * * ```ts * import { actorCalled, Numeric } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * import { Text } from '@serenity-js/web' * import { PriceList, PriceListItem } from './ui/price-list' * * await actorCalled('Zoé').attemptsTo( * Ensure.that( * Numeric.floatValue().of( * Text.of( // '£2.25' * PriceListItem.price().of(PriceList.items().first()) * ).replace('£', '') // '2.25' * ), * equals(2.25), * ), * ) * ``` * * #### Learn more * * View the [`Numeric` API documentation](https://serenity-js.org/api/core/class/Numeric) for more details * and examples. */ static floatValue(): MetaQuestion<string, QuestionAdapter<number>> { return { /** * @param value * The value to parse, coerced to a string. Leading whitespace in this argument is ignored. */ of: (value: Answerable<string>) => Question.about<Promise<number>>(the`the float value of ${ value }`, async actor => { const description = this.descriptionOf(value); const maybeNumber = ensure(description, await actor.answer(value), isString()); const parsed = Number.parseFloat(maybeNumber); if (Number.isNaN(parsed)) { throw new TypeError(`Parsing ${ description } as a float value returned a NaN`); } return parsed; }), } } private static flatten<T>(items: Array<Answerable<T | T[]>>, ...predicates: Array<Predicate<T>>): QuestionAdapter<T[]> { return Question.about('flatten', async actor => { const result: T[] = []; for (const item of items) { const valueOrValues = await actor.answer(item); const values = Array.isArray(valueOrValues) ? valueOrValues : [ valueOrValues ]; const valuesOfCorrectType = values.map(value => ensure(this.descriptionOf(value), value, ...predicates)); result.push(...valuesOfCorrectType); } return result; }); } private static descriptionOf(value: unknown): string { if (value === undefined) { return 'undefined'; } return Question.formattedValue().of(value).toString(); } }