Skip to content

Commit

Permalink
Merge pull request #154 from getodk/features/constraint-required-vali…
Browse files Browse the repository at this point in the history
…dation

Engine support for `constraint`, `required` validation
  • Loading branch information
eyelidlessness authored Jul 12, 2024
2 parents d1d31ea + 200c222 commit 9e64b5d
Show file tree
Hide file tree
Showing 83 changed files with 2,022 additions and 621 deletions.
8 changes: 8 additions & 0 deletions .changeset/tasty-hornets-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@getodk/common": minor
"@getodk/scenario": minor
"@getodk/web-forms": minor
"@getodk/xforms-engine": minor
---

Engine support for `constraint`, `required` validation
1 change: 1 addition & 0 deletions packages/common/src/test/assertions/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { instanceArrayAssertion } from './instanceArrayAssertion.ts';
export { instanceAssertion } from './instanceAssertion.ts';
export { typeofAssertion } from './typeofAssertion.ts';
export { ArbitraryConditionExpectExtension } from './vitest/ArbitraryConditionExpectExtension.ts';
export { AsymmetricTypedExpectExtension } from './vitest/AsymmetricTypedExpectExtension.ts';
export { InspectableComparisonError } from './vitest/InspectableComparisonError.ts';
export { StaticConditionExpectExtension } from './vitest/StaticConditionExpectExtension.ts';
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/test/assertions/typeofAssertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ interface TypeofTypes {

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { SyncExpectationResult } from 'vitest';
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';

export class ArbitraryConditionExpectExtension<Parameter> {
readonly extensionMethod: ExpectExtensionMethod<unknown, unknown, SyncExpectationResult>;

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

this.extensionMethod = expandSimpleExpectExtensionResult(validatedMethod);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SyncExpectationResult } from 'vitest';
import type { JSONValue } from '../../../../types/JSONValue.ts';
import type { Primitive } from '../../../../types/Primitive.ts';
import type { ArbitraryConditionExpectExtension } from './ArbitraryConditionExpectExtension.ts';
import type { AsymmetricTypedExpectExtension } from './AsymmetricTypedExpectExtension.ts';
import type { StaticConditionExpectExtension } from './StaticConditionExpectExtension.ts';
import type { SymmetricTypedExpectExtension } from './SymmetricTypedExpectExtension.ts';
Expand Down Expand Up @@ -67,7 +68,10 @@ export type DeriveStaticVitestExpectExtension<
> = {
[K in keyof Implementation]:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Implementation[K] extends StaticConditionExpectExtension<any, any>
Implementation[K] extends ArbitraryConditionExpectExtension<any>
? () => VitestParameterizedReturn
// eslint-disable-next-line @typescript-eslint/no-explicit-any
: Implementation[K] extends StaticConditionExpectExtension<any, any>
? () => VitestParameterizedReturn
// eslint-disable-next-line @typescript-eslint/no-explicit-any
: Implementation[K] extends TypedExpectExtension<any, infer Expected>
Expand Down
83 changes: 83 additions & 0 deletions packages/scenario/resources/ImageSelectTester-alt.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<h:html xmlns:h="http://www.w3.org/1999/xhtml"
xmlns="http://www.w3.org/2002/xforms"
xmlns:ev="http://www.w3.org/2001/xml-events"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa">
<h:head>
<h:title>RichMedia testing Images</h:title>
<meta jr:name="rm-subforms-test-images"/>
<model>
<instance>
<icons id="rm-subforms-test-images">
<id />
<name />
<find-mirc />
<non-local />
<consTest />
</icons>
</instance>

<bind nodeset="/icons/name" required="true()" />
<bind nodeset="/icons/find-mirc" required="true()" />
<bind nodeset="/icons/consTest" type="xsd:int" constraint=". > 10" />

<itext>
<translation lang="English" default="">
<text id="id">
<value form="long">Patient ID</value>
<value form="short">ID</value>
<value form="audio">jr://audio/hah.mp3</value>
</text>
<text id="name">
<value>Full Name</value>
<value form="short">Name</value>
<value form="image">jr://images/four.gif</value>
</text>
<text id="find-mirc">
<value form="long">Please find the mirc icon</value>
<value form="short">MircIcon</value>
</text>
<text id="pandora">
<value form="image">jr://images/four.gif</value>
<value form="long">Icon 4</value>
<value form="short">AltText</value>
</text>
<text id="mirc">
<value form="image">jr://images/three.gif</value>
<value form="long">Icon 3</value>
<value form="short">AltText</value>
</text>
<text id="gmail">
<value form="image">jr://images/two.gif</value>
<value form="long">Icon 2</value>
<value form="short">AltText</value>
</text>
<text id="powerpoint">
<value form="image">jr://images/one.gif</value>
<value form="long">Icon 1</value>
<value form="short">AltText</value>
</text>
<text id="constraint-test">
<value>Should Be Less than 10</value>
</text>
</translation>

</itext>

</model>
</h:head>
<h:body>
<input ref="/icons/id"><label ref="jr:itext('id')" /></input>
<input ref="/icons/name"><label ref="jr:itext('name')" /></input>
<select1 ref="/icons/find-mirc">
<label ref="jr:itext('find-mirc')" />
<item><label ref="jr:itext('pandora')"/><value>pand</value></item>
<item><label ref="jr:itext('mirc')" /><value>mirc</value></item>
<item><label ref="jr:itext('powerpoint')" /><value>powerp</value></item>
<item><label ref="jr:itext('gmail')" /><value>gmail</value></item>
<item><label>Non-localized select text item label</label><value>other</value></item>
</select1>
<input ref="/icons/non-local"><label>Non-Localized label inner text!</label></input>
<input ref="/icons/consTest"><label ref="jr:itext('constraint-test')" /></input>
</h:body>
</h:html>
2 changes: 2 additions & 0 deletions packages/scenario/src/answer/ValueNodeAnswer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { AnyLeafNode as ValueNode } from '@getodk/xforms-engine';
import { ComparableAnswer } from './ComparableAnswer.ts';

export type { ValueNode };

export abstract class ValueNodeAnswer<Node extends ValueNode = ValueNode> extends ComparableAnswer {
constructor(readonly node: Node) {
super();
Expand Down
104 changes: 99 additions & 5 deletions packages/scenario/src/assertion/extensions/answers.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts';
import {
AsymmetricTypedExpectExtension,
InspectableComparisonError,
StaticConditionExpectExtension,
SymmetricTypedExpectExtension,
extendExpect,
instanceAssertion,
} from '@getodk/common/test/assertions/helpers.ts';
import { constants, type ValidationCondition } from '@getodk/xforms-engine';
import { expect } from 'vitest';
import { ComparableAnswer } from '../../answer/ComparableAnswer.ts';
import { ExpectedApproximateUOMAnswer } from '../../answer/ExpectedApproximateUOMAnswer.ts';
import type { ValueNode } from '../../answer/ValueNodeAnswer.ts';
import { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts';
import { AnswerResult } from '../../jr/Scenario.ts';
import { ValidationImplementationPendingError } from '../../jr/validation/ValidationImplementationPendingError.ts';
import { assertString } from './shared-type-assertions.ts';
import { assertNullableString, assertString } from './shared-type-assertions.ts';

const assertComparableAnswer = instanceAssertion(ComparableAnswer);

const assertValueNodeAnswer = instanceAssertion<ValueNodeAnswer<ValueNode>>(ValueNodeAnswer);

const assertExpectedApproximateUOMAnswer = instanceAssertion(ExpectedApproximateUOMAnswer);

type AssertAnswerResult = (value: unknown) => asserts value is AnswerResult;
Expand All @@ -29,6 +35,31 @@ const assertAnswerResult: AssertAnswerResult = (value) => {
}
};

const matchDefaultMessage = (condition: ValidationCondition) => {
const expectedMessage = constants.VALIDATION_TEXT[`${condition}Msg`];

return {
node: {
validationState: {
[condition]: {
valid: false,
message: {
origin: 'engine',
asString: expectedMessage,
},
},
violation: {
condition,
message: {
origin: 'engine',
asString: expectedMessage,
},
},
},
},
};
};

const answerExtensions = extendExpect(expect, {
toEqualAnswer: new SymmetricTypedExpectExtension(assertComparableAnswer, (actual, expected) => {
const pass = actual.stringValue === expected.stringValue;
Expand Down Expand Up @@ -64,13 +95,76 @@ const answerExtensions = extendExpect(expect, {
* the spec.
*/
toHaveValidityStatus: new AsymmetricTypedExpectExtension(
assertComparableAnswer,
assertValueNodeAnswer,
assertAnswerResult,
(_actual, _expected) => {
return new ValidationImplementationPendingError();
(actual, expected) => {
const { condition } = actual.node.validationState.violation ?? {};
let pass: boolean;

switch (expected) {
case AnswerResult.CONSTRAINT_VIOLATED:
pass = condition === 'constraint';
break;

case AnswerResult.REQUIRED_BUT_EMPTY:
pass = condition === 'required';
break;

case AnswerResult.OK:
pass = condition == null;
break;

default:
return new UnreachableError(expected);
}

return pass || new InspectableComparisonError(actual, expected, 'be');
}
),

toHaveConstraintMessage: new AsymmetricTypedExpectExtension(
assertValueNodeAnswer,
assertNullableString,
(actual, expected) => {
const { asString = null } = actual.node.validationState.constraint?.message ?? {};
const pass = asString === expected;

return pass || new InspectableComparisonError(asString, expected, 'to be message');
}
),

toHaveRequiredMessage: new AsymmetricTypedExpectExtension(
assertValueNodeAnswer,
assertNullableString,
(actual, expected) => {
const { asString = null } = actual.node.validationState.required?.message ?? {};
const pass = asString === expected;

return pass || new InspectableComparisonError(asString, expected, 'to be message');
}
),

toHaveValidityMessage: new AsymmetricTypedExpectExtension(
assertValueNodeAnswer,
assertNullableString,
(actual, expected) => {
const { asString = null } = actual.node.validationState.violation?.message ?? {};
const pass = asString === expected;

return pass || new InspectableComparisonError(asString, expected, 'to be message');
}
),

toHaveDefaultConstraintMessage: new StaticConditionExpectExtension(
assertValueNodeAnswer,
matchDefaultMessage('constraint')
),

toHaveDefaultRequiredMessage: new StaticConditionExpectExtension(
assertValueNodeAnswer,
matchDefaultMessage('required')
),

/**
* Asserts that the `actual` {@link ComparableAnswer} has a string value which
* starts with the `expected` string.
Expand Down
15 changes: 7 additions & 8 deletions packages/scenario/src/assertion/extensions/form-state.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { assertInstanceType } from '@getodk/common/lib/runtime-types/instance-predicates.ts';
import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts';
import {
ArbitraryConditionExpectExtension,
extendExpect,
StaticConditionExpectExtension,
} from '@getodk/common/test/assertions/helpers.ts';
import { expect } from 'vitest';
import { ConstraintImplementationPendingError } from '../../error/ConstraintImplementationPendingError.ts';
import { JRFormDef } from '../../jr/form/JRFormDef.ts';

type AssertJRFormDef = (value: unknown) => asserts value is JRFormDef;
Expand All @@ -31,12 +30,12 @@ const formStateExtensions = extendExpect(expect, {
* ])
* ```
*/
toBeValid: new StaticConditionExpectExtension(assertJRFormDef, {
currentState: {
get valid(): boolean {
throw new ConstraintImplementationPendingError();
},
},
toBeValid: new ArbitraryConditionExpectExtension(assertJRFormDef, (actual) => {
if (actual.scenario.instanceRoot.validationState.violations.length) {
return new Error();
}

return true;
}),
});

Expand Down
7 changes: 7 additions & 0 deletions packages/scenario/src/assertion/extensions/node-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ const nodeStateExtensions = extendExpect(expect, {
currentState: { relevant: false },
}),

toBeRequired: new StaticConditionExpectExtension(assertEngineNode, {
currentState: { required: true },
}),
toBeOptional: new StaticConditionExpectExtension(assertEngineNode, {
currentState: { required: false },
}),

/**
* **PORTING NOTES**
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts';
import { arrayOfAssertion } from '@getodk/common/test/assertions/arrayOfAssertion.ts';
import type { TypeofAssertion } from '@getodk/common/test/assertions/typeofAssertion.ts';
import { typeofAssertion } from '@getodk/common/test/assertions/typeofAssertion.ts';
import type { AnyNode, RootNode } from '@getodk/xforms-engine';

Expand Down Expand Up @@ -50,6 +51,14 @@ export const assertEngineNode: AssertEngineNode = (node) => {
}
};

export const assertString = typeofAssertion('string');
export const assertString: TypeofAssertion<'string'> = typeofAssertion('string');

type AssertNullableString = (value: unknown) => asserts value is string | null | undefined;

export const assertNullableString: AssertNullableString = (value) => {
if (value != null) {
assertString(value);
}
};

export const assertArrayOfStrings = arrayOfAssertion(assertString, 'string');

This file was deleted.

Loading

0 comments on commit 9e64b5d

Please sign in to comment.