Skip to content

Commit

Permalink
Add tests for validation messages
Browse files Browse the repository at this point in the history
- Form-specified constraintMsg, requiredMsg
- Default/fallback messages for both conditions, as provided by the engine
  • Loading branch information
eyelidlessness committed Jul 2, 2024
1 parent 75675dc commit 7ff0ec2
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 10 deletions.
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
72 changes: 71 additions & 1 deletion packages/scenario/src/assertion/extensions/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/asse
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 { assertString } from './shared-type-assertions.ts';
import { assertNullableString, assertString } from './shared-type-assertions.ts';

const assertComparableAnswer = instanceAssertion(ComparableAnswer);

Expand All @@ -33,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 @@ -95,6 +122,49 @@ const answerExtensions = extendExpect(expect, {
}
),

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
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');
5 changes: 2 additions & 3 deletions packages/scenario/src/jr/Scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { AnyNode, RootNode, SelectNode } from '@getodk/xforms-engine';
import type { Accessor, Setter } from 'solid-js';
import { createMemo, createSignal, runWithOwner } from 'solid-js';
import { afterEach, expect } from 'vitest';
import type { ComparableAnswer } from '../answer/ComparableAnswer.ts';
import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts';
import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts';
import { answerOf } from '../client/answerOf.ts';
Expand Down Expand Up @@ -313,7 +312,7 @@ export class Scenario {
return this.setNonTerminalEventPosition(() => index, reference);
}

