Skip to content

Commit

Permalink
Merge pull request #110 from getodk/jr-scenario-ports/kitchen-sink
Browse files Browse the repository at this point in the history
Port JavaRosa `Scenario` test suites
  • Loading branch information
eyelidlessness authored May 30, 2024
2 parents 6bf47de + 16e4f56 commit 11f18d1
Show file tree
Hide file tree
Showing 232 changed files with 135,278 additions and 879 deletions.
11 changes: 11 additions & 0 deletions packages/common/src/lib/runtime-types/instance-predicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type AssertInstanceType = <T, U extends T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Constructor: abstract new (...args: any[]) => U,
instance: T
) => asserts instance is U;

export const assertInstanceType: AssertInstanceType = (Constructor, instance) => {
if (!(instance instanceof Constructor)) {
throw new Error('Instance of unexpected type');
}
};
13 changes: 13 additions & 0 deletions packages/common/src/test/assertions/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export { instanceArrayAssertion } from './instanceArrayAssertion.ts';
export { instanceAssertion } from './instanceAssertion.ts';
export { typeofAssertion } from './typeofAssertion.ts';
export { AsymmetricTypedExpectExtension } from './vitest/AsymmetricTypedExpectExtension.ts';
export { InspectableComparisonError } from './vitest/InspectableComparisonError.ts';
export { StaticConditionExpectExtension } from './vitest/StaticConditionExpectExtension.ts';
export { SymmetricTypedExpectExtension } from './vitest/SymmetricTypedExpectExtension.ts';
export { extendExpect } from './vitest/extendExpect.ts';
export type {
CustomInspectable,
DeriveStaticVitestExpectExtension,
Inspectable,
} from './vitest/shared-extension-types.ts';
30 changes: 30 additions & 0 deletions packages/common/src/test/assertions/instanceArrayAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { AssertIs } from '../../../types/assertions/AssertIs.ts';
import type { ConstructorOf } from '../../../types/helpers';
import { instanceAssertion } from './instanceAssertion.ts';

/**
* Creates a type assertion function, used to validate both statically and at
* runtime that a value is an array, and each item in the array is an instance
* of the provided {@link Constructor}.
*/
export const instanceArrayAssertion = <T>(
Constructor: ConstructorOf<T>
): AssertIs<readonly T[]> => {
const assertInstance: AssertIs<T> = instanceAssertion(Constructor);

return (value) => {
if (!Array.isArray(value)) {
throw new Error(`Not an array of ${Constructor.name}: value itself is not an array`);
}

for (const [index, item] of value.entries()) {
try {
assertInstance(item);
} catch {
throw new Error(
`Not an array of ${Constructor.name}: item at index ${index} not an instance`
);
}
}
};
};
14 changes: 14 additions & 0 deletions packages/common/src/test/assertions/instanceAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { AssertIs } from '../../../types/assertions/AssertIs.ts';
import type { ConstructorOf } from '../../../types/helpers';

/**
* Creates a type assertion function, used to validate both statically and at
* runtime that a value is an instance of the provided {@link Constructor}.
*/
export const instanceAssertion = <T>(Constructor: ConstructorOf<T>): AssertIs<T> => {
return (value) => {
if (!(value instanceof Constructor)) {
throw new Error(`Not an instance of ${Constructor.name}`);
}
};
};
76 changes: 76 additions & 0 deletions packages/common/src/test/assertions/typeofAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { AnyConstructor, AnyFunction } from '../../../types/helpers.d.ts';

/**
* @see {@link Typeof}
*/
const TYPEOF = typeof ('' as unknown);

/**
* Used to derive the union of strings produced by a `typeof ...` expression.
* While this doesn't change frequently:
*
* - It has changed twice in recent memory (`"symbol"` and `"bigint"`)
* - It will likely change in the foreseeable future (`"record"` and `"tuple"`)
*
* Deriving this type helps to ensure {@link TypeofType} remains up to date.
*/
type Typeof = typeof TYPEOF;

/**
* Corresponds to values producing "function" in a `typeof ...` expression.
*
* Note that TypeScript will produce {@link Function} when the input type is
* `unknown`, but will produce a narrower type for some more specific inputs,
* such as a union between some non-function type and:
*
* - One or more specific function signatures, possibly also specifying its
* `this` context.
* - Any class, which may or may not be `abstract`.
*
* TypeScript will **also** narrow those cases with {@link typeofAssertion} with
* this more expanded type, but in many cases it would fail to do so if we only
* specify {@link Function}.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
type TypeofFunction = AnyConstructor | AnyFunction | Function;

/**
* Corresponds to values producing "object" in a `typeof ...` expression.
*/
type TypeofObject = object | null;

