Skip to content

Commit

Permalink
Merge pull request #216 from getodk/features/engine/trigger
Browse files Browse the repository at this point in the history
Engine support for `<trigger>`
  • Loading branch information
sadiqkhoja committed Sep 19, 2024
2 parents 294e48a + 6fb1054 commit 17e5b25
Show file tree
Hide file tree
Showing 23 changed files with 502 additions and 41 deletions.
7 changes: 7 additions & 0 deletions .changeset/mean-socks-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@getodk/scenario": minor
"@getodk/web-forms": minor
"@getodk/xforms-engine": minor
---

Engine support for `<trigger>`
22 changes: 21 additions & 1 deletion packages/scenario/src/answer/ComparableAnswer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
import type { JSONValue } from '@getodk/common/types/JSONValue.ts';
import type { Scenario } from '../jr/Scenario.ts';

interface OptionalBooleanComparable {
// Expressed here so it can be overridden as either a `readonly` property or
// as a `get` accessor
readonly booleanValue?: boolean;
}

/**
* Provides a common interface for comparing "answer" values of arbitrary data
* types, where the answer may be obtained from:
Expand All @@ -14,9 +21,19 @@ import type { Scenario } from '../jr/Scenario.ts';
* {@link https://vitest.dev/guide/extending-matchers.html | extended}
* assertions/matchers.
*/
export abstract class ComparableAnswer {
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue
export abstract class ComparableAnswer implements OptionalBooleanComparable {
abstract get stringValue(): string;

// To be overridden
equals(
// @ts-expect-error -- part of the interface to be overridden
// eslint-disable-next-line @typescript-eslint/no-unused-vars
answer: ComparableAnswer
): SimpleAssertionResult | null {
return null;
}

/**
* Note: we currently return {@link stringValue} here, but this probably
* won't last as we expand support for other data types. This is why the
Expand All @@ -34,3 +51,6 @@ export abstract class ComparableAnswer {
return this.stringValue;
}
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue
export interface ComparableAnswer extends OptionalBooleanComparable {}
16 changes: 15 additions & 1 deletion packages/scenario/src/answer/ExpectedBooleanAnswer.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { InspectableComparisonError } from '@getodk/common/test/assertions/helpers.ts';
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
import { ComparableAnswer } from './ComparableAnswer.ts';

export class ExpectedBooleanAnswer extends ComparableAnswer {
readonly stringValue: string;

constructor(booleanValue: boolean) {
constructor(override readonly booleanValue: boolean) {
super();

/**
* @todo Consistency of boolean serialization.
*/
this.stringValue = booleanValue ? '1' : '0';
}

override equals(actual: ComparableAnswer): SimpleAssertionResult | null {
const { booleanValue: actualBooleanValue } = actual;

if (typeof actualBooleanValue === 'boolean') {
const pass = this.booleanValue === actualBooleanValue;

return pass || new InspectableComparisonError(actual, this, 'equal');
}

return null;
}
}