private answerSelect(reference: string, ...selectionValues: string[]): ComparableAnswer {
private answerSelect(reference: string, ...selectionValues: string[]): ValueNodeAnswer {
const event = this.setPositionalStateToReference(reference);

if (!isQuestionEventOfType(event, 'select')) {
Expand All @@ -325,7 +324,7 @@ export class Scenario {
return event.answerQuestion(new SelectValuesAnswer(selectionValues));
}

answer(...args: AnswerParameters): unknown {
answer(...args: AnswerParameters): ValueNodeAnswer {
if (isAnswerSelectParams(args)) {
return this.answerSelect(...args);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/scenario/src/jr/event/SelectQuestionEvent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { xmlXPathWhitespaceSeparatedList } from '@getodk/common/lib/string/whitespace.ts';
import type { SelectNode } from '@getodk/xforms-engine';
import type { ComparableAnswer } from '../../answer/ComparableAnswer.ts';
import { SelectNodeAnswer } from '../../answer/SelectNodeAnswer.ts';
import { UntypedAnswer } from '../../answer/UntypedAnswer.ts';
import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts';
import type { Scenario } from '../Scenario.ts';
import { SelectChoice } from '../select/SelectChoice.ts';
import { QuestionEvent } from './QuestionEvent.ts';
Expand Down Expand Up @@ -41,7 +41,7 @@ export class SelectQuestionEvent extends QuestionEvent<'select'> {
* behavior! For now it's consistent with the internals (which we shouldn't
* need to know about here because {@link Scenario} is a client)
*/
answerQuestion(answerValue: unknown): ComparableAnswer {
answerQuestion(answerValue: unknown): ValueNodeAnswer {
const { node } = this;
const { stringValue } = new UntypedAnswer(answerValue);

Expand Down
4 changes: 2 additions & 2 deletions packages/scenario/src/jr/event/StringQuestionEvent.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { ComparableAnswer } from '../../answer/ComparableAnswer.ts';
import { StringNodeAnswer } from '../../answer/StringNodeAnswer.ts';
import { UntypedAnswer } from '../../answer/UntypedAnswer.ts';
import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts';
import { QuestionEvent } from './QuestionEvent.ts';

export class StringInputQuestionEvent extends QuestionEvent<'string'> {
getAnswer(): StringNodeAnswer {
return new StringNodeAnswer(this.node);
}

answerQuestion(answerValue: unknown): ComparableAnswer {
answerQuestion(answerValue: unknown): ValueNodeAnswer {
const { stringValue } = new UntypedAnswer(answerValue);

this.node.setValue(stringValue);
Expand Down
132 changes: 132 additions & 0 deletions packages/scenario/test/validity-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,135 @@ describe('`constraint`', () => {
expect(result).toHaveValidityStatus(AnswerResult.CONSTRAINT_VIOLATED);
});
});

describe('Validity messages', () => {
interface ValidationMessageOptions {
readonly constraintMsg?: string;
readonly requiredMsg?: string;
}

const initValidationFixture = async (
options: ValidationMessageOptions = {}
): Promise<Scenario> => {
const { constraintMsg, requiredMsg } = options;

let bindConstrainedInput = bind('/data/constrained-input').constraint("regex(.,'[0-9]{10}')");

if (constraintMsg != null) {
bindConstrainedInput = bindConstrainedInput.withAttribute(
'jr',
'constraintMsg',
constraintMsg
);
}

let bindRequiredInput = bind('/data/required-input').required();

if (requiredMsg != null) {
bindRequiredInput = bindRequiredInput.withAttribute('jr', 'requiredMsg', requiredMsg);
}

return Scenario.init(
'Validation fixture',
html(
head(
title('Validation fixture'),
model(
mainInstance(
t('data id="validation-fixture"', t('constrained-input'), t('required-input'))
),
bindConstrainedInput,
bindRequiredInput
)
),
body(input('/data/constrained-input'), input('/data/required-input'))
)
);
};

it('provides a form-defined message on constraint validation failure', async () => {
const constraintMsg = 'Must be ten digits';
const scenario = await initValidationFixture({ constraintMsg });

let result = scenario.answer('/data/constrained-input', '00000');

expect(result).toHaveConstraintMessage(constraintMsg);
expect(result).toHaveRequiredMessage(null);
expect(result).toHaveValidityMessage(constraintMsg);

result = scenario.answer('/data/constrained-input', '0000000000');

expect(result).toHaveConstraintMessage(null);
expect(result).toHaveRequiredMessage(null);
expect(result).toHaveValidityMessage(null);

result = scenario.answer('/data/constrained-input', '00000');

expect(result).toHaveConstraintMessage(constraintMsg);
expect(result).toHaveRequiredMessage(null);
expect(result).toHaveValidityMessage(constraintMsg);
});

it('provides an engine-defined message on constraint validation failure', async () => {
const scenario = await initValidationFixture();

let result = scenario.answer('/data/constrained-input', '00000');

expect(result).toHaveDefaultConstraintMessage();
expect(result).toHaveRequiredMessage(null);

result = scenario.answer('/data/constrained-input', '0000000000');

expect(result).toHaveConstraintMessage(null);
expect(result).toHaveRequiredMessage(null);
expect(result).toHaveValidityMessage(null);

result = scenario.answer('/data/constrained-input', '00000');

expect(result).toHaveDefaultConstraintMessage();
expect(result).toHaveRequiredMessage(null);
});

it('provides a form-defined message on required validation failure', async () => {
const requiredMsg = 'Must provide an answer!!';
const scenario = await initValidationFixture({ requiredMsg });

let result = scenario.answerOf('/data/required-input');

expect(result).toHaveConstraintMessage(null);
expect(result).toHaveRequiredMessage(requiredMsg);
expect(result).toHaveValidityMessage(requiredMsg);

result = scenario.answer('/data/required-input', '0000000000');

expect(result).toHaveConstraintMessage(null);
expect(result).toHaveRequiredMessage(null);
expect(result).toHaveValidityMessage(null);

result = scenario.answer('/data/required-input', '');

expect(result).toHaveConstraintMessage(null);
expect(result).toHaveRequiredMessage(requiredMsg);
expect(result).toHaveValidityMessage(requiredMsg);
});

it('provides an engine-defined message on required validation failure', async () => {
const scenario = await initValidationFixture();

let result = scenario.answerOf('/data/required-input');

expect(result).toHaveDefaultRequiredMessage();
expect(result).toHaveConstraintMessage(null);

result = scenario.answer('/data/required-input', '0000000000');

expect(result).toHaveConstraintMessage(null);
expect(result).toHaveRequiredMessage(null);
expect(result).toHaveValidityMessage(null);

result = scenario.answer('/data/required-input', '');

expect(result).toHaveDefaultRequiredMessage();
expect(result).toHaveConstraintMessage(null);
});
});

0 comments on commit 7ff0ec2

Please sign in to comment.