Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Engine support for constraint, required validation #154

Merged
merged 18 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9ff8fda
Initial engine/client API for `constraint`/`required` validation
eyelidlessness Jul 1, 2024
beb4d38
Expand text definitions to support constraintMsg, requiredMsg
eyelidlessness Jul 1, 2024
96a9921
Parse constraintMsg/requiredMsg definitions
eyelidlessness Jun 30, 2024
e94c389
Update existing text reactivity
eyelidlessness Jul 1, 2024
cf34554
Initial constraint/required implementation (value nodes)
eyelidlessness Jul 1, 2024
5cbfb62
Initial aggregated violations implementation (ancestor nodes)
eyelidlessness Jul 1, 2024
ac20ff5
Fix: special case casting of boolean expressions which evaluate to si…
eyelidlessness Jul 1, 2024
9b9efc5
Update `scenario` validation tests
eyelidlessness Jul 1, 2024
a5f33b2
Update other tests now passing with boolean cast fix
eyelidlessness Jul 1, 2024
379b339
“Fix” misused repeat label definition
eyelidlessness Jul 1, 2024
d583b9f
Add tests for validation messages
eyelidlessness Jul 2, 2024
573b06b
Add changeset for `constraint` and `required` validation
eyelidlessness Jul 9, 2024
9a6fa61
Fix (quick and dirty): client reactivity of parent node validation state
eyelidlessness Jul 9, 2024
d1acaa6
Roll back boolean casting fix
eyelidlessness Jul 9, 2024
460f68a
Remove `node` object from aggregated validation state
eyelidlessness Jul 10, 2024
23171c7
Scenario: Improve `getValidationOutcome` error message, remove outdat…
eyelidlessness Jul 10, 2024
a385483
Scenario: add test for skipping validation of non-relevant nodes (at …
eyelidlessness Jul 10, 2024
200c222
Fix (engine): skip validation of non-relevant nodes
eyelidlessness Jul 10, 2024
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
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