/**
* While {@link Typeof} can be derived, the type implied by each member of
* that union cannot. This is a best faith effort to represent the actual
* types corresponding to each case. By mapping the type, we can use the
* {@link typeofAssertion} factory to produce accurate derived types with
* minimal redundancy, and correct any discrepancies in one place as they
* might arise (noting that both {@link function} and {@link object} are
* mapped to more complex types than one might assume from their names).
*/
interface TypeofTypes {
bigint: bigint;
boolean: boolean;
function: TypeofFunction;
number: number;
object: TypeofObject;
string: string;
symbol: symbol;
undefined: undefined;
}

type TypeofType<T extends Typeof> = TypeofTypes[T];

type TypeofAssertion<T extends Typeof> = <U>(
value: U
) => asserts value is Extract<TypeofType<T>, U>;

export const typeofAssertion = <T extends Typeof>(expected: T): TypeofAssertion<T> => {
return (value) => {
const actual = typeof value;

if (actual !== expected) {
throw new Error(`Expected typeof value to be ${expected}, got ${actual}`);
}
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { SyncExpectationResult } from 'vitest';
import type { AssertIs } from '../../../../types/assertions/AssertIs.ts';
import { expandSimpleExpectExtensionResult } from './expandSimpleExpectExtensionResult.ts';
import type { ExpectExtensionMethod } from './shared-extension-types.ts';
import { validatedExtensionMethod } from './validatedExtensionMethod.ts';

/**
* Generalizes definition of a Vitest `expect` API extension where the assertion
* expects differing types for its `actual` and `expected` parameters, and:
*
* - Automatically perfoms runtime validation of those parameters, helping to
* ensure that the extensions' static types are consistent with the runtime
* values passed in a given test's assertions
*
* - Expands simplified assertion result types to the full interface expected by
* Vitest
*
* - Facilitates deriving and defining corresponding static types on the base
* `expect` type
*/
export class AsymmetricTypedExpectExtension<Actual = unknown, Expected = Actual> {
readonly extensionMethod: ExpectExtensionMethod<unknown, unknown, SyncExpectationResult>;

constructor(
readonly validateActualArgument: AssertIs<Actual>,
readonly validateExpectedArgument: AssertIs<Expected>,
extensionMethod: ExpectExtensionMethod<Actual, Expected>
) {
const validatedMethod = validatedExtensionMethod(
validateActualArgument,
validateExpectedArgument,
extensionMethod
);

this.extensionMethod = expandSimpleExpectExtensionResult(validatedMethod);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { inspect } from './inspect.ts';
import type { Inspectable } from './shared-extension-types.ts';

interface InspectableComparisonErrorOptions {
readonly comparisonQualifier?: string;
readonly details?: string;
}

/**
* Provides a general mechanism for reporting assertion failures in a consistent
* format, for the general class of Vitest assertion extensions which fall into
* a broad category of comparisons, where failure reports will tend to follow a
* format of:
*
* > Expected $actual to $comparisonVerb $expected
* > $comparisonQualifier? ...$details?
*/
export class InspectableComparisonError extends Error {
constructor(
actual: Inspectable,
expected: Inspectable,
comparisonVerb: string,
options: InspectableComparisonErrorOptions = {}
) {
const { comparisonQualifier, details } = options;

const messageParts = [
'Expected',
inspect(actual),
'to',
comparisonVerb,
inspect(expected),
comparisonQualifier,
details,
].filter((value): value is string => typeof value === 'string');

super(messageParts.join(' '));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { JSONValue } from '../../../../types/JSONValue.ts';
import { inspect } from './inspect.ts';
import type { Inspectable } from './shared-extension-types.ts';

interface InspectableStaticConditionErrorOptions {
readonly details?: string;
}

export class InspectableStaticConditionError extends Error {
constructor(
actual: Inspectable,
expectedCondition: JSONValue,
options: InspectableStaticConditionErrorOptions = {}
) {
const { details } = options;

const messageParts = [
'Expected',
inspect(actual),
'to equal',
JSON.stringify(expectedCondition),
details,
].filter((value): value is string => typeof value === 'string');

super(messageParts.join(' '));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { JestAssertion, SyncExpectationResult } from 'vitest';
import { expect } from 'vitest';
import type { JSONObject } from '../../../../types/JSONValue.ts';
import type { AssertIs } from '../../../../types/assertions/AssertIs.ts';
import { assertVoidExpectedArgument } from './assertVoidExpectedArgument.ts';
import { expandSimpleExpectExtensionResult } from './expandSimpleExpectExtensionResult.ts';
import type { ExpectExtensionMethod } from './shared-extension-types.ts';
import { validatedExtensionMethod } from './validatedExtensionMethod.ts';

/**
* Produces a callable `expect` extension implementation, which matches a
* provided `staticCondition` object. This is effectively a wrapper around
* {@link JestAssertion.toMatchObject | `expect(...).toMatchObject(staticCondition)`},
* generated by the custom assertion's definition, and producing the return type
* expected by all of our custom assertion interfaces.
*/
const staticConditionExtensionMethodFactory = <Parameter>(
staticCondition: JSONObject
): ExpectExtensionMethod<Parameter, void> => {
return (actual) => {
try {
expect(actual).toMatchObject(staticCondition);

return true;
} catch (error) {
if (error instanceof Error) {
return error;
}

return new Error('Unknown error in assertion');
}
};
};

/**
* Generalizes definition of a Vitest `expect` API extension where the assertion
* expects a specific type for its `actual` parameter, and:
*
* - Implements an assertion checking some statically known condition of the
* `actual` argument, as represented by an object suitable for use in
* {@link JestAssertion.toMatchObject | `expect(...).toMatchObject(expectedStaticCondition)`}
*
* - Automatically perfoms runtime validation of that parameter, helping to
* ensure that the extensions' static types are consistent with the runtime
* values passed in a given test's assertions
*
* - Expands simplified assertion result types to the full interface expected by
* Vitest
*
* - Facilitates deriving and defining corresponding static types on the base
* `expect` type
*
* @todo Reconsider naming and language around "static"-ness. The idea here is
* that the `expected` parameter is defined upfront by the extension, not passed
* as a parameter at the assertion's call site.
*/
export class StaticConditionExpectExtension<
StaticCondition extends JSONObject,
Parameter extends StaticCondition = StaticCondition,
> {
readonly extensionMethod: ExpectExtensionMethod<unknown, unknown, SyncExpectationResult>;

constructor(
readonly validateArgument: AssertIs<Parameter>,
readonly expectedStaticCondition: StaticCondition
) {
const validatedMethod = validatedExtensionMethod(
validateArgument,
assertVoidExpectedArgument,
staticConditionExtensionMethodFactory(expectedStaticCondition)
);

this.extensionMethod = expandSimpleExpectExtensionResult(validatedMethod);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { SyncExpectationResult } from 'vitest';
import type { AssertIs } from '../../../../types/assertions/AssertIs.ts';
import { expandSimpleExpectExtensionResult } from './expandSimpleExpectExtensionResult.ts';
import type { ExpectExtensionMethod } from './shared-extension-types.ts';
import { validatedExtensionMethod } from './validatedExtensionMethod.ts';

/**
* Generalizes definition of a Vitest `expect` API extension where the assertion
* expects the same type for both its `actual` and `expected` parameters, and:
*
* - Automatically perfoms runtime validation of those parameters, helping to
* ensure that the extensions' static types are consistent with the runtime
* values passed in a given test's assertions
*
* - Expands simplified assertion result types to the full interface expected
* by Vitest
*
* - Facilitates deriving and defining corresponding static types on the
* base `expect` type
*/
export class SymmetricTypedExpectExtension<Parameter = unknown> {
readonly extensionMethod: ExpectExtensionMethod<unknown, unknown, SyncExpectationResult>;

constructor(
readonly validateArgument: AssertIs<Parameter>,
extensionMethod: ExpectExtensionMethod<Parameter, Parameter>
) {
const validatedMethod = validatedExtensionMethod(
validateArgument,
validateArgument,
extensionMethod
);

this.extensionMethod = expandSimpleExpectExtensionResult(validatedMethod);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type AssertVoidExpectedArgument = (
args: readonly unknown[]
) => asserts args is readonly [expected?: undefined];

export const assertVoidExpectedArgument: AssertVoidExpectedArgument = (args) => {
// This accounts for awkwardness around the generic assertion types, where the
// expected argument (as optional first item in a `...rest` array) may be
// present but undefined, to allow for a common call site shape.
if (args.length === 1 && args[0] === undefined) {
return;
}

if (args.length > 0) {
throw new Error('Assertion does not accept any `expected` arguments');
}
};
Loading

0 comments on commit 11f18d1

Please sign in to comment.