export const booleanAnswer = (booleanValue: boolean): ExpectedBooleanAnswer => {
Expand Down
41 changes: 41 additions & 0 deletions packages/scenario/src/answer/TriggerNodeAnswer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { InspectableComparisonError } from '@getodk/common/test/assertions/helpers.ts';
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
import type { TriggerNode } from '@getodk/xforms-engine';
import type { ComparableAnswer } from './ComparableAnswer.ts';
import { ValueNodeAnswer } from './ValueNodeAnswer.ts';

class TriggerNodeAnswerComparisonError extends Error {
constructor() {
super('Trigger value should be compared with/asserted as a boolean');
}
}

export class TriggerNodeAnswer extends ValueNodeAnswer<TriggerNode> {
private readonly triggerValue: boolean;

override get booleanValue(): boolean {
return this.triggerValue;
}

constructor(node: TriggerNode) {
super(node);

this.triggerValue = node.currentState.value;
}

override equals(expected: ComparableAnswer): SimpleAssertionResult | null {
const { booleanValue } = expected;

if (booleanValue == null) {
throw new TriggerNodeAnswerComparisonError();
}

const pass = this.booleanValue === booleanValue;

return pass || new InspectableComparisonError(expected, this, 'equal');
}

get stringValue(): string {
throw new TriggerNodeAnswerComparisonError();
}
}
10 changes: 10 additions & 0 deletions packages/scenario/src/answer/UntypedAnswer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ export class UntypedAnswer extends ComparableAnswer {
super();
}

override get booleanValue(): boolean {
const { unknownValue } = this;

if (typeof unknownValue === 'boolean') {
return unknownValue;
}

throw new Error(`Conversion of type ${typeof unknownValue} to boolean not currently supported`);
}

get stringValue(): string {
const { unknownValue } = this;

Expand Down
19 changes: 17 additions & 2 deletions packages/scenario/src/assertion/extensions/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
extendExpect,
instanceAssertion,
} from '@getodk/common/test/assertions/helpers.ts';
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
import { constants, type ValidationCondition } from '@getodk/xforms-engine';
import { expect } from 'vitest';
import { ComparableAnswer } from '../../answer/ComparableAnswer.ts';
Expand Down Expand Up @@ -62,9 +63,23 @@ const matchDefaultMessage = (condition: ValidationCondition) => {

export const answerExtensions = extendExpect(expect, {
toEqualAnswer: new SymmetricTypedExpectExtension(assertComparableAnswer, (actual, expected) => {
const pass = actual.stringValue === expected.stringValue;
let result: SimpleAssertionResult | null = null;

return pass || new InspectableComparisonError(actual, expected, 'equal');
if (typeof expected.equals === 'function') {
result = expected.equals(actual);
}

if (result == null && typeof actual.equals === 'function') {
result = actual.equals(expected);
}

if (result == null) {
const pass = actual.stringValue === expected.stringValue;

result = pass || new InspectableComparisonError(actual, expected, 'equal');
}

return result;
}),

toHaveAnswerCloseTo: new AsymmetricTypedExpectExtension(
Expand Down
9 changes: 8 additions & 1 deletion packages/scenario/src/client/answerOf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import type { AnyNode, RootNode } from '@getodk/xforms-engine';
import { ModelValueNodeAnswer } from '../answer/ModelValueNodeAnswer.ts.ts';
import { SelectNodeAnswer } from '../answer/SelectNodeAnswer.ts';
import { StringNodeAnswer } from '../answer/StringNodeAnswer.ts';
import { TriggerNodeAnswer } from '../answer/TriggerNodeAnswer.ts';
import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts';
import { getNodeForReference } from './traversal.ts';

const isValueNode = (node: AnyNode) => {
return (
node.nodeType === 'model-value' || node.nodeType === 'select' || node.nodeType === 'string'
node.nodeType === 'model-value' ||
node.nodeType === 'select' ||
node.nodeType === 'string' ||
node.nodeType === 'trigger'
);
};

Expand All @@ -29,6 +33,9 @@ export const answerOf = (instanceRoot: RootNode, reference: string): ValueNodeAn
case 'string':
return new StringNodeAnswer(node);

case 'trigger':
return new TriggerNodeAnswer(node);

default:
throw new UnreachableError(node);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/scenario/src/jr/event/PositionalEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
RootNode,
SelectNode,
StringNode,
TriggerNode,
} from '@getodk/xforms-engine';
import type { Scenario } from '../Scenario.ts';

Expand All @@ -17,6 +18,7 @@ export type QuestionPositionalEventNode =
| NoteNode
| SelectNode
| StringNode
| TriggerNode
| AnyUnsupportedControlNode;

export interface PositionalEventTypeMapping {
Expand Down
18 changes: 18 additions & 0 deletions packages/scenario/src/jr/event/TriggerQuestionEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { TriggerNodeAnswer } from '../../answer/TriggerNodeAnswer.ts';
import { UntypedAnswer } from '../../answer/UntypedAnswer.ts';
import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts';
import { QuestionEvent } from './QuestionEvent.ts';

export class TriggerQuestionEvent extends QuestionEvent<'trigger'> {
getAnswer(): TriggerNodeAnswer {
return new TriggerNodeAnswer(this.node);
}

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

this.node.setValue(booleanValue);

return new TriggerNodeAnswer(this.node);
}
}
6 changes: 5 additions & 1 deletion packages/scenario/src/jr/event/getPositionalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import { PromptNewRepeatEvent } from './PromptNewRepeatEvent.ts';
import { RepeatInstanceEvent } from './RepeatInstanceEvent.ts';
import { SelectQuestionEvent } from './SelectQuestionEvent.ts';
import { StringInputQuestionEvent } from './StringInputQuestionEvent.ts';
import { TriggerQuestionEvent } from './TriggerQuestionEvent.ts';
import { UnsupportedControlQuestionEvent } from './UnsupportedControlQuestionEvent.ts';

// prettier-ignore
export type AnyQuestionEvent =
| NoteQuestionEvent
| SelectQuestionEvent
| StringInputQuestionEvent
| TriggerQuestionEvent
| UnsupportedControlQuestionEvent;

// prettier-ignore
Expand Down Expand Up @@ -81,9 +83,11 @@ export const getPositionalEvents = (instanceRoot: RootNode): PositionalEvents =>
case 'string':
return StringInputQuestionEvent.from(node);

case 'trigger':
return TriggerQuestionEvent.from(node);

case 'range':
case 'rank':
case 'trigger':
case 'upload':
return UnsupportedControlQuestionEvent.from(node);

Expand Down
86 changes: 86 additions & 0 deletions packages/scenario/test/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
body,
head,
html,
label,
mainInstance,
model,
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
import { describe, expect, it } from 'vitest';
import { booleanAnswer } from '../src/answer/ExpectedBooleanAnswer.ts';
import { Scenario } from '../src/jr/Scenario.ts';

describe('<trigger>', () => {
type TriggerValue = '' | 'OK';

interface TriggerCase {
readonly initialFormDefinitionValue: TriggerValue;
readonly expectedInitialValue: boolean;
readonly assignState: boolean;
readonly expectedAssignedValue: boolean;
}

it.each<TriggerCase>([
{
initialFormDefinitionValue: '',
expectedInitialValue: false,
assignState: true,
expectedAssignedValue: true,
},
{
initialFormDefinitionValue: 'OK',
expectedInitialValue: true,
assignState: false,
expectedAssignedValue: false,
},
{
initialFormDefinitionValue: 'OK',
expectedInitialValue: true,
assignState: true,
expectedAssignedValue: true,
},
{
initialFormDefinitionValue: '',
expectedInitialValue: false,
assignState: false,
expectedAssignedValue: false,
},
])(
'supports ODK XForms trigger semantics (initial state: $initialFormDefinitionValue; expected initial: $expectedInitialValue; assign state: $assignState; expected assigned: $expectedAssignedValue)',
async ({
initialFormDefinitionValue,
expectedInitialValue,
assignState,
expectedAssignedValue,
}) => {
const scenario = await Scenario.init(
'Form with trigger',
// prettier-ignore
html(
head(
title('Form with trigger'),
model(
mainInstance(t("data id='multilingual-select'",
t('maybe-ok', initialFormDefinitionValue))))
),
body(
t('trigger ref="/data/maybe-ok"',
label('Maybe OK'))
)
)
);

expect(scenario.answerOf('/data/maybe-ok')).toEqualAnswer(
booleanAnswer(expectedInitialValue)
);

scenario.answer('/data/maybe-ok', assignState);

expect(scenario.answerOf('/data/maybe-ok')).toEqualAnswer(
booleanAnswer(expectedAssignedValue)
);
}
);
});
4 changes: 4 additions & 0 deletions packages/web-forms/src/components/FormQuestion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { inject } from 'vue';
import InputText from './controls/InputText.vue';
import NoteControl from './controls/NoteControl.vue';
import SelectControl from './controls/SelectControl.vue';
import TriggerControl from './controls/TriggerControl.vue';
import UnsupportedControl from './controls/UnsupportedControl.vue';
type ControlNode = AnyControlNode | AnyUnsupportedControlNode;
Expand All @@ -19,6 +20,7 @@ defineProps<{ question: ControlNode }>();
const isStringNode = (n: ControlNode): n is StringNode => n.nodeType === 'string';
const isSelectNode = (n: ControlNode): n is SelectNode => n.nodeType === 'select';
const isNoteNode = (n: ControlNode): n is NoteNode => n.nodeType === 'note';
const isTriggerNode = (node: ControlNode) => node.nodeType === 'trigger';
const submitPressed = inject('submitPressed');
</script>
Expand All @@ -37,6 +39,8 @@ const submitPressed = inject('submitPressed');
<NoteControl v-else-if="isNoteNode(question)" :question="question" />
<TriggerControl v-else-if="isTriggerNode(question)" :question="question" />
<UnsupportedControl v-else :question="question" />
</div>
</template>
Expand Down
Loading

0 comments on commit 17e5b25

Please sign in to comment.