Skip to content

Commit

Permalink
Add scenario integration tests for appearances and body classes
Browse files Browse the repository at this point in the history
  • Loading branch information
eyelidlessness committed May 31, 2024
1 parent fc776a9 commit 4844b7d
Show file tree
Hide file tree
Showing 9 changed files with 686 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type UnknownObject = Record<PropertyKey, unknown>;

type AssertUnknownObject = (value: unknown) => asserts value is UnknownObject;

export const assertUnknownObject: AssertUnknownObject = (value) => {
if (typeof value !== 'object' || value == null) {
throw new Error('Not an object');
}
};
24 changes: 24 additions & 0 deletions packages/common/src/test/assertions/arrayOfAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { AssertIs } from '../../../types/assertions/AssertIs.ts';

type ArrayItemAssertion<T> = (item: unknown) => asserts item is T;

export const arrayOfAssertion = <T>(
assertItem: ArrayItemAssertion<T>,
itemTypeDescription: string
): AssertIs<readonly T[]> => {
return (value) => {
if (!Array.isArray(value)) {
throw new Error(`Not an array of ${itemTypeDescription}: value itself is not an array`);
}

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

/**
Expand All @@ -12,19 +13,5 @@ export const instanceArrayAssertion = <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`
);
}
}
};
return arrayOfAssertion(assertInstance, Constructor.name);
};
4 changes: 1 addition & 3 deletions packages/scenario/src/assertion/extensions/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,18 @@ import {
SymmetricTypedExpectExtension,
extendExpect,
instanceAssertion,
typeofAssertion,
} from '@getodk/common/test/assertions/helpers.ts';
import { expect } from 'vitest';
import { ComparableAnswer } from '../../answer/ComparableAnswer.ts';
import { ExpectedApproximateUOMAnswer } from '../../answer/ExpectedApproximateUOMAnswer.ts';
import { AnswerResult } from '../../jr/Scenario.ts';
import { ValidationImplementationPendingError } from '../../jr/validation/ValidationImplementationPendingError.ts';
import { assertString } from './shared-type-assertions.ts';

const assertComparableAnswer = instanceAssertion(ComparableAnswer);

const assertExpectedApproximateUOMAnswer = instanceAssertion(ExpectedApproximateUOMAnswer);

const assertString = typeofAssertion('string');

type AssertAnswerResult = (value: unknown) => asserts value is AnswerResult;

const answerResults = new Set<AnswerResult>(Object.values(AnswerResult));
Expand Down
75 changes: 75 additions & 0 deletions packages/scenario/src/assertion/extensions/appearances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts';
import {
AsymmetricTypedExpectExtension,
extendExpect,
} from '@getodk/common/test/assertions/helpers.ts';
import type { AnyNode } from '@getodk/xforms-engine';
import { expect } from 'vitest';
import { assertArrayOfStrings, assertEngineNode, assertString } from './shared-type-assertions.ts';

const hasAppearance = (node: AnyNode, appearance: string): boolean => {
return node.appearances?.[appearance] === true;
};

const appearanceExtensions = extendExpect(expect, {
toHaveAppearance: new AsymmetricTypedExpectExtension(
assertEngineNode,
assertString,
(actual, expected) => {
if (hasAppearance(actual, expected)) {
return true;
}

return new Error(
`Node ${actual.currentState.reference} does not have appearance "${expected}"`
);
}
),

notToHaveAppearance: new AsymmetricTypedExpectExtension(
assertEngineNode,
assertString,
(actual, expected) => {
if (hasAppearance(actual, expected)) {
return new Error(
`Node ${actual.currentState.reference} has appearance "${expected}", which was not expected`
);
}

return true;
}
),

toYieldAppearances: new AsymmetricTypedExpectExtension(
assertEngineNode,
assertArrayOfStrings,
(actual, expected) => {
const yielded = new Set<string>();

for (const appearance of actual.appearances ?? []) {
yielded.add(appearance);
}

const notYielded = expected.filter((item) => {
return !yielded.has(item);
});

if (notYielded.length === 0) {
return true;
}

return new Error(
`Node ${actual.currentState.reference} did not yield expected appearances ${notYielded.join(', ')}`
);
}
),
});

type AppearanceExtensions = typeof appearanceExtensions;

declare module 'vitest' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Assertion<T = any> extends DeriveStaticVitestExpectExtension<AppearanceExtensions, T> {}
interface AsymmetricMatchersContaining
extends DeriveStaticVitestExpectExtension<AppearanceExtensions> {}
}
75 changes: 75 additions & 0 deletions packages/scenario/src/assertion/extensions/body-classes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts';
import {
AsymmetricTypedExpectExtension,
extendExpect,
} from '@getodk/common/test/assertions/helpers.ts';
import type { RootNode } from '@getodk/xforms-engine';
import { expect } from 'vitest';
import { assertArrayOfStrings, assertRootNode, assertString } from './shared-type-assertions.ts';

const hasClass = (node: RootNode, className: string): boolean => {
return node.classes?.[className] === true;
};

const bodyClassesExtensions = extendExpect(expect, {
toHaveClass: new AsymmetricTypedExpectExtension(
assertRootNode,
assertString,
(actual, expected) => {
if (hasClass(actual, expected)) {
return true;
}

return new Error(
`RootNode ${actual.currentState.reference} does not have class "${expected}"`
);
}
),

notToHaveClass: new AsymmetricTypedExpectExtension(
assertRootNode,
assertString,
(actual, expected) => {
if (hasClass(actual, expected)) {
return new Error(
`RootNode ${actual.currentState.reference} has class "${expected}", which was not expected`
);
}

return true;
}
),

toYieldClasses: new AsymmetricTypedExpectExtension(
assertRootNode,
assertArrayOfStrings,
(actual, expected) => {
const yielded = new Set<string>();

for (const className of actual.classes) {
yielded.add(className);
}

const notYielded = expected.filter((item) => {
return !yielded.has(item);
});

if (notYielded.length === 0) {
return true;
}

return new Error(
`RootNode ${actual.currentState.reference} did not yield expected classes ${notYielded.join(', ')}`
);
}
),
});

type BodyClassExtensions = typeof bodyClassesExtensions;

declare module 'vitest' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Assertion<T = any> extends DeriveStaticVitestExpectExtension<BodyClassExtensions, T> {}
interface AsymmetricMatchersContaining
extends DeriveStaticVitestExpectExtension<BodyClassExtensions> {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts';
import { arrayOfAssertion } from '@getodk/common/test/assertions/arrayOfAssertion.ts';
import { typeofAssertion } from '@getodk/common/test/assertions/typeofAssertion.ts';
import type { AnyNode, RootNode } from '@getodk/xforms-engine';

type AssertRootNode = (node: unknown) => asserts node is RootNode;

export const assertRootNode: AssertRootNode = (node) => {
assertUnknownObject(node);

const maybeRootNode = node as Partial<RootNode>;

if (
maybeRootNode.nodeType !== 'root' ||
typeof maybeRootNode.setLanguage !== 'function' ||
typeof maybeRootNode.currentState !== 'object' ||
maybeRootNode.currentState == null
) {
throw new Error('Node is not a `RootNode`');
}
};

type AssertEngineNode = (node: unknown) => asserts node is AnyNode;

type AnyNodeType = AnyNode['nodeType'];
type NonRootNodeType = Exclude<AnyNodeType, 'root'>;

const nonRootNodeTypes = new Set<NonRootNodeType>([
'string',
'select',
'subtree',
'group',
'repeat-range',
'repeat-instance',
]);

export const assertEngineNode: AssertEngineNode = (node) => {
assertUnknownObject(node);

const maybeNode = node as Partial<AnyNode>;

assertRootNode(maybeNode.root);

if (maybeNode === maybeNode.root) {
return;
}

if (!nonRootNodeTypes.has(maybeNode.nodeType as NonRootNodeType)) {
throw new Error('Not an engine node');
}
};

export const assertString = typeofAssertion('string');

export const assertArrayOfStrings = arrayOfAssertion(assertString, 'string');
2 changes: 2 additions & 0 deletions packages/scenario/src/assertion/setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import './extensions/answers.ts';
import './extensions/appearances.ts';
import './extensions/body-classes.ts';
import './extensions/choices.ts';
import './extensions/form-state.ts';
import './extensions/node-state.ts';
Expand Down
Loading

0 comments on commit 4844b7d

Please sign in to comment.