Skip to content

Commit

Permalink
Initial support for appearances, body classes (#127)
Browse files Browse the repository at this point in the history
* Normalize repeats by *unwrapping* <group ref><repeat nodeset> pairs

Note that this introduces an in-memory `<label form-definition-source=“repeat-group”>` to distinguish outer/inner labels for such structures.

Example: for this form definition structure…

```xml
<group ref="/root/rep">
  <label>Repeat/group label</label>
  <repeat nodeset="/root/rep">
    <label>Repeat label</label>
  </repeat>
</group>
```

… this would be the normalized structure:

```xml
<repeat nodeset="/root/rep">
  <label form-definition-source=“repeat-group”>Repeat/group label</label>
  <label>Repeat label</label>
</repeat>
```

* Eliminate concept of “repeat group” in parsed definition

Note that `RepeatGroupDefinition` previously had two responsibilities:

1. To provide access to its `repeat`
2. To provide access to a label defined in the repeat’s containing group

The first is no longer necessary because the repeat is accessed directly.

The second is accommodated by defining the `RepeatElementDefinition`’s label from the special-case `<label form-definition-source=“repeat-group”>` as produced in the previous commit.

Also note that this still does not deal with the unhandled repeat labeling engine responsibility: <repeat><group><label/></group></repeat>. A TODO is added specifically so it can be traced back to this commit, as it will likely help shorten the path to reabsorbing the pertinent code/implementation details back into active brain memory.

* Consistent repeat terminology: “sequence” -> “range”

* Initial support for appearances (and body classes)

Automated testing is pending. Some manual validation has been done to verify that this likely works as intended. The intent is to make this first pass available for client iteration as quickly as possible.

While it’s possible to include unit tests for `TokenListParser`, it seems more likely we’ll want to add integration tests in the `scenario` package. Given there’s a ton of unaddressed feedback in #110, it seems most prudent to get this into draft first, and bring in integration tests once that  lands.

* Add scenario integration tests for appearances and body classes

* Add changeset

---------

Co-authored-by: Hélène Martin <[email protected]>
  • Loading branch information
eyelidlessness and lognaturel authored Jun 6, 2024
1 parent 2d0e81d commit e7bef0c
Show file tree
Hide file tree
Showing 59 changed files with 1,560 additions and 445 deletions.
9 changes: 9 additions & 0 deletions .changeset/empty-ravens-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@getodk/xforms-engine": patch
"@getodk/web-forms": patch
"@getodk/scenario": patch
"@getodk/ui-solid": patch
"@getodk/common": patch
---

Add initial engine support for appearances
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);
};
27 changes: 27 additions & 0 deletions packages/common/types/string/PartiallyKnownString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Produces a `string` type while preserving autocomplete/autosuggest
* functionality for a known string (union).
*
* @see {@link https://www.totaltypescript.com/tips/create-autocomplete-helper-which-allows-for-arbitrary-values}
*
* @example
* ```ts
* let foo: PartiallyKnownString<'a' | 'b' | 'zed'>;
*
* // Each of these will be suggested by a TypeScript-supporting editor:
* foo = 'a';
* foo = 'b';
* foo = 'zed';
*
* // ... but any string is valid:
* foo = 'lmnop';
* ```
*/
// prettier-ignore
export type PartiallyKnownString<Known extends string> =
[string] extends [Known]
? string
: (
| Known
| (string & { /* Type hack! */ })
);
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 e7bef0c

Please sign in to comment.