diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8d3c02cb..7108ab30d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -432,71 +432,6 @@ jobs:
- run: 'yarn workspace @getodk/tree-sitter-xpath test'
- ui-solid:
- name: '@getodk/ui-solid'
- needs: ['install-and-build']
- runs-on: 'ubuntu-latest'
-
- strategy:
- matrix:
- target: ['Node']
- node-version: ['18.20.4', '20.17.0', '22.9.0']
- include:
- - target: 'Web'
- node-version: '22.9.0'
- browser: chromium
- - target: 'Web'
- node-version: '22.9.0'
- browser: firefox
- - target: 'Web'
- node-version: '22.9.0'
- browser: webkit
-
- steps:
- - uses: 'actions/checkout@v4'
-
- - uses: 'volta-cli/action@v4'
- with:
- node-version: '${{ matrix.node-version }}'
- yarn-version: '1.22.22'
-
- - uses: 'actions/cache@v4'
- id: cache-install
- with:
- path: |
- node_modules
- **/node_modules
- key: install-${{ matrix.node-version }}-${{ hashFiles('yarn.lock', '.github/workflows/ci.yml', 'examples/*/yarn.lock', 'packages/*/package.json', 'packages/*/yarn.lock') }}
- fail-on-cache-miss: true
-
- - uses: 'actions/cache@v4'
- id: cache-build
- with:
- path: |
- examples/*/dist
- packages/*/dist
- packages/web-forms/dist-demo
- packages/tree-sitter-xpath/grammar.js
- packages/tree-sitter-xpath/src/grammar.json
- packages/tree-sitter-xpath/src/parser.c
- packages/tree-sitter-xpath/src/tree_sitter/parser.h
- packages/tree-sitter-xpath/tree-sitter-xpath.wasm
- packages/tree-sitter-xpath/types
- key: build-${{ matrix.node-version }}-${{ github.sha }}
- fail-on-cache-miss: true
-
- - if: ${{ matrix.target == 'Node' }}
- run: 'yarn workspace @getodk/ui-solid test:types'
-
- - if: ${{ matrix.target == 'Node' }}
- run: 'yarn workspace @getodk/ui-solid test-node:jsdom'
-
- - if: ${{ matrix.target == 'Web' }}
- run: 'yarn playwright install ${{ matrix.browser }} --with-deps'
-
- - if: ${{ matrix.target == 'Web' }}
- run: 'yarn workspace @getodk/ui-solid test-browser:${{ matrix.browser }}'
-
web-forms:
name: '@getodk/web-forms'
needs: ['install-and-build']
diff --git a/eslint.config.js b/eslint.config.js
index cae01e661..0a6a2600b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -82,6 +82,7 @@ export default tseslint.config(
'packages/tree-sitter-xpath/grammar.js',
'packages/tree-sitter-xpath/bindings/**/*',
'packages/tree-sitter-xpath/types/**/*',
+ 'packages/ui-solid/**/*',
'packages/web-forms/dist-demo/**/*',
'packages/xforms-engine/api-docs/**/*',
'**/vendor',
diff --git a/package.json b/package.json
index 312537995..65ed897c7 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@types/eslint": "^9.6.1",
"@types/eslint-config-prettier": "^6.11.3",
"@types/eslint__js": "^8.42.3",
+ "@types/geojson": "^7946.0.14",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.7.2",
"@typescript-eslint/eslint-plugin": "^8.7.0",
diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md
index 283eefb4d..acfc3d32d 100644
--- a/packages/common/CHANGELOG.md
+++ b/packages/common/CHANGELOG.md
@@ -1,5 +1,16 @@
# @getodk/common
+## 0.5.0
+
+### Minor Changes
+
+- 2fddcec: Support for external secondary instances (XML, CSV, GeoJSON)
+
+### Patch Changes
+
+- e636a9c: XPath support for evaluation of arbitrary DOM implementations (XPathDOMAdapter)
+- 8edf375: Initial engine support for preparing submissions
+
## 0.4.0
### Minor Changes
diff --git a/packages/common/package.json b/packages/common/package.json
index be3650fbf..ca7fd013e 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -1,7 +1,7 @@
{
"name": "@getodk/common",
"private": true,
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "@getodk/common",
"type": "module",
"author": "getodk",
diff --git a/packages/common/src/constants/xmlns.ts b/packages/common/src/constants/xmlns.ts
index 1d74e8756..2873f09a1 100644
--- a/packages/common/src/constants/xmlns.ts
+++ b/packages/common/src/constants/xmlns.ts
@@ -1,31 +1,66 @@
// Native/common standards
export const XHTML_NAMESPACE_URI = 'http://www.w3.org/1999/xhtml';
+export type XHTML_NAMESPACE_URI = typeof XHTML_NAMESPACE_URI;
+
export const HTML_NAMESPACE_URI = XHTML_NAMESPACE_URI;
+export type HTML_NAMESPACE_URI = typeof HTML_NAMESPACE_URI;
+
export const XML_NAMESPACE_URI = 'http://www.w3.org/XML/1998/namespace';
+export type XML_NAMESPACE_URI = typeof XML_NAMESPACE_URI;
+
export const XMLNS_NAMESPACE_URI = 'http://www.w3.org/2000/xmlns/';
+export type XMLNS_NAMESPACE_URI = typeof XMLNS_NAMESPACE_URI;
+
+export const XSD_NAMESPACE_URI = 'http://www.w3.org/2001/XMLSchema';
+export type XSD_NAMESPACE_URI = typeof XSD_NAMESPACE_URI;
+
export const FN_NAMESPACE_URI = 'http://www.w3.org/2005/xpath-functions';
+export type FN_NAMESPACE_URI = typeof FN_NAMESPACE_URI;
// XForms/ODK
export const JAVAROSA_NAMESPACE_URI = 'http://openrosa.org/javarosa';
+export type JAVAROSA_NAMESPACE_URI = typeof JAVAROSA_NAMESPACE_URI;
+
export const ODK_NAMESPACE_URI = 'http://www.opendatakit.org/xforms';
+export type ODK_NAMESPACE_URI = typeof ODK_NAMESPACE_URI;
+
export const OPENROSA_XFORMS_NAMESPACE_URI = 'http://openrosa.org/xforms';
-export const XFORMS_NAMESPACE_URI = 'http://www.w3.org/2002/xforms';
+export type OPENROSA_XFORMS_NAMESPACE_URI = typeof OPENROSA_XFORMS_NAMESPACE_URI;
-export type JavaRosaNamespaceURI = typeof JAVAROSA_NAMESPACE_URI;
-export type XFormsNamespaceURI = typeof XFORMS_NAMESPACE_URI;
+export const XFORMS_NAMESPACE_URI = 'http://www.w3.org/2002/xforms';
+export type XFORMS_NAMESPACE_URI = typeof XFORMS_NAMESPACE_URI;
// Enketo
export const ENKETO_NAMESPACE_URI = 'http://enketo.org/xforms';
+export type ENKETO_NAMESPACE_URI = typeof ENKETO_NAMESPACE_URI;
// Default prefixes
export const HTML_PREFIX = 'h';
+export type HTML_PREFIX = typeof HTML_PREFIX;
+
export const XML_PREFIX = 'xml';
+export type XML_PREFIX = typeof XML_PREFIX;
+
export const XMLNS_PREFIX = 'xmlns';
+export type XMLNS_PREFIX = typeof XMLNS_PREFIX;
+
+export const XSD_PREFIX = 'xsd';
+export type XSD_PREFIX = typeof XSD_PREFIX;
+
export const FN_PREFIX = 'fn';
+export type FN_PREFIX = typeof FN_PREFIX;
export const JAVAROSA_PREFIX = 'jr';
+export type JAVAROSA_PREFIX = typeof JAVAROSA_PREFIX;
+
export const ODK_PREFIX = 'odk';
+export type ODK_PREFIX = typeof ODK_PREFIX;
+
export const OPENROSA_XFORMS_PREFIX = 'orx';
+export type OPENROSA_XFORMS_PREFIX = typeof OPENROSA_XFORMS_PREFIX;
+
export const XFORMS_PREFIX = 'xf';
+export type XFORMS_PREFIX = typeof XFORMS_PREFIX;
export const ENKETO_PREFIX = 'enk';
+export type ENKETO_PREFIX = typeof ENKETO_PREFIX;
diff --git a/packages/common/src/fixtures/import-glob-helper.ts b/packages/common/src/fixtures/import-glob-helper.ts
new file mode 100644
index 000000000..708af1426
--- /dev/null
+++ b/packages/common/src/fixtures/import-glob-helper.ts
@@ -0,0 +1,70 @@
+import { IS_NODE_RUNTIME } from '../env/detection.ts';
+
+interface GlobURLFetchResponse {
+ text(): Promise;
+}
+
+type Awaitable = Promise | T;
+
+type FetchGlobURL = (globURL: string) => Awaitable;
+
+let fetchGlobURL: FetchGlobURL;
+
+if (IS_NODE_RUNTIME) {
+ const { readFile } = await import('node:fs/promises');
+
+ class NodeGlobURLFetchResponse {
+ readonly fsPath: string;
+
+ constructor(globURL: string) {
+ this.fsPath = globURL.replace('/@fs/', '/');
+ }
+
+ text(): Promise {
+ return readFile(this.fsPath, 'utf-8');
+ }
+ }
+
+ fetchGlobURL = (globURL) => {
+ return new NodeGlobURLFetchResponse(globURL);
+ };
+} else {
+ fetchGlobURL = fetch;
+}
+
+type ImportMetaGlobURLRecord = Readonly>;
+
+export type GlobFixtureLoader = (this: void) => Promise;
+
+export interface GlobFixture {
+ readonly url: URL;
+ readonly load: GlobFixtureLoader;
+}
+
+export type GlobFixtureEntry = readonly [absolutePath: string, loader: GlobFixture];
+
+const globFixtureLoader = (globURL: string): GlobFixtureLoader => {
+ return async () => {
+ const response = await fetchGlobURL(globURL);
+
+ return response.text();
+ };
+};
+
+export const toGlobLoaderEntries = (
+ importMeta: ImportMeta,
+ globObject: ImportMetaGlobURLRecord
+): readonly GlobFixtureEntry[] => {
+ const parentPathURL = new URL('./', importMeta.url);
+
+ return Object.entries(globObject).map(([relativePath, value]) => {
+ const { pathname: absolutePath } = new URL(relativePath, parentPathURL);
+ const fixtureAssetURL = new URL(value, import.meta.url);
+ const fixture: GlobFixture = {
+ url: fixtureAssetURL,
+ load: globFixtureLoader(value),
+ };
+
+ return [absolutePath, fixture];
+ });
+};
diff --git a/packages/common/src/fixtures/test-scenario/csv-attachment.csv b/packages/common/src/fixtures/test-scenario/csv-attachment.csv
new file mode 100644
index 000000000..38d6bcd32
--- /dev/null
+++ b/packages/common/src/fixtures/test-scenario/csv-attachment.csv
@@ -0,0 +1,3 @@
+item-label,item-value
+Y,y
+Z,z
diff --git a/packages/common/src/fixtures/test-scenario/xml-attachment.xml b/packages/common/src/fixtures/test-scenario/xml-attachment.xml
new file mode 100644
index 000000000..517a46b9b
--- /dev/null
+++ b/packages/common/src/fixtures/test-scenario/xml-attachment.xml
@@ -0,0 +1,10 @@
+
+
+ A
+ a
+
+
+ B
+ b
+
+
diff --git a/packages/common/src/fixtures/value-types/numeric-value-types.xml b/packages/common/src/fixtures/value-types/numeric-value-types.xml
new file mode 100644
index 000000000..b8d7235b7
--- /dev/null
+++ b/packages/common/src/fixtures/value-types/numeric-value-types.xml
@@ -0,0 +1,69 @@
+
+
+
+ Value types (numeric)
+
+
+
+ 1
+
+ 1.5
+
+ 95
+
+
+
+
+ 9999
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/common/src/fixtures/xform-attachments.ts b/packages/common/src/fixtures/xform-attachments.ts
new file mode 100644
index 000000000..949965a3c
--- /dev/null
+++ b/packages/common/src/fixtures/xform-attachments.ts
@@ -0,0 +1,137 @@
+import { UpsertableMap } from '../lib/collections/UpsertableMap.ts';
+import { UnreachableError } from '../lib/error/UnreachableError.ts';
+import type { GlobFixtureLoader } from './import-glob-helper.ts';
+import { toGlobLoaderEntries } from './import-glob-helper.ts';
+
+/**
+ * @todo Support Windows paths?
+ */
+const getFileName = (absolutePath: string): string => {
+ const fileName = absolutePath.split('/').at(-1);
+
+ if (fileName == null) {
+ throw new Error(`Failed to get file name for file system path: ${absolutePath}`);
+ }
+
+ return fileName;
+};
+
+// prettier-ignore
+const xformAttachmentFileExtensions = [
+ '.csv',
+ '.geojson',
+ '.xml',
+ '.xml.example',
+ '.xlsx',
+] as const;
+
+type XFormAttachmentFileExtensions = typeof xformAttachmentFileExtensions;
+type XFormAttachmentFileExtension = XFormAttachmentFileExtensions[number];
+
+const getFileExtension = (absolutePath: string): XFormAttachmentFileExtension => {
+ for (const extension of xformAttachmentFileExtensions) {
+ if (absolutePath.endsWith(extension)) {
+ return extension;
+ }
+ }
+
+ throw new Error(`Unknown file extension for file name: ${getFileName(absolutePath)}`);
+};
+
+const getParentDirectory = (absolutePath: string): string => {
+ const fileName = getFileName(absolutePath);
+
+ return absolutePath.slice(0, absolutePath.length - fileName.length - 1);
+};
+
+const xformAttachmentFixtureLoaderEntries = toGlobLoaderEntries(
+ import.meta,
+ import.meta.glob('./*/**/*', {
+ query: '?url',
+ import: 'default',
+ eager: true,
+ })
+);
+
+export class XFormAttachmentFixture {
+ readonly fileName: string;
+ readonly fileExtension: string;
+ readonly mimeType: string;
+
+ constructor(
+ readonly absolutePath: string,
+ readonly load: GlobFixtureLoader
+ ) {
+ const fileName = getFileName(absolutePath);
+ const fileExtension = getFileExtension(fileName);
+
+ this.fileName = fileName;
+ this.fileExtension = fileExtension;
+
+ switch (fileExtension) {
+ case '.csv':
+ this.mimeType = 'text/csv';
+ break;
+
+ case '.geojson':
+ this.mimeType = 'application/geo+json';
+ break;
+
+ case '.xml':
+ case '.xml.example':
+ this.mimeType = 'text/xml';
+ break;
+
+ case '.xlsx':
+ this.mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+ break;
+
+ default:
+ throw new UnreachableError(fileExtension);
+ }
+ }
+}
+
+type XFormAttachmentFixtureEntry = readonly [absolutePath: string, fixture: XFormAttachmentFixture];
+
+type XFormAttachmentFixtureEntries = readonly XFormAttachmentFixtureEntry[];
+
+const xformAttachmentFixtureEntries: XFormAttachmentFixtureEntries =
+ xformAttachmentFixtureLoaderEntries.map(([absolutePath, { load }]) => {
+ const fixture = new XFormAttachmentFixture(absolutePath, load);
+
+ return [absolutePath, fixture];
+ });
+
+type XFormAttachmentFixturesByAbsolutePath = ReadonlyMap;
+
+const buildXFormAttachmentFixturesByAbsolutePath = (
+ entries: XFormAttachmentFixtureEntries
+): XFormAttachmentFixturesByAbsolutePath => {
+ return new Map(entries);
+};
+
+export const xformAttachmentFixturesByPath = buildXFormAttachmentFixturesByAbsolutePath(
+ xformAttachmentFixtureEntries
+);
+
+type XFormAttachmentFixturesByDirectory = ReadonlyMap;
+
+const buildXFormAttachmentFixturesByDirectory = (
+ entries: XFormAttachmentFixtureEntries
+): XFormAttachmentFixturesByDirectory => {
+ const result = new UpsertableMap();
+
+ for (const [absolutePath, fixture] of entries) {
+ const parentDirectory = getParentDirectory(absolutePath);
+ const subset = result.upsert(parentDirectory, () => []);
+
+ subset.push(fixture);
+ }
+
+ return result;
+};
+
+export const xformAttachmentFixturesByDirectory = buildXFormAttachmentFixturesByDirectory(
+ xformAttachmentFixtureEntries
+);
diff --git a/packages/common/src/fixtures/xforms.ts b/packages/common/src/fixtures/xforms.ts
index 558186049..6270bda49 100644
--- a/packages/common/src/fixtures/xforms.ts
+++ b/packages/common/src/fixtures/xforms.ts
@@ -1,24 +1,41 @@
-import { IS_NODE_RUNTIME } from '../env/detection.ts';
+import { JRResourceService } from '../jr-resources/JRResourceService.ts';
+import type { JRResourceURL } from '../jr-resources/JRResourceURL.ts';
import { UpsertableMap } from '../lib/collections/UpsertableMap.ts';
+import type { GlobFixture } from './import-glob-helper.ts';
+import { toGlobLoaderEntries } from './import-glob-helper.ts';
type XFormResourceType = 'local' | 'remote';
+type ResourceServiceFactory = () => JRResourceService;
+
interface BaseXFormResourceOptions {
readonly localPath: string | null;
readonly identifier: string | null;
readonly category: string | null;
+ readonly initializeFormAttachmentService?: ResourceServiceFactory;
}
interface LocalXFormResourceOptions extends BaseXFormResourceOptions {
readonly localPath: string;
readonly identifier: string;
readonly category: string;
+ readonly initializeFormAttachmentService: ResourceServiceFactory;
}
interface RemoteXFormResourceOptions extends BaseXFormResourceOptions {
readonly category: string | null;
readonly localPath: null;
readonly identifier: string;
+
+ /**
+ * @todo Note that {@link RemoteXFormResourceOptions} corresponds to an API
+ * primarily serving
+ * {@link https://getodk.org/web-forms-preview/ | Web Forms Preview}
+ * functionality. In theory, we could allow a mechanism to support form
+ * attachments in for that use case, but we'd need to design for it. Until
+ * then, it doesn't make a whole lot of sense to accept arbitrary IO here.
+ */
+ readonly initializeFormAttachmentService?: never;
}
type XFormResourceOptions = {
@@ -71,20 +88,24 @@ const xformURLLoader = (url: URL): LoadXFormXML => {
};
};
+const getNoopResourceService: ResourceServiceFactory = () => {
+ return new JRResourceService();
+};
+
export class XFormResource {
- static forLocalFixture(
- importerURL: string,
- relativePath: string,
- localURL: URL | string,
- loadXML?: LoadXFormXML
- ): XFormResource<'local'> {
- const resourceURL = new URL(localURL, importerURL);
- const localPath = new URL(relativePath, importerURL).pathname;
-
- return new XFormResource('local', resourceURL, loadXML ?? xformURLLoader(resourceURL), {
+ static forLocalFixture(localPath: string, fixture: GlobFixture): XFormResource<'local'> {
+ return new XFormResource('local', fixture.url, fixture.load, {
category: localFixtureDirectoryCategory(localPath),
localPath,
identifier: pathToFileName(localPath),
+ initializeFormAttachmentService: () => {
+ const service = new JRResourceService();
+ const parentPath = localPath.replace(/\/[^/]+$/, '');
+
+ service.activateFixtures(parentPath, ['file', 'file-csv']);
+
+ return service;
+ },
});
}
@@ -96,6 +117,8 @@ export class XFormResource {
const loadXML = xformURLLoader(resourceURL);
return new XFormResource('remote', resourceURL, loadXML, {
+ ...options,
+
category: options?.category ?? 'other',
identifier: options?.identifier ?? extractURLIdentifier(resourceURL),
localPath: options?.localPath ?? null,
@@ -105,6 +128,7 @@ export class XFormResource {
readonly category: string;
readonly localPath: XFormResourceOptions['localPath'];
readonly identifier: XFormResourceOptions['identifier'];
+ readonly fetchFormAttachment: (url: JRResourceURL) => Promise;
private constructor(
readonly resourceType: Type,
@@ -115,37 +139,34 @@ export class XFormResource {
this.category = options.category ?? 'other';
this.localPath = options.localPath;
this.identifier = options.identifier;
- }
-}
-export type XFormFixture = XFormResource<'local'>;
+ const initializeFormAttachmentService =
+ options.initializeFormAttachmentService ?? getNoopResourceService;
-const buildXFormFixtures = (): readonly XFormFixture[] => {
- if (IS_NODE_RUNTIME) {
- const fixtureXMLByRelativePath = import.meta.glob('./**/*.xml', {
- query: '?raw',
- import: 'default',
- eager: false,
- });
+ let resourceService: JRResourceService | null = null;
- return Object.entries(fixtureXMLByRelativePath).map(([path, loadXML]) => {
- const localURL = new URL(path, import.meta.url);
- const fixture = XFormResource.forLocalFixture(import.meta.url, path, localURL, loadXML);
+ this.fetchFormAttachment = (url) => {
+ resourceService = resourceService ?? initializeFormAttachmentService();
- return fixture;
- });
+ return resourceService.handleRequest(url);
+ };
}
+}
- const fixtureURLByRelativePath = import.meta.glob('./**/*.xml', {
+const xformFixtureLoaderEntries = toGlobLoaderEntries(
+ import.meta,
+ import.meta.glob('./**/*.xml', {
query: '?url',
import: 'default',
eager: true,
- });
+ })
+);
- return Object.entries(fixtureURLByRelativePath).map(([path, url]) => {
- const fixture = XFormResource.forLocalFixture(import.meta.url, path, url);
+export type XFormFixture = XFormResource<'local'>;
- return fixture;
+const buildXFormFixtures = (): readonly XFormFixture[] => {
+ return xformFixtureLoaderEntries.map(([path, fixture]) => {
+ return XFormResource.forLocalFixture(path, fixture);
});
};
diff --git a/packages/common/src/jr-resources/JRResource.ts b/packages/common/src/jr-resources/JRResource.ts
new file mode 100644
index 000000000..cd2c71936
--- /dev/null
+++ b/packages/common/src/jr-resources/JRResource.ts
@@ -0,0 +1,39 @@
+import type { XFormAttachmentFixture } from '../fixtures/xform-attachments.ts';
+import { JRResourceURL } from './JRResourceURL.ts';
+
+type JRResourceLoader = (this: void) => Promise;
+
+export interface JRResourceSource {
+ readonly url: JRResourceURL;
+ readonly fileName: string;
+ readonly mimeType: string;
+ readonly load: JRResourceLoader;
+}
+
+export class JRResource {
+ static fromFormAttachmentFixture(category: string, fixture: XFormAttachmentFixture): JRResource {
+ const { fileName, mimeType, load } = fixture;
+ const url = JRResourceURL.create(category, fileName);
+
+ return new JRResource({
+ url,
+ fileName,
+ mimeType,
+ load,
+ });
+ }
+
+ readonly url: JRResourceURL;
+ readonly fileName: string;
+ readonly mimeType: string;
+ readonly load: JRResourceLoader;
+
+ constructor(source: JRResourceSource) {
+ const { url, fileName, mimeType, load } = source;
+
+ this.url = url;
+ this.fileName = fileName;
+ this.mimeType = mimeType;
+ this.load = load;
+ }
+}
diff --git a/packages/common/src/jr-resources/JRResourceService.ts b/packages/common/src/jr-resources/JRResourceService.ts
new file mode 100644
index 000000000..7297f9a73
--- /dev/null
+++ b/packages/common/src/jr-resources/JRResourceService.ts
@@ -0,0 +1,108 @@
+import { xformAttachmentFixturesByDirectory } from '../fixtures/xform-attachments.ts';
+import { JRResource } from './JRResource.ts';
+import type { JRResourceURLString } from './JRResourceURL.ts';
+import { JRResourceURL } from './JRResourceURL.ts';
+
+export interface InlineFixtureMetadata {
+ readonly url: JRResourceURLString;
+ readonly fileName: string;
+ readonly mimeType: string;
+}
+
+class JRResourceServiceRegistry extends Map {}
+
+interface ActivateFixturesOptions {
+ readonly suppressMissingFixturesDirectoryWarning?: boolean;
+}
+
+export class JRResourceService {
+ readonly resources = new JRResourceServiceRegistry();
+
+ readonly handleRequest = async (url: JRResourceURL | JRResourceURLString): Promise => {
+ let resourceKey: JRResourceURLString;
+
+ if (typeof url === 'string') {
+ resourceKey = url;
+ } else {
+ resourceKey = url.href;
+ }
+
+ const resource = this.resources.get(resourceKey);
+
+ if (resource == null) {
+ return new Response('Not found', {
+ status: 404,
+ headers: { 'Content-Type': 'text/plain' },
+ });
+ }
+
+ const { load, mimeType } = resource;
+ const body = await load();
+
+ return new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': mimeType },
+ });
+ };
+
+ private setRegisteredResourceState(resource: JRResource) {
+ const url = resource.url.href;
+
+ if (this.resources.has(url)) {
+ throw new Error(`Resource already registered for URL: ${url}`);
+ }
+
+ this.resources.set(url, resource);
+ }
+
+ activateFixtures(
+ fixtureDirectory: string,
+ categories: readonly string[],
+ options?: ActivateFixturesOptions
+ ): void {
+ this.reset();
+
+ try {
+ for (const category of categories) {
+ const fixtures = xformAttachmentFixturesByDirectory.get(fixtureDirectory);
+
+ if (fixtures == null) {
+ if (options?.suppressMissingFixturesDirectoryWarning !== true) {
+ // eslint-disable-next-line no-console
+ console.warn(`No form attachments in directory: ${fixtureDirectory}`);
+ }
+
+ continue;
+ }
+
+ for (const fixture of fixtures) {
+ const resource = JRResource.fromFormAttachmentFixture(category, fixture);
+
+ this.setRegisteredResourceState(resource);
+ }
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error occurred during resource state setup:', error);
+
+ this.reset();
+ }
+ }
+
+ activateResource(metadata: InlineFixtureMetadata, data: string): void {
+ const url = JRResourceURL.from(metadata.url);
+ const load = () => Promise.resolve(data);
+ const resource = new JRResource({
+ url,
+ fileName: metadata.fileName,
+ mimeType: metadata.mimeType,
+ load,
+ });
+
+ this.setRegisteredResourceState(resource);
+ }
+
+ reset(): void {
+ this.resources.clear();
+ }
+}
diff --git a/packages/common/src/jr-resources/JRResourceURL.ts b/packages/common/src/jr-resources/JRResourceURL.ts
new file mode 100644
index 000000000..32597c0e4
--- /dev/null
+++ b/packages/common/src/jr-resources/JRResourceURL.ts
@@ -0,0 +1,46 @@
+const JR_RESOURCE_URL_PROTOCOL = 'jr:';
+type JRResourceURLProtocol = typeof JR_RESOURCE_URL_PROTOCOL;
+
+export type JRResourceURLString = `${JRResourceURLProtocol}${string}`;
+
+interface ValidatedJRResourceURL extends URL {
+ readonly protocol: JRResourceURLProtocol;
+ readonly href: JRResourceURLString;
+}
+
+type ValidateJRResourceURL = (url: URL) => asserts url is ValidatedJRResourceURL;
+
+const validateJRResourceURL: ValidateJRResourceURL = (url) => {
+ if (import.meta.env.DEV) {
+ const { protocol, href } = url;
+
+ if (protocol !== JR_RESOURCE_URL_PROTOCOL || !href.startsWith(JR_RESOURCE_URL_PROTOCOL)) {
+ throw new Error(`Invalid JRResoruceURL: ${url}`);
+ }
+ }
+};
+
+export class JRResourceURL extends URL {
+ static create(category: string, fileName: string): JRResourceURL {
+ return new this(`jr://${category}/${fileName}`);
+ }
+
+ static from(url: JRResourceURLString): JRResourceURL {
+ return new this(url);
+ }
+
+ static isJRResourceReference(reference: string | null): reference is JRResourceURLString {
+ return reference?.startsWith(JR_RESOURCE_URL_PROTOCOL) ?? false;
+ }
+
+ declare readonly protocol: JRResourceURLProtocol;
+ declare readonly href: JRResourceURLString;
+
+ private constructor(url: JRResourceURL);
+ private constructor(url: JRResourceURLString);
+ private constructor(url: URL | string) {
+ super(url);
+
+ validateJRResourceURL(this);
+ }
+}
diff --git a/packages/common/src/lib/type-assertions/assertNull.ts b/packages/common/src/lib/type-assertions/assertNull.ts
new file mode 100644
index 000000000..51346a727
--- /dev/null
+++ b/packages/common/src/lib/type-assertions/assertNull.ts
@@ -0,0 +1,7 @@
+export type AssertNull = (value: unknown) => asserts value is null;
+
+export const assertNull: AssertNull = (value) => {
+ if (value !== null) {
+ throw new Error('Not null');
+ }
+};
diff --git a/packages/common/src/lib/type-assertions/assertUnknownArray.ts b/packages/common/src/lib/type-assertions/assertUnknownArray.ts
new file mode 100644
index 000000000..48e85d53d
--- /dev/null
+++ b/packages/common/src/lib/type-assertions/assertUnknownArray.ts
@@ -0,0 +1,9 @@
+type UnknownArray = readonly unknown[];
+
+type AssertUnknownArray = (value: unknown) => asserts value is UnknownArray;
+
+export const assertUnknownArray: AssertUnknownArray = (value) => {
+ if (!Array.isArray(value)) {
+ throw new Error('Not an array');
+ }
+};
diff --git a/packages/common/src/lib/type-assertions/assertUnknownObject.ts b/packages/common/src/lib/type-assertions/assertUnknownObject.ts
index 24692e6d9..a7ac3c461 100644
--- a/packages/common/src/lib/type-assertions/assertUnknownObject.ts
+++ b/packages/common/src/lib/type-assertions/assertUnknownObject.ts
@@ -1,4 +1,4 @@
-type UnknownObject = Record;
+export type UnknownObject = Record;
type AssertUnknownObject = (value: unknown) => asserts value is UnknownObject;
diff --git a/packages/common/src/test/assertions/helpers.ts b/packages/common/src/test/assertions/helpers.ts
index 3482f375b..4cf1482b3 100644
--- a/packages/common/src/test/assertions/helpers.ts
+++ b/packages/common/src/test/assertions/helpers.ts
@@ -3,12 +3,14 @@ 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';
-export { SymmetricTypedExpectExtension } from './vitest/SymmetricTypedExpectExtension.ts';
+export { AsyncAsymmetricTypedExpectExtension } from './vitest/AsyncAsymmetricTypedExpectExtension.ts';
export { extendExpect } from './vitest/extendExpect.ts';
+export { InspectableComparisonError } from './vitest/InspectableComparisonError.ts';
+export { InspectableStaticConditionError } from './vitest/InspectableStaticConditionError.ts';
export type {
CustomInspectable,
DeriveStaticVitestExpectExtension,
Inspectable,
} from './vitest/shared-extension-types.ts';
+export { StaticConditionExpectExtension } from './vitest/StaticConditionExpectExtension.ts';
+export { SymmetricTypedExpectExtension } from './vitest/SymmetricTypedExpectExtension.ts';
diff --git a/packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts b/packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts
new file mode 100644
index 000000000..be6bca631
--- /dev/null
+++ b/packages/common/src/test/assertions/vitest/AsyncAsymmetricTypedExpectExtension.ts
@@ -0,0 +1,41 @@
+import type { SyncExpectationResult } from 'vitest';
+import type { AssertIs } from '../../../../types/assertions/AssertIs.ts';
+import { expandAsyncExpectExtensionResult } from './expandAsyncExpectExtensionResult.ts';
+import type { ExpectExtensionMethod, SimpleAssertionResult } from './shared-extension-types.ts';
+import { validatedExtensionMethod } from './validatedExtensionMethod.ts';
+
+/**
+ * Generalizes definition of a Vitest `expect` API extension where the assertion
+ * expects differing types for its `actual` and `expected` parameters, and:
+ *
+ * - Automatically perfoms runtime validation of those parameters, helping to
+ * ensure that the extensions' static types are consistent with the runtime
+ * values passed in a given test's assertions
+ *
+ * - Expands simplified assertion result types to the full interface expected by
+ * Vitest
+ *
+ * - Facilitates deriving and defining corresponding static types on the base
+ * `expect` type
+ */
+export class AsyncAsymmetricTypedExpectExtension<
+ Actual = unknown,
+ Expected = Actual,
+ Result extends SimpleAssertionResult = SimpleAssertionResult,
+> {
+ readonly extensionMethod: ExpectExtensionMethod>;
+
+ constructor(
+ readonly validateActualArgument: AssertIs,
+ readonly validateExpectedArgument: AssertIs,
+ extensionMethod: ExpectExtensionMethod>
+ ) {
+ const validatedMethod = validatedExtensionMethod(
+ validateActualArgument,
+ validateExpectedArgument,
+ extensionMethod
+ );
+
+ this.extensionMethod = expandAsyncExpectExtensionResult(validatedMethod);
+ }
+}
diff --git a/packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts b/packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts
new file mode 100644
index 000000000..afacf5b4a
--- /dev/null
+++ b/packages/common/src/test/assertions/vitest/expandAsyncExpectExtensionResult.ts
@@ -0,0 +1,53 @@
+import type { SyncExpectationResult } from 'vitest';
+import type { expandSimpleExpectExtensionResult } from './expandSimpleExpectExtensionResult.ts';
+import { isErrorLike } from './isErrorLike.ts';
+import type { ExpectExtensionMethod, SimpleAssertionResult } from './shared-extension-types.ts';
+
+/**
+ * Asynchronous counterpart to {@link expandSimpleExpectExtensionResult}
+ */
+export const expandAsyncExpectExtensionResult = (
+ simpleMethod: ExpectExtensionMethod>
+): ExpectExtensionMethod> => {
+ return async (actual, expected) => {
+ const simpleResult = await simpleMethod(actual, expected);
+
+ const pass = simpleResult === true;
+
+ if (pass) {
+ return {
+ pass,
+ /**
+ * @todo It was previously assumed that it would never occur that an
+ * assertion would pass, and that Vitest would then produce a message
+ * for that. In hindsight, it makes sense that this case occurs in
+ * negated assertions (e.g.
+ * `expect(...).not.toPassSomeCustomAssertion`). It seems
+ * {@link SimpleAssertionResult} is not a good way to model the
+ * generalization, and that we may want a more uniform `AssertionResult`
+ * type which always includes both `pass` and `message` capabilities.
+ * This is should probably be addressed before we merge the big JR port
+ * PR, but is being temporarily put aside to focus on porting tests in
+ * bulk in anticipation of a scope change/hopefully-temporary
+ * interruption of momentum.
+ */
+ message: () => {
+ throw new Error('Unsupported `SimpleAssertionResult` runtime value');
+ },
+ };
+ }
+
+ let message: () => string;
+
+ if (isErrorLike(simpleResult)) {
+ message = () => simpleResult.message;
+ } else {
+ message = () => simpleResult;
+ }
+
+ return {
+ pass,
+ message,
+ };
+ };
+};
diff --git a/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts b/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts
index d97853374..ac4e2f5ef 100644
--- a/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts
+++ b/packages/common/src/test/assertions/vitest/expandSimpleExpectExtensionResult.ts
@@ -1,13 +1,6 @@
import type { SyncExpectationResult } from 'vitest';
-import type {
- ErrorLike,
- ExpectExtensionMethod,
- SimpleAssertionResult,
-} from './shared-extension-types.ts';
-
-const isErrorLike = (result: SimpleAssertionResult): result is ErrorLike => {
- return typeof result === 'object' && typeof result.message === 'string';
-};
+import { isErrorLike } from './isErrorLike.ts';
+import type { ExpectExtensionMethod, SimpleAssertionResult } from './shared-extension-types.ts';
/**
* Where Vitest assertion extends may be defined to return a
diff --git a/packages/common/src/test/assertions/vitest/isErrorLike.ts b/packages/common/src/test/assertions/vitest/isErrorLike.ts
new file mode 100644
index 000000000..f8a3ff51e
--- /dev/null
+++ b/packages/common/src/test/assertions/vitest/isErrorLike.ts
@@ -0,0 +1,5 @@
+import type { ErrorLike, SimpleAssertionResult } from './shared-extension-types.ts';
+
+export const isErrorLike = (result: SimpleAssertionResult): result is ErrorLike => {
+ return typeof result === 'object' && typeof result.message === 'string';
+};
diff --git a/packages/common/src/test/assertions/vitest/shared-extension-types.ts b/packages/common/src/test/assertions/vitest/shared-extension-types.ts
index 7e6ea42fd..909d307e2 100644
--- a/packages/common/src/test/assertions/vitest/shared-extension-types.ts
+++ b/packages/common/src/test/assertions/vitest/shared-extension-types.ts
@@ -3,6 +3,7 @@ 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 { AsyncAsymmetricTypedExpectExtension } from './AsyncAsymmetricTypedExpectExtension.ts';
import type { StaticConditionExpectExtension } from './StaticConditionExpectExtension.ts';
import type { SymmetricTypedExpectExtension } from './SymmetricTypedExpectExtension.ts';
@@ -37,14 +38,26 @@ export type ExpectExtensionMethod<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TypedExpectExtension =
| AsymmetricTypedExpectExtension
+ | AsyncAsymmetricTypedExpectExtension
| SymmetricTypedExpectExtension;
-export type UntypedExpectExtensionFunction = ExpectExtensionMethod<
+type AsyncUntypedExpectExtensionFunction = ExpectExtensionMethod<
+ unknown,
+ unknown,
+ Promise
+>;
+
+type SyncUntypedExpectExtensionFunction = ExpectExtensionMethod<
unknown,
unknown,
SyncExpectationResult
>;
+// prettier-ignore
+export type UntypedExpectExtensionFunction =
+ | AsyncUntypedExpectExtensionFunction
+ | SyncUntypedExpectExtensionFunction;
+
export interface UntypedExpectExtensionObject {
readonly extensionMethod: UntypedExpectExtensionFunction;
}
@@ -54,7 +67,10 @@ export type UntypedExpectExtension =
| UntypedExpectExtensionFunction
| UntypedExpectExtensionObject;
-export type ExpectExtension = TypedExpectExtension | UntypedExpectExtension;
+// prettier-ignore
+export type ExpectExtension =
+ | TypedExpectExtension
+ | UntypedExpectExtension;
export type ExpectExtensionRecord = {
[K in MethodName]: ExpectExtension;
@@ -68,7 +84,10 @@ export type DeriveStaticVitestExpectExtension<
> = {
[K in keyof Implementation]:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- Implementation[K] extends ArbitraryConditionExpectExtension
+ Implementation[K] extends AsyncAsymmetricTypedExpectExtension
+ ? (expected: Expected) => Promise
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ : Implementation[K] extends ArbitraryConditionExpectExtension
? () => VitestParameterizedReturn
// eslint-disable-next-line @typescript-eslint/no-explicit-any
: Implementation[K] extends StaticConditionExpectExtension
diff --git a/packages/common/types/collections/IterableReadonlyTuple.ts b/packages/common/types/collections/IterableReadonlyTuple.ts
deleted file mode 100644
index 30b6c9d87..000000000
--- a/packages/common/types/collections/IterableReadonlyTuple.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { ReadonlyTuple } from './ReadonlyTuple';
-
-// eslint-disable-next-line @typescript-eslint/sort-type-constituents
-export type IterableReadonlyTuple = ReadonlyTuple &
- Pick;
diff --git a/packages/scenario/CHANGELOG.md b/packages/scenario/CHANGELOG.md
index fe0cb678c..df7656105 100644
--- a/packages/scenario/CHANGELOG.md
+++ b/packages/scenario/CHANGELOG.md
@@ -1,5 +1,16 @@
# @getodk/scenario
+## 0.5.0
+
+### Minor Changes
+
+- 2fddcec: Support for external secondary instances (XML, CSV, GeoJSON)
+- 8edf375: Initial engine support for preparing submissions
+
+### Patch Changes
+
+- e636a9c: XPath support for evaluation of arbitrary DOM implementations (XPathDOMAdapter)
+
## 0.4.0
### Minor Changes
diff --git a/packages/scenario/package.json b/packages/scenario/package.json
index 60a1b50e9..58e2069df 100644
--- a/packages/scenario/package.json
+++ b/packages/scenario/package.json
@@ -1,7 +1,7 @@
{
"name": "@getodk/scenario",
"private": true,
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "@getodk/scenario",
"type": "module",
"author": "getodk",
@@ -47,7 +47,7 @@
"test:types": "tsc --project ./tsconfig.json --emitDeclarationOnly false --noEmit"
},
"devDependencies": {
- "@getodk/xforms-engine": "0.4.0",
+ "@getodk/xforms-engine": "0.5.0",
"@js-joda/core": "^5.6.3",
"@vitest/browser": "^2.1.1",
"jsdom": "^25.0.1",
diff --git a/packages/scenario/src/answer/ComparableAnswer.ts b/packages/scenario/src/answer/ComparableAnswer.ts
index 9c1d8e2d6..c80756b4a 100644
--- a/packages/scenario/src/answer/ComparableAnswer.ts
+++ b/packages/scenario/src/answer/ComparableAnswer.ts
@@ -1,13 +1,6 @@
-import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
-import type { JSONValue } from '@getodk/common/types/JSONValue.ts';
+import { ComparableAssertableValue } from '../comparable/ComparableAssertableValue.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:
@@ -21,36 +14,5 @@ interface OptionalBooleanComparable {
* {@link https://vitest.dev/guide/extending-matchers.html | extended}
* assertions/matchers.
*/
-// 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
- * return type is currently `unknown`.
- */
- getValue(): unknown {
- return this.stringValue;
- }
-
- inspectValue(): JSONValue {
- return this.stringValue;
- }
-
- toString(): string {
- return this.stringValue;
- }
-}
-// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue
-export interface ComparableAnswer extends OptionalBooleanComparable {}
+export abstract class ComparableAnswer extends ComparableAssertableValue {}
diff --git a/packages/scenario/src/answer/InputNodeAnswer.ts b/packages/scenario/src/answer/InputNodeAnswer.ts
new file mode 100644
index 000000000..3a9c5b8d8
--- /dev/null
+++ b/packages/scenario/src/answer/InputNodeAnswer.ts
@@ -0,0 +1,17 @@
+import type { InputNode, InputValue, ValueType } from '@getodk/xforms-engine';
+import { ValueNodeAnswer } from './ValueNodeAnswer.ts';
+
+export class InputNodeAnswer extends ValueNodeAnswer<
+ InputNode
+> {
+ readonly valueType: V;
+ readonly stringValue: string;
+ readonly value: InputValue;
+
+ constructor(node: InputNode) {
+ super(node);
+ this.valueType = node.valueType;
+ this.stringValue = this.node.currentState.instanceValue;
+ this.value = this.node.currentState.value;
+ }
+}
diff --git a/packages/scenario/src/answer/ModelValueNodeAnswer.ts.ts b/packages/scenario/src/answer/ModelValueNodeAnswer.ts.ts
index db13e2faa..95fd5f618 100644
--- a/packages/scenario/src/answer/ModelValueNodeAnswer.ts.ts
+++ b/packages/scenario/src/answer/ModelValueNodeAnswer.ts.ts
@@ -1,8 +1,19 @@
-import type { ModelValueNode } from '@getodk/xforms-engine';
+import type { ModelValue, ModelValueNode, ValueType } from '@getodk/xforms-engine';
import { ValueNodeAnswer } from './ValueNodeAnswer.ts';
-export class ModelValueNodeAnswer extends ValueNodeAnswer {
- get stringValue(): string {
- return this.node.currentState.value;
+export class ModelValueNodeAnswer extends ValueNodeAnswer> {
+ readonly valueType: V;
+ readonly stringValue: string;
+ readonly value: ModelValue;
+
+ constructor(node: ModelValueNode) {
+ super(node);
+ this.valueType = node.valueType;
+ this.stringValue = node.currentState.instanceValue;
+ this.value = node.currentState.value;
}
}
+
+export type AnyModelValueNodeAnswer = {
+ [V in ValueType]: ModelValueNodeAnswer;
+};
diff --git a/packages/scenario/src/answer/StringNodeAnswer.ts b/packages/scenario/src/answer/StringNodeAnswer.ts
deleted file mode 100644
index 70bf6e175..000000000
--- a/packages/scenario/src/answer/StringNodeAnswer.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { StringNode } from '@getodk/xforms-engine';
-import { ValueNodeAnswer } from './ValueNodeAnswer.ts';
-
-export class StringNodeAnswer extends ValueNodeAnswer {
- get stringValue(): string {
- return this.node.currentState.value;
- }
-}
diff --git a/packages/scenario/src/answer/ValueNodeAnswer.ts b/packages/scenario/src/answer/ValueNodeAnswer.ts
index b076fe3be..43ba4c82e 100644
--- a/packages/scenario/src/answer/ValueNodeAnswer.ts
+++ b/packages/scenario/src/answer/ValueNodeAnswer.ts
@@ -1,7 +1,7 @@
-import type { AnyLeafNode as ValueNode } from '@getodk/xforms-engine';
+import type { AnyLeafNode, InputNode, ModelValueNode } from '@getodk/xforms-engine';
import { ComparableAnswer } from './ComparableAnswer.ts';
-export type { ValueNode };
+export type ValueNode = AnyLeafNode | InputNode | ModelValueNode;
export abstract class ValueNodeAnswer extends ComparableAnswer {
constructor(readonly node: Node) {
diff --git a/packages/scenario/src/assertion/extensions/shared-type-assertions.ts b/packages/scenario/src/assertion/extensions/shared-type-assertions.ts
index a680b429b..d72d198bb 100644
--- a/packages/scenario/src/assertion/extensions/shared-type-assertions.ts
+++ b/packages/scenario/src/assertion/extensions/shared-type-assertions.ts
@@ -27,7 +27,7 @@ type AnyNodeType = AnyNode['nodeType'];
type NonRootNodeType = Exclude;
const nonRootNodeTypes = new Set([
- 'string',
+ 'input',
'select',
'subtree',
'group',
diff --git a/packages/scenario/src/assertion/extensions/submission.ts b/packages/scenario/src/assertion/extensions/submission.ts
new file mode 100644
index 000000000..866a06dda
--- /dev/null
+++ b/packages/scenario/src/assertion/extensions/submission.ts
@@ -0,0 +1,194 @@
+import { assertUnknownArray } from '@getodk/common/lib/type-assertions/assertUnknownArray.ts';
+import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts';
+import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
+import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts';
+import {
+ ArbitraryConditionExpectExtension,
+ AsymmetricTypedExpectExtension,
+ AsyncAsymmetricTypedExpectExtension,
+ extendExpect,
+ instanceAssertion,
+} from '@getodk/common/test/assertions/helpers.ts';
+import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
+import type { AssertIs } from '@getodk/common/types/assertions/AssertIs.ts';
+import type {
+ SubmissionChunkedType,
+ SubmissionData,
+ SubmissionInstanceFile,
+ SubmissionResult,
+} from '@getodk/xforms-engine';
+import { constants } from '@getodk/xforms-engine';
+import { assert, expect } from 'vitest';
+import { Scenario } from '../../jr/Scenario.ts';
+import { ComparableXMLSerialization } from '../../serialization/ComparableXMLSerialization.ts';
+import { assertString } from './shared-type-assertions.ts';
+
+type AssertScenario = AssertIs;
+
+const assertScenario: AssertScenario = (value) => {
+ assert(value instanceof Scenario);
+};
+
+const compareSubmissionXML = (actual: string, expected: string): SimpleAssertionResult => {
+ const comparableActual = new ComparableXMLSerialization(actual);
+ const comparableExpected = new ComparableXMLSerialization(expected);
+
+ return comparableActual.equals(comparableExpected);
+};
+
+const assertFormData: AssertIs = instanceAssertion(FormData);
+
+type AnySubmissionResult = SubmissionResult;
+
+/**
+ * Validating the full {@link SubmissionResult} type is fairly involved. We
+ * check the basic object shape (expected keys present, gut check a few easy to
+ * check property types), on the assumption that downstream assertions will fail
+ * if the runtime and static types disagree.
+ *
+ * @todo If that assumption turns out to be wrong, it would make sense to do
+ * more complete validation here, serving as a smoke test for all tests
+ * exercising aspects of a prepared submission result.
+ */
+const assertSubmissionResult: AssertIs = (value) => {
+ assertUnknownObject(value);
+ assertString(value.status);
+ if (value.violations !== null) {
+ assertUnknownArray(value.violations);
+ }
+ assertUnknownObject(value.definition);
+
+ if (Array.isArray(value.data)) {
+ value.data.forEach((item) => {
+ assertFormData(item);
+ });
+ } else {
+ assertFormData(value.data);
+ }
+};
+
+const assertFile: AssertIs = instanceAssertion(File);
+
+const { SUBMISSION_INSTANCE_FILE_NAME, SUBMISSION_INSTANCE_FILE_TYPE } = constants;
+
+const assertSubmissionInstanceFile: AssertIs = (value) => {
+ assertFile(value);
+
+ if (value.name !== SUBMISSION_INSTANCE_FILE_NAME) {
+ throw new Error(`Expected file named ${SUBMISSION_INSTANCE_FILE_NAME}, got ${value.name}`);
+ }
+
+ if (value.type !== SUBMISSION_INSTANCE_FILE_TYPE) {
+ throw new Error(`Expected file of type ${SUBMISSION_INSTANCE_FILE_TYPE}, got ${value.type}`);
+ }
+};
+
+type ChunkedSubmissionData = readonly [SubmissionData, ...SubmissionData[]];
+
+const isChunkedSubmissionData = (
+ data: ChunkedSubmissionData | SubmissionData
+): data is ChunkedSubmissionData => {
+ return Array.isArray(data);
+};
+
+const getSubmissionData = (submissionResult: AnySubmissionResult): SubmissionData => {
+ const { data } = submissionResult;
+
+ if (isChunkedSubmissionData(data)) {
+ const [first] = data;
+
+ return first;
+ }
+
+ return data;
+};
+
+const getSubmissionInstanceFile = (
+ submissionResult: AnySubmissionResult
+): SubmissionInstanceFile => {
+ const submissionData = getSubmissionData(submissionResult);
+ const file = submissionData.get(SUBMISSION_INSTANCE_FILE_NAME);
+
+ assertSubmissionInstanceFile(file);
+
+ return file;
+};
+
+export const submissionExtensions = extendExpect(expect, {
+ toHaveSerializedSubmissionXML: new AsymmetricTypedExpectExtension(
+ assertScenario,
+ assertString,
+ (actual, expected) => {
+ const actualXML = actual.proposed_serializeInstance();
+
+ return compareSubmissionXML(actualXML, expected);
+ }
+ ),
+
+ toBeReadyForSubmission: new ArbitraryConditionExpectExtension(
+ assertSubmissionResult,
+ (result) => {
+ try {
+ expect(result).toMatchObject({
+ status: 'ready',
+ violations: null,
+ });
+
+ return true;
+ } catch (error) {
+ if (error instanceof Error) {
+ return error;
+ }
+
+ // eslint-disable-next-line no-console
+ console.error(error);
+ return new Error('Unknown error');
+ }
+ }
+ ),
+
+ toBePendingSubmissionWithViolations: new ArbitraryConditionExpectExtension(
+ assertSubmissionResult,
+ (result) => {
+ try {
+ expect(result.status).toBe('pending');
+ expect(result.violations).toMatchObject([expect.any(Object)]);
+ expect(result).toMatchObject({
+ status: 'pending',
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ violations: expect.arrayContaining([expect.any(Object)]),
+ });
+
+ return true;
+ } catch (error) {
+ if (error instanceof Error) {
+ return error;
+ }
+
+ // eslint-disable-next-line no-console
+ console.error(error);
+ return new Error('Unknown error');
+ }
+ }
+ ),
+
+ toHavePreparedSubmissionXML: new AsyncAsymmetricTypedExpectExtension(
+ assertSubmissionResult,
+ assertString,
+ async (actual, expected): Promise => {
+ const instanceFile = getSubmissionInstanceFile(actual);
+ const actualText = await getBlobText(instanceFile);
+
+ return compareSubmissionXML(actualText, expected);
+ }
+ ),
+});
+
+type SubmissionExtensions = typeof submissionExtensions;
+
+declare module 'vitest' {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ interface Assertion extends DeriveStaticVitestExpectExtension {}
+ interface AsymmetricMatchersContaining
+ extends DeriveStaticVitestExpectExtension {}
+}
diff --git a/packages/scenario/src/assertion/setup.ts b/packages/scenario/src/assertion/setup.ts
index 2c4e83d69..2178feea8 100644
--- a/packages/scenario/src/assertion/setup.ts
+++ b/packages/scenario/src/assertion/setup.ts
@@ -4,4 +4,5 @@ import './extensions/body-classes.ts';
import './extensions/choices.ts';
import './extensions/form-state.ts';
import './extensions/node-state.ts';
+import './extensions/submission.ts';
import './extensions/tree-reference.ts';
diff --git a/packages/scenario/src/client/answerOf.ts b/packages/scenario/src/client/answerOf.ts
index fa945df92..3b20598c5 100644
--- a/packages/scenario/src/client/answerOf.ts
+++ b/packages/scenario/src/client/answerOf.ts
@@ -1,8 +1,8 @@
import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
import type { AnyNode, RootNode } from '@getodk/xforms-engine';
+import { InputNodeAnswer } from '../answer/InputNodeAnswer.ts';
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';
@@ -11,7 +11,7 @@ const isValueNode = (node: AnyNode) => {
return (
node.nodeType === 'model-value' ||
node.nodeType === 'select' ||
- node.nodeType === 'string' ||
+ node.nodeType === 'input' ||
node.nodeType === 'trigger'
);
};
@@ -30,8 +30,8 @@ export const answerOf = (instanceRoot: RootNode, reference: string): ValueNodeAn
case 'select':
return new SelectNodeAnswer(node);
- case 'string':
- return new StringNodeAnswer(node);
+ case 'input':
+ return new InputNodeAnswer(node);
case 'trigger':
return new TriggerNodeAnswer(node);
diff --git a/packages/scenario/src/client/init.ts b/packages/scenario/src/client/init.ts
index 00affd394..95070bf24 100644
--- a/packages/scenario/src/client/init.ts
+++ b/packages/scenario/src/client/init.ts
@@ -1,12 +1,15 @@
+import type { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts';
import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
-import {
- initializeForm,
- type EngineConfig,
- type FormResource,
- type RootNode,
+import type {
+ EngineConfig,
+ FormResource,
+ OpaqueReactiveObjectFactory,
+ RootNode,
} from '@getodk/xforms-engine';
+import { initializeForm } from '@getodk/xforms-engine';
import type { Owner } from 'solid-js';
import { createRoot, getOwner, runWithOwner } from 'solid-js';
+import type { MissingResourceBehavior } from '../../../xforms-engine/dist/client/constants';
import { FormDefinitionResource } from '../jr/resource/FormDefinitionResource.ts';
/**
@@ -47,28 +50,19 @@ export const getFormResource = async (
* @todo Currently we stub resource fetching. We can address this as needed
* while we port existing tests and/or add new ones which require it.
*/
-const fetchResourceStub: typeof fetch = () => {
- throw new Error('TODO: resource fetching not implemented');
+const fetchFormDefinitionStub: typeof fetch = () => {
+ throw new Error('TODO: fetching form definition not implemented');
};
-/**
- * Satisfies the xforms-engine client `stateFactory` option. Currently this is
- * intentionally **not** reactive, as the current scenario tests (as
- * ported/derived from JavaRosa's test suite) do not explicitly exercise any
- * reactive aspects of the client interface.
- *
- * @todo It **is possible** to use Solid's `createMutable`, which would enable
- * expansion of the JavaRosa test suite to _also_ test reactivity. In local
- * testing during the migration to the new client interface, no additional
- * changes were necessary to make that change. For now this non-reactive factory
- * is supplied as a validation that reactivity is in fact optional.
- */
-const nonReactiveIdentityStateFactory = (value: T): T => value;
+export interface InitializeTestFormOptions {
+ readonly resourceService: JRResourceService;
+ readonly missingResourceBehavior: MissingResourceBehavior;
+ readonly stateFactory: OpaqueReactiveObjectFactory;
+}
const defaultConfig = {
- fetchResource: fetchResourceStub,
- stateFactory: nonReactiveIdentityStateFactory,
-} as const satisfies EngineConfig;
+ fetchFormDefinition: fetchFormDefinitionStub,
+} as const satisfies Omit;
interface InitializedTestForm {
readonly instanceRoot: RootNode;
@@ -77,7 +71,8 @@ interface InitializedTestForm {
}
export const initializeTestForm = async (
- testForm: TestFormResource
+ testForm: TestFormResource,
+ options: InitializeTestFormOptions
): Promise => {
return createRoot(async (dispose) => {
const owner = getOwner();
@@ -89,7 +84,12 @@ export const initializeTestForm = async (
const formResource = await getFormResource(testForm);
const instanceRoot = await runWithOwner(owner, async () => {
return initializeForm(formResource, {
- config: defaultConfig,
+ config: {
+ ...defaultConfig,
+ fetchFormAttachment: options.resourceService.handleRequest,
+ missingResourceBehavior: options.missingResourceBehavior,
+ stateFactory: options.stateFactory,
+ },
});
})!;
diff --git a/packages/scenario/src/client/traversal.ts b/packages/scenario/src/client/traversal.ts
index b806b9738..4dd0b1712 100644
--- a/packages/scenario/src/client/traversal.ts
+++ b/packages/scenario/src/client/traversal.ts
@@ -27,7 +27,7 @@ export const collectFlatNodeList = (currentNode: AnyNode): readonly AnyNode[] =>
case 'model-value':
case 'note':
case 'select':
- case 'string':
+ case 'input':
case 'range':
case 'rank':
case 'trigger':
@@ -62,7 +62,7 @@ export const getClosestRepeatRange = (currentNode: AnyNode): RepeatRangeNode | n
case 'subtree':
case 'model-value':
case 'note':
- case 'string':
+ case 'input':
case 'select':
case 'range':
case 'rank':
diff --git a/packages/scenario/src/comparable/ComparableAssertableValue.ts b/packages/scenario/src/comparable/ComparableAssertableValue.ts
new file mode 100644
index 000000000..7fdf85d37
--- /dev/null
+++ b/packages/scenario/src/comparable/ComparableAssertableValue.ts
@@ -0,0 +1,60 @@
+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 values of arbitrary data types, to
+ * support specialized comparison logic between values produced by
+ * {@link Scenario} and the expected values asserted against those.
+ *
+ * Example use cases include asserting expected values for:
+ *
+ * - "Answers" (the {@link Scenario} concept, as read from "questions")
+ * - Serialized XML (where we may elide certain formatting differences, such as
+ * length of whitespace, or whether an empty element is self-closed)
+ *
+ * This interface is used to support evaluation of assertions, where their
+ * JavaRosa expression has been adapted to the closest equivalent in Vitest's
+ * {@link https://vitest.dev/api/expect.html | built-in} or
+ * {@link https://vitest.dev/guide/extending-matchers.html | extended}
+ * assertions/matchers.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue
+export abstract class ComparableAssertableValue 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
+ other: ComparableAssertableValue
+ ): 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
+ * return type is currently `unknown`.
+ */
+ getValue(): unknown {
+ return this.stringValue;
+ }
+
+ inspectValue(): JSONValue {
+ return this.stringValue;
+ }
+
+ toString(): string {
+ return this.stringValue;
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -- see OptionalBooleanComparable.booleanValue
+export interface ComparableAssertableValue extends OptionalBooleanComparable {}
diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts
index abc65ca05..365872692 100644
--- a/packages/scenario/src/jr/Scenario.ts
+++ b/packages/scenario/src/jr/Scenario.ts
@@ -1,24 +1,31 @@
import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
import type {
AnyNode,
+ OpaqueReactiveObjectFactory,
RepeatRangeControlledNode,
RepeatRangeNode,
RepeatRangeUncontrolledNode,
RootNode,
SelectNode,
+ SubmissionChunkedType,
+ SubmissionOptions,
+ SubmissionResult,
} from '@getodk/xforms-engine';
+import { constants as ENGINE_CONSTANTS } from '@getodk/xforms-engine';
import type { Accessor, Setter } from 'solid-js';
import { createMemo, createSignal, runWithOwner } from 'solid-js';
import { afterEach, expect } from 'vitest';
import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts';
import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts';
import { answerOf } from '../client/answerOf.ts';
-import type { TestFormResource } from '../client/init.ts';
+import type { InitializeTestFormOptions, TestFormResource } from '../client/init.ts';
import { initializeTestForm } from '../client/init.ts';
import { isRepeatRange } from '../client/predicates.ts';
import { getClosestRepeatRange, getNodeForReference } from '../client/traversal.ts';
import { ImplementationPendingError } from '../error/ImplementationPendingError.ts';
import { UnclearApplicabilityError } from '../error/UnclearApplicabilityError.ts';
+import type { ReactiveScenario } from '../reactive/ReactiveScenario.ts';
+import { SharedJRResourceService } from '../resources/SharedJRResourceService.ts';
import type { JRFormEntryCaption } from './caption/JRFormEntryCaption.ts';
import type { BeginningOfFormEvent } from './event/BeginningOfFormEvent.ts';
import type { EndOfFormEvent } from './event/EndOfFormEvent.ts';
@@ -42,10 +49,34 @@ import { ValidateOutcome } from './validation/ValidateOutcome.ts';
import { JREvaluationContext } from './xpath/JREvaluationContext.ts';
import { JRTreeReference } from './xpath/JRTreeReference.ts';
-interface ScenarioConstructorOptions {
+/**
+ * Satisfies the xforms-engine client `stateFactory` option. Currently this is
+ * intentionally **not** reactive, as scenario tests ported/derived from
+ * JavaRosa's test suite do not explicitly exercise any reactive aspects of the
+ * engine/client interface.
+ *
+ * This identity function is used as the default
+ * {@link ScenarioConstructorOptions.stateFactory} for tests using
+ * {@link Scenario.init}.
+ *
+ * The {@link ReactiveScenario} subclass provides a default client reactivity
+ * implementation for tests directly exercising the engine's reactive APIs and
+ * behaviors.
+ */
+const nonReactiveIdentityStateFactory = (value: T): T => value;
+
+export interface ScenarioConstructorOptions {
readonly dispose: VoidFunction;
readonly formName: string;
readonly instanceRoot: RootNode;
+
+ /**
+ * No reactivity is provided by default.
+ *
+ * @see {@link ReactiveScenario} for tests exercising reactive engine/client
+ * functionality.
+ */
+ readonly stateFactory?: OpaqueReactiveObjectFactory;
}
type FormFileName = `${string}.xml`;
@@ -57,7 +88,7 @@ const isFormFileName = (value: FormDefinitionResource | string): value is FormFi
// prettier-ignore
type ScenarioStaticInitParameters =
| readonly [formFileName: FormFileName]
- | readonly [formName: string, form: XFormsElement]
+ | readonly [formName: string, form: XFormsElement, overrideOptions?: Partial]
| readonly [resource: FormDefinitionResource];
interface AssertCurrentReferenceOptions {
@@ -117,12 +148,29 @@ const isAnswerSelectParams = (args: AnswerParameters): args is AnswerSelectParam
* to clarify their branchiness at both call and implementation sites.
*/
export class Scenario {
+ /**
+ * To be overridden, e.g. by {@link ReactiveScenario}.
+ */
+
+ static getInitializeTestFormOptions(
+ overrideOptions?: Partial
+ ): InitializeTestFormOptions {
+ return {
+ resourceService: overrideOptions?.resourceService ?? SharedJRResourceService.init(),
+ missingResourceBehavior:
+ overrideOptions?.missingResourceBehavior ??
+ ENGINE_CONSTANTS.MISSING_RESOURCE_BEHAVIOR.DEFAULT,
+ stateFactory: overrideOptions?.stateFactory ?? nonReactiveIdentityStateFactory,
+ };
+ }
+
static async init(
this: This,
...args: ScenarioStaticInitParameters
): Promise {
let resource: TestFormResource;
let formName: string;
+ let options: InitializeTestFormOptions;
if (isFormFileName(args[0])) {
return this.init(r(args[0]));
@@ -130,14 +178,16 @@ export class Scenario {
const [pathResource] = args;
resource = pathResource;
formName = pathResource.formName;
+ options = this.getInitializeTestFormOptions();
} else {
- const [name, form] = args;
+ const [name, form, overrideOptions] = args;
formName = name;
resource = form;
+ options = this.getInitializeTestFormOptions(overrideOptions);
}
- const { dispose, owner, instanceRoot } = await initializeTestForm(resource);
+ const { dispose, owner, instanceRoot } = await initializeTestForm(resource, options);
return runWithOwner(owner, () => {
return new this({
@@ -901,7 +951,20 @@ export class Scenario {
}
proposed_serializeInstance(): string {
- throw new ImplementationPendingError('instance serialization');
+ return this.instanceRoot.submissionState.submissionXML;
+ }
+
+ /**
+ * @todo Name is currently Web Forms-specific, pending question on whether
+ * this feature set is novel to Web Forms. If it is novel, isn't clear whether
+ * it would be appropriate to propose an equivalent JavaRosa method. Find out
+ * more about Collect's responsibility for submission (beyond serialization,
+ * already handled by {@link proposed_serializeInstance}).
+ */
+ prepareWebFormsSubmission(
+ options?: SubmissionOptions
+ ): Promise> {
+ return this.instanceRoot.prepareSubmission(options);
}
// TODO: consider adapting tests which use the following interfaces to use
diff --git a/packages/scenario/src/jr/event/InputQuestionEvent.ts b/packages/scenario/src/jr/event/InputQuestionEvent.ts
new file mode 100644
index 000000000..b4996970c
--- /dev/null
+++ b/packages/scenario/src/jr/event/InputQuestionEvent.ts
@@ -0,0 +1,55 @@
+import type { AnyInputNode, DecimalInputNode, IntInputNode } from '@getodk/xforms-engine';
+import { InputNodeAnswer } from '../../answer/InputNodeAnswer.ts';
+import { UntypedAnswer } from '../../answer/UntypedAnswer.ts';
+import type { ValueNodeAnswer } from '../../answer/ValueNodeAnswer.ts';
+import { QuestionEvent } from './QuestionEvent.ts';
+
+export class InputQuestionEvent extends QuestionEvent<'input'> {
+ getAnswer(): InputNodeAnswer {
+ return new InputNodeAnswer(this.node);
+ }
+
+ private answerDefault(node: AnyInputNode, answerValue: unknown): ValueNodeAnswer {
+ const { stringValue } = new UntypedAnswer(answerValue);
+
+ node.setValue(stringValue);
+
+ return new InputNodeAnswer(node);
+ }
+
+ private answerNumericQuestionNode(
+ node: DecimalInputNode | IntInputNode,
+ answerValue: unknown
+ ): ValueNodeAnswer {
+ if (answerValue === null) {
+ node.setValue(answerValue);
+
+ return new InputNodeAnswer(node);
+ }
+
+ switch (typeof answerValue) {
+ case 'bigint':
+ case 'number':
+ case 'string':
+ node.setValue(answerValue);
+
+ return new InputNodeAnswer(node);
+
+ default:
+ return this.answerDefault(node, answerValue);
+ }
+ }
+
+ answerQuestion(answerValue: unknown): ValueNodeAnswer {
+ const { node } = this;
+
+ switch (node.valueType) {
+ case 'int':
+ case 'decimal':
+ return this.answerNumericQuestionNode(node, answerValue);
+
+ default:
+ return this.answerDefault(node, answerValue);
+ }
+ }
+}
diff --git a/packages/scenario/src/jr/event/PositionalEvent.ts b/packages/scenario/src/jr/event/PositionalEvent.ts
index 98712e0c6..b25a58af6 100644
--- a/packages/scenario/src/jr/event/PositionalEvent.ts
+++ b/packages/scenario/src/jr/event/PositionalEvent.ts
@@ -1,5 +1,6 @@
import { assertInstanceType } from '@getodk/common/lib/runtime-types/instance-predicates.ts';
import type {
+ AnyInputNode,
AnyUnsupportedControlNode,
GroupNode,
NoteNode,
@@ -7,7 +8,6 @@ import type {
RepeatRangeUncontrolledNode,
RootNode,
SelectNode,
- StringNode,
TriggerNode,
} from '@getodk/xforms-engine';
import type { Scenario } from '../Scenario.ts';
@@ -17,7 +17,7 @@ export type QuestionPositionalEventNode =
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
| NoteNode
| SelectNode
- | StringNode
+ | AnyInputNode
| TriggerNode
| AnyUnsupportedControlNode;
diff --git a/packages/scenario/src/jr/event/StringInputQuestionEvent.ts b/packages/scenario/src/jr/event/StringInputQuestionEvent.ts
deleted file mode 100644
index cb4c9d81c..000000000
--- a/packages/scenario/src/jr/event/StringInputQuestionEvent.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-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): ValueNodeAnswer {
- const { stringValue } = new UntypedAnswer(answerValue);
-
- this.node.setValue(stringValue);
-
- return new StringNodeAnswer(this.node);
- }
-}
diff --git a/packages/scenario/src/jr/event/getPositionalEvents.ts b/packages/scenario/src/jr/event/getPositionalEvents.ts
index 25647e6cb..bb0c18e4d 100644
--- a/packages/scenario/src/jr/event/getPositionalEvents.ts
+++ b/packages/scenario/src/jr/event/getPositionalEvents.ts
@@ -4,19 +4,19 @@ import { collectFlatNodeList } from '../../client/traversal.ts';
import { BeginningOfFormEvent } from './BeginningOfFormEvent.ts';
import { EndOfFormEvent } from './EndOfFormEvent.ts';
import { GroupEvent } from './GroupEvent.ts';
+import { InputQuestionEvent } from './InputQuestionEvent.ts';
import { NoteQuestionEvent } from './NoteQuestionEvent.ts';
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 =
+ | InputQuestionEvent
| NoteQuestionEvent
| SelectQuestionEvent
- | StringInputQuestionEvent
| TriggerQuestionEvent
| UnsupportedControlQuestionEvent;
@@ -80,8 +80,8 @@ export const getPositionalEvents = (instanceRoot: RootNode): PositionalEvents =>
case 'select':
return SelectQuestionEvent.from(node);
- case 'string':
- return StringInputQuestionEvent.from(node);
+ case 'input':
+ return InputQuestionEvent.from(node);
case 'trigger':
return TriggerQuestionEvent.from(node);
diff --git a/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts b/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts
index 1f93b4433..d04a06889 100644
--- a/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts
+++ b/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts
@@ -1,67 +1,39 @@
-import type { EngineConfig, InitializeFormOptions } from '@getodk/xforms-engine';
-import { afterEach, beforeEach } from 'vitest';
import type { JavaNIOPath } from '../../java/nio/Path.ts';
-import type { TextFileResourcePath } from '../file/TextFileResourcePath.ts';
-
-/**
- * @todo This is incomplete! It was intended to add preliminary support for
- * resource loading consistent with setup in ported JavaRosa tests. Since we
- * don't yet have any non-form resource loading logic, we'd have no way of
- * exercising the actual functionality. As such, this is currently a sketch of
- * what a basis for that might look like in terms of JavaRosa interface
- * compatibility, test-scoped setup and teardown. When it does become relevant,
- * it will likely intersect with {@link TextFileResourcePath} (or some other
- * similar interface to resource fixtures) to service the pertinent resources as
- * the engine requests them (via {@link EngineConfig}'s `fetchResource` option).
- */
-class ResourceManager {
- constructor(
- readonly path: JavaNIOPath,
- readonly jrResourceBasePaths: readonly string[]
- ) {}
-}
-
-let resourceManagers: ResourceManager[] = [];
-
-beforeEach(() => {
- resourceManagers = [];
-});
-
-afterEach(() => {
- resourceManagers = [];
-});
+import { SharedJRResourceService } from '../../resources/SharedJRResourceService.ts';
/**
* **PORTING NOTES**
*
- * The name {@link schemes} has been preserved in the signature of this function
- * (corresponding to JavaRosa's static method of the same name). It somewhat
- * unintuitively **does not** refer to a URL scheme (i.e. `jr:`), but rather the
- * first path segment in a `jr:` resource template URL. For instance, if a form
- * references a file resource `jr://file/external-data.geojson`, a test may set
- * up access to that resource by calling this function and specifying `"files"`
- * as a "scheme".
- *
- * - - -
- *
- * Exposed as a plain function; addresses pertinent aspects of the semantic
- * intent of JavaRosa's same-named static method on the
- * `ReferenceManagerTestUtils` class.
+ * This signature is preserved as a reference to the equivalent JavaRosa test
+ * setup interface. Some time was spent tracing the actual setup behavior, and
+ * it was determined (and since confirmed) that ultimately for test purposes the
+ * intent is to register a set of file system paths which are available for
+ * resolving fixtures and fixture resources.
*
- * Significant divergences:
+ * As such, the actual behavior when calling this function produces the
+ * following minimal equivalent behavior:
*
- * 1. Returns `void`, where JavaRosa's equivalent returns a `ReferenceManager`
- * (which, per @lognaturel, "nobody likes [...] Don't look :smile:"). This
- * appears to be safe for now, as there are no current references to its
- * return value.
+ * 1. When called, any state produced by a prior call is reset.
+ * 2. The string representation of {@link path} establishes a common base file
+ * system path for all state produced by the current call.
+ * 3. For each value in {@link schemes} (naming preserved from JavaRosa), a file
+ * system path is produced by concatenating that as a subdirectory of that
+ * common base path.
+ * 4. Any logic in the active running test will serve fixture resources from the
+ * set of file system paths produced by the above steps.
*
- * 2. While also implicitly stateful, the intent is to keep that state scoped as
- * clearly as possible to a given test (its state being tracked and cleaned
- * up in an `afterEach` controlled locally in this module as well), and as
- * minimal as possible to set up the web forms engine's closest semantic
- * equivalent (the configuration of `config.fetchResource` in
- * {@link InitializeFormOptions}).
+ * **Implicitly**, the same state is cleared before and after each test, to
+ * avoid establishing shared state between tests which might cause them to
+ * become dependent on ordering of test runs.
*/
export const setUpSimpleReferenceManager = (path: JavaNIOPath, ...schemes: string[]): void => {
- resourceManagers.push(new ResourceManager(path, schemes));
+ const service = SharedJRResourceService.init();
+
+ service.activateFixtures(path.toAbsolutePath().toString(), schemes, {
+ get suppressMissingFixturesDirectoryWarning(): boolean {
+ const stack = new Error().stack;
+
+ return stack?.includes('configureReferenceManagerIncorrectly') ?? false;
+ },
+ });
};
diff --git a/packages/scenario/src/reactive/ReactiveScenario.ts b/packages/scenario/src/reactive/ReactiveScenario.ts
new file mode 100644
index 000000000..4765d6c27
--- /dev/null
+++ b/packages/scenario/src/reactive/ReactiveScenario.ts
@@ -0,0 +1,46 @@
+import type { EffectFunction, Owner } from 'solid-js';
+import { createEffect, createRoot, getOwner, runWithOwner } from 'solid-js';
+import { createMutable } from 'solid-js/store';
+import { assert } from 'vitest';
+import type { InitializeTestFormOptions } from '../client/init.ts';
+import { Scenario, type ScenarioConstructorOptions } from '../jr/Scenario.ts';
+
+export class ReactiveScenario extends Scenario {
+ static override getInitializeTestFormOptions(): InitializeTestFormOptions {
+ return super.getInitializeTestFormOptions({
+ stateFactory: createMutable,
+ });
+ }
+
+ private readonly testScopedOwner: Owner;
+
+ constructor(options: ScenarioConstructorOptions) {
+ let dispose: VoidFunction;
+
+ const testScopedOwner = createRoot((disposeFn) => {
+ dispose = disposeFn;
+
+ const owner = getOwner();
+
+ assert(owner);
+
+ return owner;
+ });
+
+ super({
+ ...options,
+ dispose: () => {
+ dispose();
+ options.dispose();
+ },
+ });
+
+ this.testScopedOwner = testScopedOwner;
+ }
+
+ createEffect(fn: EffectFunction | undefined, Next>): void {
+ runWithOwner(this.testScopedOwner, () => {
+ createEffect(fn);
+ });
+ }
+}
diff --git a/packages/scenario/src/resources/SharedJRResourceService.ts b/packages/scenario/src/resources/SharedJRResourceService.ts
new file mode 100644
index 000000000..b39d3d035
--- /dev/null
+++ b/packages/scenario/src/resources/SharedJRResourceService.ts
@@ -0,0 +1,26 @@
+import { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts';
+import { afterEach, beforeEach } from 'vitest';
+
+let state: SharedJRResourceService | null = null;
+
+export class SharedJRResourceService extends JRResourceService {
+ static init(): SharedJRResourceService {
+ if (state == null) {
+ state = new this();
+ }
+
+ return state;
+ }
+
+ private constructor() {
+ super();
+
+ beforeEach(() => {
+ this.reset();
+ });
+
+ afterEach(() => {
+ this.reset();
+ });
+ }
+}
diff --git a/packages/scenario/src/serialization/ComparableXMLSerialization.ts b/packages/scenario/src/serialization/ComparableXMLSerialization.ts
new file mode 100644
index 000000000..e61719ad7
--- /dev/null
+++ b/packages/scenario/src/serialization/ComparableXMLSerialization.ts
@@ -0,0 +1,179 @@
+import { XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts';
+import { InspectableComparisonError } from '@getodk/common/test/assertions/helpers.ts';
+import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
+import { ComparableAssertableValue } from '../comparable/ComparableAssertableValue.ts';
+
+class ComparableXMLQualifiedName {
+ readonly sortKey: string;
+
+ constructor(
+ readonly namespaceURI: string | null,
+ readonly localName: string
+ ) {
+ this.sortKey = JSON.stringify({ namespaceURI, localName });
+ }
+
+ /**
+ * @todo prefix re-serialization
+ */
+ toString(): string {
+ const { namespaceURI } = this;
+
+ if (namespaceURI == null || namespaceURI === XFORMS_NAMESPACE_URI) {
+ return this.localName;
+ }
+
+ return this.sortKey;
+ }
+}
+
+class ComparableXMLAttribute {
+ static from(attr: Attr): ComparableXMLAttribute {
+ return new this(attr.namespaceURI, attr.localName, attr.value);
+ }
+
+ readonly qualifiedName: ComparableXMLQualifiedName;
+
+ private constructor(
+ namespaceURI: string | null,
+ localName: string,
+ readonly value: string
+ ) {
+ this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, localName);
+ }
+
+ /**
+ * Note: re-serialization is space prefixed for easier use downstream (i.e.
+ * re-serialization of the element an attribute belongs to).
+ *
+ * @todo value re-escaping. Probably means moving XML escaping up to common
+ * after all?
+ */
+ toString(): string {
+ return ` ${this.qualifiedName.toString()}="${this.value}"`;
+ }
+}
+
+const comparableXMLElementAttributes = (element: Element): readonly ComparableXMLAttribute[] => {
+ const attributes = Array.from(element.attributes).map((attr) => {
+ return ComparableXMLAttribute.from(attr);
+ });
+
+ return attributes.sort(({ qualifiedName: a }, { qualifiedName: b }) => {
+ if (a > b) {
+ return 1;
+ }
+
+ if (b > a) {
+ return -1;
+ }
+
+ return 0;
+ });
+};
+
+const isElement = (node: ChildNode): node is Element => {
+ return node.nodeType === Node.ELEMENT_NODE;
+};
+
+const isText = (node: ChildNode): node is CDATASection | Text => {
+ return node.nodeType === Node.CDATA_SECTION_NODE || node.nodeType === Node.TEXT_NODE;
+};
+
+/**
+ * @todo we will probably also need to support comments (e.g. if/when we leave
+ * markers for non-relevant repeat sub-ranges).
+ */
+type ComparableXMLElementChild = ComparableXMLElement | string;
+
+const comparableXMLElementChildren = (node: Element): readonly ComparableXMLElementChild[] => {
+ const clone = node.cloneNode(true);
+
+ clone.normalize();
+
+ return Array.from(clone.childNodes).flatMap((child) => {
+ if (isElement(child)) {
+ return ComparableXMLElement.from(child);
+ }
+
+ if (isText(child)) {
+ // TODO: collapse whitespace
+ return child.data;
+ }
+
+ // TODO: more detail
+ throw new Error('Unexpected node');
+ });
+};
+
+class ComparableXMLElement {
+ static fromXML(xml: string): ComparableXMLElement {
+ const domParser = new DOMParser();
+ const xmlDocument = domParser.parseFromString(xml, 'text/xml');
+
+ return this.from(xmlDocument.documentElement);
+ }
+
+ static from(element: Element): ComparableXMLElement {
+ const attributes = comparableXMLElementAttributes(element);
+ const children = comparableXMLElementChildren(element);
+
+ return new this(element.namespaceURI, element.localName, attributes, children);
+ }
+
+ readonly qualifiedName: ComparableXMLQualifiedName;
+
+ private constructor(
+ namespaceURI: string | null,
+ localName: string,
+ readonly attributes: readonly ComparableXMLAttribute[],
+ readonly children: readonly ComparableXMLElementChild[]
+ ) {
+ this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, localName);
+ }
+
+ toString(): string {
+ const attributes = this.attributes.map((attribute) => attribute.toString()).join('');
+ const children = this.children.map((child) => child.toString()).join('');
+ const nodeName = this.qualifiedName.toString();
+ const prefix = `<${nodeName}${attributes}`;
+
+ if (children === '') {
+ return `${prefix}/>`;
+ }
+
+ return `${prefix}>${children}${nodeName}>`;
+ }
+}
+
+export class ComparableXMLSerialization extends ComparableAssertableValue {
+ private _element: ComparableXMLElement | null = null;
+
+ get element(): ComparableXMLElement {
+ if (this._element == null) {
+ this._element = ComparableXMLElement.fromXML(this.xml);
+ }
+
+ return this._element;
+ }
+
+ get stringValue(): string {
+ return this.element.toString();
+ }
+
+ override equals(other: ComparableAssertableValue): SimpleAssertionResult {
+ let pass: boolean;
+
+ if (other instanceof ComparableXMLSerialization && this.xml === other.xml) {
+ pass = true;
+ } else {
+ pass = this.stringValue === other.stringValue;
+ }
+
+ return pass || new InspectableComparisonError(this, other, 'equal');
+ }
+
+ constructor(readonly xml: string) {
+ super();
+ }
+}
diff --git a/packages/scenario/test/actions-events.test.ts b/packages/scenario/test/actions-events.test.ts
index e3f0dfc41..658843eca 100644
--- a/packages/scenario/test/actions-events.test.ts
+++ b/packages/scenario/test/actions-events.test.ts
@@ -378,7 +378,7 @@ describe('Actions/Events', () => {
// expectedException.expectMessage("An action was registered for unsupported events: odk-inftance-first-load, my-fake-event");
const init = async () => {
- return Scenario.init('invalid-events.xml');
+ await Scenario.init('invalid-events.xml');
};
await expect(init).rejects.toThrowError(
@@ -608,6 +608,16 @@ describe('Actions/Events', () => {
/**
* **PORTING NOTES**
*
+ * This test previously had a confusing assertion: setting a value
+ * referenced by a `jr:count` expression to "1" (as a string value) would
+ * produce a value of 0 (as a number value) for that expression. This
+ * reflected the behavior in JavaRosa at the time, rather than the intent of
+ * the test. The behavior and test were corrected after this test was
+ * ported, in {@link https://github.com/getodk/javarosa/pull/789}. Our port
+ * has been updated accordingly.
+ *
+ * - - -
+ *
* 1. None of this test feels like it has anything to do with
* actions/events, `odk-new-repeat` specifically, or really anything in
* this module/suite/bag/vat other than loading the same fixture.
@@ -660,7 +670,7 @@ describe('Actions/Events', () => {
* form-definition-validity.test.ts.
*/
describe('set [value other than integer] other than integer value, on repeat with count', () => {
- it.fails('converts [the count-setting]', async () => {
+ it('converts [the count-setting]', async () => {
const scenario = await Scenario.init(r('event-odk-new-repeat.xml'));
// String
@@ -670,7 +680,7 @@ describe('Actions/Events', () => {
// scenario.next();
// }
- expect(scenario.countRepeatInstancesOf('/data/my-jr-count-repeat')).toBe(0);
+ expect(scenario.countRepeatInstancesOf('/data/my-jr-count-repeat')).toBe(1);
// Decimal
scenario.jumpToBeginningOfForm();
@@ -704,20 +714,19 @@ describe('Actions/Events', () => {
/**
* **PORTING NOTES** (alternate)
*
- * As expected, this fails. It could be made to pass by updating the
- * pertinent {@link Scenario.answer} casting logic, but that just feels
- * like cheating.
+ * With support for `int` bind types, this test is now passing, and is
+ * updated to reflect that fact. However, since the test itself isn't
+ * especially clear about the intended functionality being exercised, this
+ * commit also introduces new tests in `bind-data-types.test.ts`
+ * exercising that (and related) functionality more clearly.
*/
- it.fails(
- "(alternate) casts a decimal/fractional value to an integer [which controls a repeat's `jr:count`]",
- async () => {
- const scenario = await Scenario.init(r('event-odk-new-repeat.xml'));
+ it("(alternate) casts a decimal/fractional value to an integer [which controls a repeat's `jr:count`]", async () => {
+ const scenario = await Scenario.init(r('event-odk-new-repeat.xml'));
- scenario.answer('/data/repeat-count', 2.5);
+ scenario.answer('/data/repeat-count', 2.5);
- expect(scenario.answerOf('/data/repeat-count')).toEqualAnswer(intAnswer(2));
- }
- );
+ expect(scenario.answerOf('/data/repeat-count')).toEqualAnswer(intAnswer(2));
+ });
it("(alternate) assigns a non-fractional integer-as-float-number [which controls a repeat's `jr:count`]", async () => {
const scenario = await Scenario.init(r('event-odk-new-repeat.xml'));
@@ -1117,8 +1126,8 @@ describe('Actions/Events', () => {
* what we might want to change, such as providing a more direct mechanism to
* influence the resolution of geolocation data for testing purposes (hint:
* it'll probably be configurable in a very similar same way to the
- * `fetchResource` engine config option), I also thought it worth mentioning
- * these thoughts in anticipation of working on the feature:
+ * `fetchFormDefinition` engine config option), I also thought it worth
+ * mentioning these thoughts in anticipation of working on the feature:
*
* - Any web-native solution will almost certainly be async.
*
diff --git a/packages/scenario/test/bind-types.test.ts b/packages/scenario/test/bind-types.test.ts
new file mode 100644
index 000000000..343113b7d
--- /dev/null
+++ b/packages/scenario/test/bind-types.test.ts
@@ -0,0 +1,627 @@
+import {
+ bind,
+ body,
+ head,
+ html,
+ input,
+ mainInstance,
+ model,
+ repeat,
+ t,
+ title,
+} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
+import type { ValueType } from '@getodk/xforms-engine';
+import { assert, beforeEach, describe, expect, expectTypeOf, it } from 'vitest';
+import { intAnswer } from '../src/answer/ExpectedIntAnswer.ts';
+import { InputNodeAnswer } from '../src/answer/InputNodeAnswer.ts';
+import { ModelValueNodeAnswer } from '../src/answer/ModelValueNodeAnswer.ts.ts';
+import type { ValueNodeAnswer } from '../src/answer/ValueNodeAnswer.ts';
+import { Scenario } from '../src/jr/Scenario.ts';
+
+describe('Data () type support', () => {
+ describe('model-only values', () => {
+ const formTitle = 'Model value types';
+ const modelNodeRelevancePath = '/root/model-node-relevance';
+ const modelNodeRelevanceExpression = `${modelNodeRelevancePath} = 'yes'`;
+
+ // prettier-ignore
+ const formDefinition = html(
+ head(
+ title(formTitle),
+ model(
+ mainInstance(
+ t(
+ 'root id="model-value-types"',
+ t('model-node-relevance', 'yes'),
+ t('string-value', 'explicit string'),
+ t('implicit-string-value', 'implicit string'),
+ t('int-value', '123'),
+ t('decimal-value', '45.67'),
+ )
+ ),
+ bind('/root/string-value').type('string').relevant(modelNodeRelevanceExpression),
+ bind('/root/implicit-string-value').relevant(modelNodeRelevanceExpression),
+ bind('/root/int-value').type('int').relevant(modelNodeRelevanceExpression),
+ bind('/root/decimal-value').type('decimal').relevant(modelNodeRelevanceExpression)
+ )
+ ),
+ body(
+ input('/root/model-node-relevance'))
+ );
+
+ type AssertTypedModelValueNodeAnswer = (
+ answer: ValueNodeAnswer,
+ valueType: V
+ ) => asserts answer is ModelValueNodeAnswer;
+
+ const assertTypedModelValueNodeAnswer: AssertTypedModelValueNodeAnswer = (
+ answer,
+ valueType
+ ) => {
+ assert(answer instanceof ModelValueNodeAnswer);
+ assert(answer.valueType === valueType);
+ };
+
+ const getTypedModelValueNodeAnswer = (
+ reference: string,
+ valueType: V
+ ): ModelValueNodeAnswer => {
+ const answer = scenario.answerOf(reference);
+
+ assertTypedModelValueNodeAnswer(answer, valueType);
+
+ return answer;
+ };
+
+ let scenario: Scenario;
+
+ beforeEach(async () => {
+ scenario = await Scenario.init(formTitle, formDefinition);
+ });
+
+ describe('explicit type="string"', () => {
+ let answer: ModelValueNodeAnswer<'string'>;
+
+ beforeEach(() => {
+ answer = getTypedModelValueNodeAnswer('/root/string-value', 'string');
+ });
+
+ it('has a string runtime value', () => {
+ expect(answer.value).toBeTypeOf('string');
+ });
+
+ it('has a string static type', () => {
+ expectTypeOf(answer.value).toBeString();
+ });
+
+ it('has a string populated value', () => {
+ expect(answer.value).toBe('explicit string');
+ });
+
+ it('has an empty string blank value', () => {
+ scenario.answer(modelNodeRelevancePath, 'no');
+ answer = getTypedModelValueNodeAnswer('/root/string-value', 'string');
+ expect(answer.value).toBe('');
+ });
+ });
+
+ describe('implicit string type (default)', () => {
+ let answer: ModelValueNodeAnswer<'string'>;
+
+ beforeEach(() => {
+ answer = getTypedModelValueNodeAnswer('/root/implicit-string-value', 'string');
+ });
+
+ it('has a string runtime value', () => {
+ expect(answer.value).toBeTypeOf('string');
+ });
+
+ it('has a string static type', () => {
+ expectTypeOf(answer.value).toBeString();
+ });
+
+ it('has a string populated value', () => {
+ expect(answer.value).toBe('implicit string');
+ });
+
+ it('has an empty string blank value', () => {
+ scenario.answer(modelNodeRelevancePath, 'no');
+ answer = getTypedModelValueNodeAnswer('/root/implicit-string-value', 'string');
+ expect(answer.value).toBe('');
+ });
+ });
+
+ describe('type="int"', () => {
+ let answer: ModelValueNodeAnswer<'int'>;
+
+ beforeEach(() => {
+ answer = getTypedModelValueNodeAnswer('/root/int-value', 'int');
+ });
+
+ it('has a bigint runtime value', () => {
+ expect(answer.value).toBeTypeOf('bigint');
+ });
+
+ it('has a bigint | null static type', () => {
+ expectTypeOf(answer.value).toEqualTypeOf();
+ });
+
+ it('has a bigint populated value', () => {
+ expect(answer.value).toBe(123n);
+ });
+
+ it('has a null blank value', () => {
+ scenario.answer(modelNodeRelevancePath, 'no');
+ answer = getTypedModelValueNodeAnswer('/root/int-value', 'int');
+ expect(answer.value).toBe(null);
+ });
+ });
+
+ describe('type="decimal"', () => {
+ let answer: ModelValueNodeAnswer<'decimal'>;
+
+ beforeEach(() => {
+ answer = getTypedModelValueNodeAnswer('/root/decimal-value', 'decimal');
+ });
+
+ it('has a number runtime value', () => {
+ expect(answer.value).toBeTypeOf('number');
+ });
+
+ it('has a number | null static type', () => {
+ expectTypeOf(answer.value).toEqualTypeOf();
+ });
+
+ it('has a number populated value', () => {
+ expect(answer.value).toBe(45.67);
+ });
+
+ it('has a null blank value', () => {
+ scenario.answer(modelNodeRelevancePath, 'no');
+ answer = getTypedModelValueNodeAnswer('/root/decimal-value', 'decimal');
+ expect(answer.value).toBe(null);
+ });
+ });
+ });
+
+ describe('inputs', () => {
+ const formTitle = 'Input types';
+ const inputRelevancePath = '/root/input-relevance';
+ const inputRelevanceExpression = `${inputRelevancePath} = 'yes'`;
+
+ // prettier-ignore
+ const formDefinition = html(
+ head(
+ title('Input types'),
+ model(
+ mainInstance(
+ t(
+ 'root id="input-types"',
+ t('input-relevance', 'yes'),
+ t('string-value', 'explicit string'),
+ t('implicit-string-value', 'implicit string'),
+ t('int-value', '123'),
+ t('decimal-value', '45.67'),
+ )
+ ),
+ bind('/root/string-value').type('string').relevant(inputRelevanceExpression),
+ bind('/root/implicit-string-value').relevant(inputRelevanceExpression),
+ bind('/root/int-value').type('int').relevant(inputRelevanceExpression),
+ bind('/root/decimal-value').type('decimal').relevant(inputRelevanceExpression)
+ )
+ ),
+ body(
+ input('/root/input-relevance'),
+ input('/root/string-value'),
+ input('/root/implicit-string-value'),
+ input('/root/int-value'),
+ input('/root/decimal-value'),
+ )
+ );
+
+ let scenario: Scenario;
+
+ type AssertTypedInputNodeAnswer = (
+ answer: ValueNodeAnswer,
+ valueType: V
+ ) => asserts answer is InputNodeAnswer;
+
+ const assertTypedInputNodeAnswer: AssertTypedInputNodeAnswer = (answer, valueType) => {
+ assert(answer instanceof InputNodeAnswer);
+ assert(answer.valueType === valueType);
+ };
+
+ const getTypedInputNodeAnswer = (
+ reference: string,
+ valueType: V
+ ): InputNodeAnswer => {
+ const answer = scenario.answerOf(reference);
+
+ assertTypedInputNodeAnswer(answer, valueType);
+
+ return answer;
+ };
+
+ beforeEach(async () => {
+ scenario = await Scenario.init(formTitle, formDefinition);
+ });
+
+ describe('explicit type="string"', () => {
+ let answer: InputNodeAnswer<'string'>;
+
+ beforeEach(() => {
+ answer = getTypedInputNodeAnswer('/root/string-value', 'string');
+ });
+
+ it('has a string runtime value', () => {
+ expect(answer.value).toBeTypeOf('string');
+ });
+
+ it('has a string static type', () => {
+ expectTypeOf(answer.value).toBeString();
+ });
+
+ it('has a string populated value', () => {
+ expect(answer.value).toBe('explicit string');
+ });
+
+ it('has an empty string blank value', () => {
+ scenario.answer(inputRelevancePath, 'no');
+ answer = getTypedInputNodeAnswer('/root/string-value', 'string');
+ expect(answer.value).toBe('');
+ });
+
+ it('sets a string value', () => {
+ scenario.answer('/root/string-value', 'updated string');
+ answer = getTypedInputNodeAnswer('/root/string-value', 'string');
+ expect(answer.value).toBe('updated string');
+ });
+ });
+
+ describe('implicit string type (default)', () => {
+ let answer: InputNodeAnswer<'string'>;
+
+ beforeEach(() => {
+ answer = getTypedInputNodeAnswer('/root/implicit-string-value', 'string');
+ });
+
+ it('has a string runtime value', () => {
+ expect(typeof answer.value).toBeTypeOf('string');
+ });
+
+ it('has a string static type', () => {
+ expectTypeOf(answer.value).toBeString();
+ });
+
+ it('has a string populated value', () => {
+ expect(answer.value).toBe('implicit string');
+ });
+
+ it('has an empty string blank value', () => {
+ scenario.answer(inputRelevancePath, 'no');
+ answer = getTypedInputNodeAnswer('/root/implicit-string-value', 'string');
+ expect(answer.value).toBe('');
+ });
+
+ it('sets a string value', () => {
+ scenario.answer('/root/implicit-string-value', 'updated string');
+ answer = getTypedInputNodeAnswer('/root/implicit-string-value', 'string');
+ expect(answer.value).toBe('updated string');
+ });
+ });
+
+ describe('type="int"', () => {
+ let answer: InputNodeAnswer<'int'>;
+
+ beforeEach(() => {
+ answer = getTypedInputNodeAnswer('/root/int-value', 'int');
+ });
+
+ it('has a bigint runtime value', () => {
+ expect(answer.value).toBeTypeOf('bigint');
+ });
+
+ it('has a bigint | null static type', () => {
+ expectTypeOf(answer.value).toEqualTypeOf();
+ });
+
+ it('has a bigint populated value', () => {
+ expect(answer.value).toBe(123n);
+ });
+
+ it('has a null blank value', () => {
+ scenario.answer(inputRelevancePath, 'no');
+ answer = getTypedInputNodeAnswer('/root/int-value', 'int');
+ expect(answer.value).toBe(null);
+ });
+
+ describe('setting int values', () => {
+ interface SetIntInputValueByType {
+ readonly bigint: bigint;
+ readonly number: number;
+ readonly string: string;
+ readonly null: null;
+ }
+
+ type SetIntInputValueType = keyof SetIntInputValueByType;
+
+ interface BaseSetIntInputValueCase {
+ readonly inputType: T;
+ readonly inputValue: SetIntInputValueByType[T];
+ readonly expectedValue: bigint | null;
+ }
+
+ type SetIntInputValueCase = {
+ [T in SetIntInputValueType]: BaseSetIntInputValueCase;
+ }[SetIntInputValueType];
+
+ describe.each([
+ { inputType: 'bigint', inputValue: 89n, expectedValue: 89n },
+ { inputType: 'number', inputValue: 10, expectedValue: 10n },
+ { inputType: 'string', inputValue: '23', expectedValue: 23n },
+ { inputType: 'null', inputValue: null, expectedValue: null },
+ { inputType: 'string', inputValue: '10.1', expectedValue: 10n },
+ { inputType: 'number', inputValue: 10.1, expectedValue: 10n },
+ ])('setValue($inputType)', ({ inputValue, expectedValue }) => {
+ it(`sets ${inputValue}, resulting in value ${expectedValue}`, () => {
+ scenario.answer('/root/int-value', inputValue);
+ answer = getTypedInputNodeAnswer('/root/int-value', 'int');
+
+ expectTypeOf(answer.value).toEqualTypeOf();
+
+ if (expectedValue == null) {
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ } else {
+ expect(answer.value).toBeTypeOf('bigint');
+ expect(answer.value).toBe(expectedValue);
+ expect(answer.stringValue).toBe(`${expectedValue}`);
+ }
+ });
+ });
+
+ interface BaseSetIntInputErrorCase {
+ readonly inputType: T;
+ readonly inputValue: SetIntInputValueByType[T];
+ }
+
+ type SetIntInputErrorCase = {
+ [T in SetIntInputValueType]: BaseSetIntInputErrorCase;
+ }[SetIntInputValueType];
+
+ describe.each([
+ { inputType: 'bigint', inputValue: -2_147_483_649n },
+ { inputType: 'bigint', inputValue: 2_147_483_648n },
+ { inputType: 'number', inputValue: -2_147_483_649 },
+ { inputType: 'number', inputValue: 2_147_483_648 },
+ { inputType: 'string', inputValue: '-2147483649' },
+ { inputType: 'string', inputValue: '2147483648' },
+ ])('integer value out of specified bounds ($inputType)', ({ inputValue }) => {
+ it(`fails to set ${inputValue}`, () => {
+ let caught: unknown;
+
+ try {
+ scenario.answer('/root/int-value', inputValue);
+ answer = getTypedInputNodeAnswer('/root/int-value', 'int');
+ } catch (error) {
+ caught = error;
+ }
+
+ expect(caught, `Value was set to ${answer.value}`).toBeInstanceOf(Error);
+ });
+ });
+ });
+ });
+
+ describe('type="decimal"', () => {
+ let answer: InputNodeAnswer<'decimal'>;
+
+ beforeEach(() => {
+ answer = getTypedInputNodeAnswer('/root/decimal-value', 'decimal');
+ });
+
+ it('has a number runtime value', () => {
+ expect(answer.value).toBeTypeOf('number');
+ });
+
+ it('has a number | null static type', () => {
+ expectTypeOf(answer.value).toEqualTypeOf();
+ });
+
+ it('has a number populated value', () => {
+ expect(answer.value).toBe(45.67);
+ });
+
+ it('has a null blank value', () => {
+ scenario.answer(inputRelevancePath, 'no');
+ answer = getTypedInputNodeAnswer('/root/decimal-value', 'decimal');
+ expect(answer.value).toBe(null);
+ });
+
+ describe('setting decimal values', () => {
+ interface SetDecimalInputValueByType {
+ readonly bigint: bigint;
+ readonly number: number;
+ readonly string: string;
+ readonly null: null;
+ }
+
+ type SetDecimalInputValueType = keyof SetDecimalInputValueByType;
+
+ interface BaseSetDecimalInputValueCase {
+ readonly inputType: T;
+ readonly inputValue: SetDecimalInputValueByType[T];
+ readonly expectedValue: number | null;
+ }
+
+ type SetDecimalInputValueCase = {
+ [T in SetDecimalInputValueType]: BaseSetDecimalInputValueCase;
+ }[SetDecimalInputValueType];
+
+ it.each([
+ { inputType: 'bigint', inputValue: 89n, expectedValue: 89 },
+ { inputType: 'number', inputValue: 10, expectedValue: 10 },
+ { inputType: 'string', inputValue: '23', expectedValue: 23 },
+ { inputType: 'null', inputValue: null, expectedValue: null },
+ ])('sets value ($inputType)', ({ inputValue, expectedValue }) => {
+ scenario.answer('/root/decimal-value', inputValue);
+ answer = getTypedInputNodeAnswer('/root/decimal-value', 'decimal');
+
+ expectTypeOf(answer.value).toEqualTypeOf();
+
+ if (expectedValue == null) {
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ } else {
+ expect(answer.value).toBeTypeOf('number');
+ expect(answer.value).toBe(expectedValue);
+ expect(answer.stringValue).toBe(`${expectedValue}`);
+ }
+ });
+ });
+ });
+ });
+
+ describe('casting fractional values to int', () => {
+ /**
+ * **PORTING NOTES**
+ *
+ * This test is distilled/derived from a test
+ * {@link https://github.com/getodk/web-forms/commit/fd7c7b7659e5babdf218c70d1b580b8460be49b9#diff-82a9bf61dc4ac99cbc7c0f624cb952fe99767787d0c1e11bbc13d563be1e2935R685-R701 | originally ported from JavaRosa}.
+ * In JavaRosa, that test is defined in situ with tests exercising the
+ * `odk-new-repeat` event, but the test itself was not intended to exercise
+ * that functionality. Instead, it was conceptually
+ * {@link https://github.com/getodk/web-forms/pull/110#discussion_r1612400634 | intended}
+ * to exercise the behavior of casting a fractional value _in the form
+ * definition_ to an integer, as specified for an `int` bind type.
+ *
+ * Since implementing `int` support has caused that test to pass, this is an
+ * opportune time to derive a test explicitly exercising that functionality
+ * as intended.
+ */
+ describe('jr:count computed from an int node', () => {
+ let scenario: Scenario;
+
+ beforeEach(async () => {
+ scenario = scenario = await Scenario.init(
+ 'Cast fractional value to int',
+ // prettier-ignore
+ html(
+ head(
+ title('Cast fractional value to int'),
+ model(
+ mainInstance(
+ t('data id="cast-fractional-value-to-int',
+ t('count-default', '2.5'),
+ t('repeat-count jr:template=""',
+ t('anything')))),
+ bind('/data/count-default').type('int'))),
+ body(
+ input('/data/count-default'),
+ repeat('/data/repeat-count', '/data/count-default')))
+ );
+ });
+
+ it('casts a fractional value from a model-defined default', () => {
+ expect(scenario.answerOf('/data/count-default')).toEqualAnswer(intAnswer(2));
+ expect(scenario.countRepeatInstancesOf('/data/repeat-count')).toBe(2);
+ });
+
+ it('casts an updated fractional value', () => {
+ scenario.answer('/data/count-default', '4.5');
+ expect(scenario.answerOf('/data/count-default')).toEqualAnswer(intAnswer(4));
+ expect(scenario.countRepeatInstancesOf('/data/repeat-count')).toBe(4);
+ });
+ });
+
+ /**
+ * The tests immediately above, which exercise parsing an `int` from a form
+ * definition's fractional value, revealed a gap in that functionality! We
+ * also add these test to ensure the same logic is applied for various
+ * `calculate` expressions and references.
+ *
+ * @todo Coming full circle: we will likely also want to add new tests in
+ * `actions-events.test.ts` to exercise the same for `` events!
+ */
+ describe('jr:count computed from a calculate expressions', () => {
+ it('casts a fractional value computed from a node with type="string"', async () => {
+ const scenario = await Scenario.init(
+ 'Cast fractional value to int',
+ // prettier-ignore
+ html(
+ head(
+ title('Cast fractional value to int'),
+ model(
+ mainInstance(
+ t('data id="cast-fractional-value-to-int',
+ t('count-calc-input-str'),
+ t('count-calc'),
+ t('repeat-count jr:template=""'))),
+ bind('/data/count-calc-input-str').type('string'),
+ bind('/data/count-calc')
+ .type('int')
+ .calculate('/data/count-calc-input-str'))),
+ body(
+ input('/data/count-calc-input-str'),
+ repeat('/data/repeat-count', '/data/count-calc')))
+ );
+
+ scenario.answer('/data/count-calc-input-str', '3.5');
+
+ expect(scenario.answerOf('/data/count-calc')).toEqualAnswer(intAnswer(3));
+ expect(scenario.countRepeatInstancesOf('/data/repeat-count')).toBe(3);
+ });
+
+ it('casts a fractional value computed from a node with type="decimal"', async () => {
+ const scenario = await Scenario.init(
+ 'Cast fractional value to int',
+ // prettier-ignore
+ html(
+ head(
+ title('Cast fractional value to int'),
+ model(
+ mainInstance(
+ t('data id="cast-fractional-value-to-int',
+ t('count-calc-input-dec'),
+ t('count-calc'),
+ t('repeat-count jr:template=""'))),
+ bind('/data/count-calc-input-dec').type('decimal'),
+ bind('/data/count-calc')
+ .type('int')
+ .calculate('/data/count-calc-input-dec'))),
+ body(
+ input('/data/count-calc-input-dec'),
+ repeat('/data/repeat-count', '/data/count-calc')))
+ );
+
+ scenario.answer('/data/count-calc-input-dec', 4.5);
+
+ expect(scenario.answerOf('/data/count-calc')).toEqualAnswer(intAnswer(4));
+ expect(scenario.countRepeatInstancesOf('/data/repeat-count')).toBe(4);
+ });
+
+ it('casts a fractional value computed from a number literal', async () => {
+ const scenario = await Scenario.init(
+ 'Cast fractional value to int',
+ // prettier-ignore
+ html(
+ head(
+ title('Cast fractional value to int'),
+ model(
+ mainInstance(
+ t('data id="cast-fractional-value-to-int',
+ t('count-calc'),
+ t('repeat-count jr:template=""'))),
+ bind('/data/count-calc')
+ .type('int')
+ .calculate('5.5'))),
+ body(
+ repeat('/data/repeat-count', '/data/count-calc')))
+ );
+
+ expect(scenario.answerOf('/data/count-calc')).toEqualAnswer(intAnswer(5));
+ expect(scenario.countRepeatInstancesOf('/data/repeat-count')).toBe(5);
+ });
+ });
+ });
+});
diff --git a/packages/scenario/test/form-definition-validity.test.ts b/packages/scenario/test/form-definition-validity.test.ts
index a0889c7d0..8269f1acc 100644
--- a/packages/scenario/test/form-definition-validity.test.ts
+++ b/packages/scenario/test/form-definition-validity.test.ts
@@ -546,7 +546,7 @@ describe('TriggerableDagTest.java', () => {
* Same notes as previous (then surprising) failure of cycle detecction.
* At least we have a pattern!
*/
- it.fails('should fail', async () => {
+ it('should fail', async () => {
// exceptionRule.expect(XFormParseException.class);
// exceptionRule.expectMessage("Cycle detected in form's relevant and calculation logic!");
@@ -645,7 +645,7 @@ describe('TriggerableDagTest.java', () => {
* Same notes as previous (then surprising) failure of cycle detecction.
* At least we have a pattern!
*/
- it.fails('should fail', async () => {
+ it('should fail', async () => {
// exceptionRule.expect(XFormParseException.class);
// exceptionRule.expectMessage("Cycle detected in form's relevant and calculation logic!");
diff --git a/packages/scenario/test/secondary-instances.test.ts b/packages/scenario/test/secondary-instances.test.ts
index 95647d5ce..d9a76517a 100644
--- a/packages/scenario/test/secondary-instances.test.ts
+++ b/packages/scenario/test/secondary-instances.test.ts
@@ -1,3 +1,6 @@
+import { xformAttachmentFixturesByDirectory } from '@getodk/common/fixtures/xform-attachments.ts';
+import { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts';
+import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
import {
bind,
body,
@@ -14,7 +17,10 @@ import {
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
-import { describe, expect, it } from 'vitest';
+import type { PartiallyKnownString } from '@getodk/common/types/string/PartiallyKnownString.ts';
+import { constants as ENGINE_CONSTANTS } from '@getodk/xforms-engine';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { stringAnswer } from '../src/answer/ExpectedStringAnswer.ts';
import { Scenario } from '../src/jr/Scenario.ts';
import { setUpSimpleReferenceManager } from '../src/jr/reference/ReferenceManagerTestUtils.ts';
import { r } from '../src/jr/resource/ResourcePathHelper.ts';
@@ -84,33 +90,25 @@ describe('Secondary instances', () => {
* (`answerOf` return value) to be `equalTo(null)`. It seems likely
* given the form's shape that the intent is to check that the field is
* present and its value is blank, at that point in time.
- *
- * 3. (HUNCH ONLY!) I'm betting this failure is related to the form's
- * `current()` sub-expression (which I doubt is being accounted for in
- * dependency analysis, and is therefore failing to establish a
- * reactive subscription within the engine).
*/
// JR: `doNotGetConfused`
- it.fails(
- "[re]computes separately within each respective repeat instance, when the predicate's dependencies affecting that node change",
- async () => {
- const scenario = await Scenario.init('repeat-secondary-instance.xml');
+ it("[re]computes separately within each respective repeat instance, when the predicate's dependencies affecting that node change", async () => {
+ const scenario = await Scenario.init('repeat-secondary-instance.xml');
- scenario.createNewRepeat('/data/repeat');
- scenario.createNewRepeat('/data/repeat');
+ scenario.createNewRepeat('/data/repeat');
+ scenario.createNewRepeat('/data/repeat');
- scenario.answer('/data/repeat[1]/choice', 'a');
+ scenario.answer('/data/repeat[1]/choice', 'a');
- expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A');
- // assertThat(scenario.answerOf('/data/repeat[2]/calculate'), equalTo(null));
- expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('');
+ expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A');
+ // assertThat(scenario.answerOf('/data/repeat[2]/calculate'), equalTo(null));
+ expect(scenario.answerOf('/data/repeat[2]/calculate').getValue()).toBe('');
- scenario.answer('/data/repeat[2]/choice', 'b');
+ scenario.answer('/data/repeat[2]/choice', 'b');
- expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A');
- expect(scenario.answerOf('/data/repeat[2]/calculate').getValue()).toBe('B');
- }
- );
+ expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A');
+ expect(scenario.answerOf('/data/repeat[2]/calculate').getValue()).toBe('B');
+ });
});
describe('predicates on different child names', () => {
@@ -534,7 +532,7 @@ describe('Secondary instances', () => {
*
* - Typical `getDisplayText` -> `getValue`
*/
- it.fails('can be selected', async () => {
+ it('can be selected', async () => {
configureReferenceManagerCorrectly();
const scenario = await Scenario.init(r('external-select-geojson.xml'));
@@ -558,11 +556,6 @@ describe('Secondary instances', () => {
* Potentially test elsewhere and/or as integration test.
*/
it.todo('itemsFromExternalSecondaryCSVInstance_ShouldBeAvailableToXPathParser');
-
- /**
- * **PORTING NOTES** (speculative addition)
- */
- it.todo('can select itemset values');
});
});
@@ -588,7 +581,7 @@ describe('Secondary instances', () => {
configureReferenceManagerCorrectly();
const init = async () => {
- return Scenario.init(
+ await Scenario.init(
'Some form',
html(
head(
@@ -622,16 +615,6 @@ describe('Secondary instances', () => {
});
describe('CSV secondary instance with header only', () => {
- /**
- * **PORTING NOTES**
- *
- * Should probably fail pending feature support. Currently passes because
- * this is the expected behavior:
- *
- * - Without support for external secondary instances (and CSV)
- *
- * - Without producing an error in their absence
- */
it('parses without error', async () => {
configureReferenceManagerCorrectly();
@@ -691,24 +674,388 @@ describe('Secondary instances', () => {
*/
it.todo('dummyNodesInExternalInstanceDeclaration_ShouldBeIgnored');
+ /**
+ * **PORTING NOTES**
+ *
+ * This sub-suite has been updated to reflect different semantics and expectations for missing external secondary instances between JavaRosa and Web Forms:
+ *
+ * - By default, Web Forms will fail to initialize a form when any of the external secondary instances are missing (i.e. with HTTP 404 semantics).
+ *
+ * - By optional configuration, Web Forms may ignore missing external secondary instances, treating them as blank.
+ */
describe('//region Missing external file', () => {
+ // JR: emptyPlaceholderInstanceIsUsed_whenExternalInstanceNotFound
+ it.fails(
+ '[uses an] empty placeholder [~~]is used[~~] when [referenced] external instance [is] not found',
+ async () => {
+ configureReferenceManagerIncorrectly();
+
+ const scenario = await Scenario.init('external-select-csv.xml');
+
+ expect(scenario.choicesOf('/data/first').size()).toBe(0);
+ }
+ );
+
/**
* **PORTING NOTES**
*
- * Should probably fail pending feature support. Currently passes because
- * this is the expected behavior:
- *
- * - Without support for external secondary instances (and CSV)
- *
- * - Without producing an error in their absence
+ * Supplemental, exercises configured override of default missing resource
+ * behavior.
*/
- it('[uses an] empty placeholder [~~]is used[~~] when [referenced] external instance [is] not found', async () => {
+ it('uses an empty/blank placeholder when not found, and when overriding configuration is specified', async () => {
configureReferenceManagerIncorrectly();
- const scenario = await Scenario.init('external-select-csv.xml');
+ const scenario = await Scenario.init(
+ 'Missing resource treated as blank',
+ // prettier-ignore
+ html(
+ head(
+ title('Missing resource treated as blank'),
+ model(
+ mainInstance(
+ t('data id="missing-resource-treated-as-blank"',
+ t('first'))),
+
+ t('instance id="external-csv" src="jr://file-csv/missing.csv"'),
+
+ bind('/data/first').type('string')
+ )
+ ),
+ body(
+ select1Dynamic(
+ '/data/first',
+ "instance('external-csv')/root/item"
+ )
+ )
+ ),
+ {
+ missingResourceBehavior: ENGINE_CONSTANTS.MISSING_RESOURCE_BEHAVIOR.BLANK,
+ }
+ );
expect(scenario.choicesOf('/data/first').size()).toBe(0);
});
});
});
+
+ describe('basic external secondary instance support', () => {
+ const xmlAttachmentFileName = 'xml-attachment.xml';
+ const xmlAttachmentURL = `jr://file/${xmlAttachmentFileName}` as const;
+ const csvAttachmentFileName = 'csv-attachment.csv';
+ const csvAttachmentURL = `jr://file/${csvAttachmentFileName}` as const;
+ const formTitle = 'External secondary instance (XML and CSV)';
+ const formDefinition = html(
+ head(
+ title(formTitle),
+ model(
+ // prettier-ignore
+ mainInstance(
+ t('data id="external-secondary-instance-xml-csv"',
+ t('first'),
+ t('second'))),
+
+ t(`instance id="external-xml" src="${xmlAttachmentURL}"`),
+ t(`instance id="external-csv" src="${csvAttachmentURL}"`),
+
+ bind('/data/first').type('string'),
+ bind('/data/second').type('string')
+ )
+ ),
+ body(
+ select1Dynamic(
+ '/data/first',
+ "instance('external-xml')/instance-root/instance-item",
+ 'item-value',
+ 'item-label'
+ ),
+ select1Dynamic(
+ '/data/second',
+ "instance('external-csv')/root/item",
+ 'item-value',
+ 'item-label'
+ )
+ )
+ );
+
+ const activateFixtures = () => {
+ resourceService.activateFixtures(fixturesDirectory, ['file', 'file-csv']);
+ };
+
+ let fixturesDirectory: string;
+ let resourceService: JRResourceService;
+
+ beforeEach(() => {
+ const scenarioFixturesDirectory = Array.from(xformAttachmentFixturesByDirectory.keys()).find(
+ (key) => {
+ return key.endsWith('/test-scenario');
+ }
+ );
+
+ if (scenarioFixturesDirectory == null) {
+ throw new Error(`Failed to get file system path for fixtures directory: "test-scenario"`);
+ }
+
+ fixturesDirectory = scenarioFixturesDirectory;
+
+ resourceService = new JRResourceService();
+ });
+
+ afterEach(() => {
+ resourceService.reset();
+ });
+
+ it('supports external secondary instances (XML, file system fixture)', async () => {
+ activateFixtures();
+
+ const scenario = await Scenario.init(formTitle, formDefinition, {
+ resourceService,
+ });
+
+ scenario.answer('/data/first', 'a');
+
+ expect(scenario.answerOf('/data/first')).toEqualAnswer(stringAnswer('a'));
+ });
+
+ it('supports external secondary instances (CSV, file system fixture)', async () => {
+ activateFixtures();
+
+ const scenario = await Scenario.init(formTitle, formDefinition, {
+ resourceService,
+ });
+
+ scenario.answer('/data/second', 'y');
+
+ expect(scenario.answerOf('/data/second')).toEqualAnswer(stringAnswer('y'));
+ });
+ });
+
+ describe('CSV parsing', () => {
+ const BOM = '\ufeff';
+ type BOM = typeof BOM;
+
+ // prettier-ignore
+ type ColumnDelimiter =
+ | ','
+ | ';'
+ | '\t'
+ | '|';
+
+ // prettier-ignore
+ type RowDelimiter =
+ | '\n'
+ | '\r'
+ | '\r\n';
+
+ type ExpectedFailure = 'parse' | 'select-value';
+
+ interface CSVCase {
+ readonly description: string;
+
+ /** @default ',' */
+ readonly columnDelimiter?: PartiallyKnownString;
+
+ /** @default '\n' */
+ readonly rowDelimiter?: PartiallyKnownString;
+
+ /** @default '' */
+ readonly bom?: BOM | '';
+
+ /** @default 0 */
+ readonly columnPadding?: number;
+
+ /** @default null */
+ readonly expectedFailure?: ExpectedFailure | null;
+
+ /** @default null */
+ readonly surprisingSuccessWarning?: string | null;
+ }
+
+ const csvCases: readonly CSVCase[] = [
+ {
+ description: 'BOM is not treated as part of first column header',
+ bom: BOM,
+ },
+ {
+ description: 'column delimiter: semicolon',
+ columnDelimiter: ';',
+ },
+ {
+ description: 'column delimiter: tab',
+ columnDelimiter: '\t',
+ },
+ {
+ description: 'column delimiter: pipe',
+ columnDelimiter: '|',
+ },
+ {
+ description: 'unsupported column delimiter: $',
+ columnDelimiter: '$',
+ expectedFailure: 'parse',
+ },
+ {
+ description: 'row delimiter: LF',
+ rowDelimiter: '\n',
+ },
+ {
+ description: 'row delimiter: CR',
+ rowDelimiter: '\r',
+ },
+ {
+ description: 'row delimiter: CRLF',
+ rowDelimiter: '\r\n',
+ },
+ {
+ description: 'unsupported row delimiter: LFLF',
+ rowDelimiter: `\n\n`,
+ expectedFailure: 'parse',
+ },
+
+ {
+ description: 'somewhat surprisingly supported row delimiter: LFCR',
+ rowDelimiter: `\n\r`,
+ surprisingSuccessWarning:
+ "LFCR is not an expected line separator in any known-common usage. It's surprising that Papaparse does not fail parsing this case, at least parsing rows!",
+ },
+
+ {
+ description: 'whitespace padding around column delimiter is not ignored (by default)',
+ columnDelimiter: ',',
+ columnPadding: 1,
+ expectedFailure: 'select-value',
+ },
+ ];
+
+ // Note: this isn't set up with `describe.each` because it would create a superfluous outer description where the inner description must be applied with `it` (to perform async setup)
+ csvCases.forEach(
+ ({
+ description,
+ columnDelimiter = ',',
+ rowDelimiter = '\n',
+ bom = '',
+ columnPadding = 0,
+ expectedFailure = null,
+ surprisingSuccessWarning = null,
+ }) => {
+ const LOWER_ALPHA_ASCII_LETTER_COUNT = 26;
+ const lowerAlphaASCIILetters = Array.from(
+ {
+ length: LOWER_ALPHA_ASCII_LETTER_COUNT,
+ },
+ (_, i) => {
+ return String.fromCharCode(i + 97);
+ }
+ );
+
+ type CSVRow = readonly [itemLabel: string, itemValue: string];
+
+ const rows: readonly CSVRow[] = [
+ ['item-label', 'item-value'],
+
+ ...lowerAlphaASCIILetters.map((letter): CSVRow => {
+ return [letter.toUpperCase(), letter];
+ }),
+ ];
+ const baseCSVFixture = rows
+ .map((row) => {
+ const padding = ' '.repeat(columnPadding);
+ const delimiter = `${padding}${columnDelimiter}${padding}`;
+
+ return row.join(delimiter);
+ })
+ .join(rowDelimiter);
+
+ const csvAttachmentFileName = 'csv-attachment.csv';
+ const csvAttachmentURL = `jr://file/${csvAttachmentFileName}` as const;
+ const formTitle = 'External secondary instance (CSV)';
+ const formDefinition = html(
+ head(
+ title(formTitle),
+ model(
+ // prettier-ignore
+ mainInstance(
+ t('data id="external-secondary-instance-csv"',
+ t('letter'))),
+
+ t(`instance id="external-csv" src="${csvAttachmentURL}"`),
+
+ bind('/data/letter').type('string')
+ )
+ ),
+ body(
+ select1Dynamic(
+ '/data/letter',
+ "instance('external-csv')/root/item",
+ 'item-value',
+ 'item-label'
+ )
+ )
+ );
+
+ let resourceService: JRResourceService;
+
+ beforeEach(() => {
+ resourceService = new JRResourceService();
+ });
+
+ afterEach(() => {
+ resourceService.reset();
+ });
+
+ it(description, async () => {
+ let csvFixture: string;
+
+ if (bom === '') {
+ csvFixture = baseCSVFixture;
+ } else {
+ const blob = new Blob([bom, baseCSVFixture]);
+
+ csvFixture = await getBlobText(blob);
+ }
+
+ resourceService.activateResource(
+ {
+ url: csvAttachmentURL,
+ fileName: csvAttachmentFileName,
+ mimeType: 'text/csv',
+ },
+ csvFixture
+ );
+
+ const letterIndex = Math.floor(Math.random() * LOWER_ALPHA_ASCII_LETTER_COUNT);
+ const letter = lowerAlphaASCIILetters[letterIndex]!;
+
+ const initScenario = async (): Promise => {
+ return await Scenario.init(formTitle, formDefinition, {
+ resourceService,
+ });
+ };
+
+ if (expectedFailure === 'parse') {
+ const initParseFailure = async () => {
+ await initScenario();
+ };
+
+ await expect(initParseFailure).rejects.toThrowError();
+
+ return;
+ }
+
+ if (surprisingSuccessWarning != null) {
+ // eslint-disable-next-line no-console
+ console.warn(surprisingSuccessWarning);
+ }
+
+ const scenario = await initScenario();
+
+ scenario.answer('/data/letter', letter);
+
+ if (expectedFailure === 'select-value') {
+ expect(scenario.answerOf('/data/letter')).toEqualAnswer(stringAnswer(''));
+ } else {
+ expect(scenario.answerOf('/data/letter')).toEqualAnswer(stringAnswer(letter));
+ }
+ });
+ }
+ );
+ });
});
diff --git a/packages/scenario/test/select.test.ts b/packages/scenario/test/select.test.ts
index ff79a4826..16f62c60e 100644
--- a/packages/scenario/test/select.test.ts
+++ b/packages/scenario/test/select.test.ts
@@ -920,50 +920,35 @@ describe('SelectMultipleChoiceFilterTest.java', () => {
});
describe('new choice filter evaluation', () => {
- /**
- * **PORTING NOTES**
- *
- * Failure appears to be a bug where selection state is (partially) lost
- * when changing an itemset filter updates the select's available items.
- * Similar behavior can be observed on simpler forms, including at least one
- * fixture previously derived from Enketo. This also appears to be at least
- * partly related to deferring a decision on the appropriate behavior for
- * the effect itemset filtering should have on selection state **when it is
- * changed and then reverted**
- * ({@link https://github.com/getodk/web-forms/issues/57}).
- */
// JR: removesIrrelevantAnswersAtAllLevels_withoutChangingOrder
- it.fails(
- 'removes predicate-filtered answers at all levels, without changing order',
- async () => {
- const scenario = await Scenario.init('three-level-cascading-multi-select.xml');
+ it('removes predicate-filtered answers at all levels, without changing order', async () => {
+ const scenario = await Scenario.init('three-level-cascading-multi-select.xml');
- expect(scenario.choicesOf('/data/level2').isEmpty()).toBe(true);
- expect(scenario.choicesOf('/data/level3').isEmpty()).toBe(true);
+ expect(scenario.choicesOf('/data/level2').isEmpty()).toBe(true);
+ expect(scenario.choicesOf('/data/level3').isEmpty()).toBe(true);
- scenario.answer('/data/level1', 'a', 'b', 'c');
- scenario.answer('/data/level2', 'aa', 'ba', 'ca');
- scenario.answer('/data/level3', 'aab', 'baa', 'aaa');
+ scenario.answer('/data/level1', 'a', 'b', 'c');
+ scenario.answer('/data/level2', 'aa', 'ba', 'ca');
+ scenario.answer('/data/level3', 'aab', 'baa', 'aaa');
- // Remove b from the level1 answer; this should filter out b-related answers and choices at levels 2 and 3
- scenario.answer('/data/level1', 'a', 'c');
+ // Remove b from the level1 answer; this should filter out b-related answers and choices at levels 2 and 3
+ scenario.answer('/data/level1', 'a', 'c');
- // Force populateDynamicChoices to run again which is what filters out irrelevant answers
- scenario.choicesOf('/data/level2');
+ // Force populateDynamicChoices to run again which is what filters out irrelevant answers
+ scenario.choicesOf('/data/level2');
- expect(scenario.answerOf('/data/level2')).toEqualAnswer(answerText('aa, ca'));
+ expect(scenario.answerOf('/data/level2')).toEqualAnswer(answerText('aa, ca'));
- // This also runs populateDynamicChoices and filters out irrelevant answers
- expect(scenario.choicesOf('/data/level3')).toContainChoices([
- choice('aaa'),
- choice('aab'),
- choice('caa'),
- choice('cab'),
- ]);
+ // This also runs populateDynamicChoices and filters out irrelevant answers
+ expect(scenario.choicesOf('/data/level3')).toContainChoices([
+ choice('aaa'),
+ choice('aab'),
+ choice('caa'),
+ choice('cab'),
+ ]);
- expect(scenario.answerOf('/data/level3')).toEqualAnswer(answerText('aab, aaa'));
- }
- );
+ expect(scenario.answerOf('/data/level3')).toEqualAnswer(answerText('aab, aaa'));
+ });
it('leaves answer unchanged if all selections still in choices', async () => {
const scenario = await Scenario.init('three-level-cascading-multi-select.xml');
@@ -1093,14 +1078,24 @@ describe('SelectOneChoiceFilterTest.java', () => {
/**
* **PORTING NOTES**
*
- * Failure likely rooted in incomplete behavior, deferred to
- * {@link https://github.com/getodk/web-forms/issues/57}.
+ * Test now passes, as a result of fixes identified during integration of
+ * the `@getodk/xpath` DOM adapter interface.
+ *
+ * Several supplemental tests are added in the sub-suite below, each
+ * tracking the open question of whether itemset filter changes should
+ * reflect selected values' unavailability by:
+ *
+ * - removing them from the value state, regardless of any subsequent change
+ * to the itemset filter state
*
- * @todo If we ultimately decide to restore selections under (some)
- * circumstances like those described in the linked issue, this may be a
- * good place to add a supplemental test exercising that.
+ * - filtering them from the effective value state, restoring the previous
+ * value state if restored by a subsequent change to the itemset filter
+ * state
+ *
+ * @see
+ * {@link https://github.com/getodk/web-forms/issues/57 | issue tracking this open question}
*/
- it.fails('should clear choices at levels 2 and 3', async () => {
+ it('should clear choices at levels 2 and 3', async () => {
const scenario = await Scenario.init('three-level-cascading-select.xml');
expect(scenario.choicesOf('/data/level2').isEmpty()).toBe(true);
@@ -1125,6 +1120,136 @@ describe('SelectOneChoiceFilterTest.java', () => {
expect(scenario.choicesOf('/data/level3').isEmpty()).toBe(true);
});
+ type ItemsetFilterValueEffectCaseExpectedBehavior = 'clear' | 'restore';
+
+ interface ItemsetFilterRestorationCase {
+ readonly expectedBehavior: ItemsetFilterValueEffectCaseExpectedBehavior;
+ readonly description: string;
+ }
+
+ describe.each([
+ {
+ expectedBehavior: 'clear',
+ description: 'clears selected items when removed by an itemset filter',
+ },
+ {
+ expectedBehavior: 'restore',
+ description:
+ 'restores previously selected items when an itemset filter change restores their availability for selection',
+ },
+ ])(
+ 'effect of removing and restoring filtered itemset items',
+ ({ expectedBehavior, description }) => {
+ let testFn: typeof it | typeof it.fails;
+
+ if (expectedBehavior === 'clear') {
+ testFn = it.fails;
+ } else {
+ testFn = it;
+ }
+
+ testFn(description, async () => {
+ const scenario = await Scenario.init('three-level-cascading-select.xml');
+
+ expect(scenario.choicesOf('/data/level2').isEmpty()).toBe(true);
+ expect(scenario.choicesOf('/data/level3').isEmpty()).toBe(true);
+
+ scenario.answer('/data/level1', 'a');
+ scenario.answer('/data/level2', 'aa');
+
+ expect(scenario.choicesOf('/data/level3')).toContainChoicesInAnyOrder([
+ choice('aaa'),
+ choice('aab'),
+ ]);
+
+ scenario.answer('/data/level3', 'aab');
+
+ // Check assumption: itemset filter narrows choices, and clears items
+ // which are no longer available.
+ scenario.answer('/data/level1', '');
+
+ expect(scenario.choicesOf('/data/level2').isEmpty()).toBe(true);
+ expect(scenario.answerOf('/data/level2').getValue()).toBe('');
+ expect(scenario.choicesOf('/data/level3').isEmpty()).toBe(true);
+ expect(scenario.answerOf('/data/level3').getValue()).toBe('');
+
+ scenario.answer('/data/level1', 'a');
+
+ expect(scenario.choicesOf('/data/level2').isEmpty()).toBe(false);
+ expect(scenario.choicesOf('/data/level3').isEmpty()).toBe(false);
+
+ expect(scenario.choicesOf('/data/level3')).toContainChoicesInAnyOrder([
+ choice('aaa'),
+ choice('aab'),
+ ]);
+
+ if (expectedBehavior === 'clear') {
+ expect(scenario.answerOf('/data/level2').getValue()).toBe('');
+ expect(scenario.answerOf('/data/level3').getValue()).toBe('');
+ } else {
+ expect(scenario.answerOf('/data/level2').getValue()).toBe('aa');
+ expect(scenario.answerOf('/data/level3').getValue()).toBe('aab');
+ }
+ });
+
+ /**
+ * Demonstrates that changing a select's value state directly negates
+ * any potential restoration of previous selection state (regardless of
+ * whether removal or restoration is expected for the above case).
+ */
+ it('does not revert values set explicitly between itemset filter state changes (depth: 2)', async () => {
+ const scenario = await Scenario.init('three-level-cascading-select.xml');
+
+ scenario.answer('/data/level1_contains', 'b');
+ scenario.answer('/data/level2_contains', 'ab');
+
+ expect(scenario.answerOf('/data/level1_contains')).toEqualAnswer(stringAnswer('b'));
+ expect(scenario.answerOf('/data/level2_contains')).toEqualAnswer(stringAnswer('ab'));
+
+ // Filter to remove availability of current level2_contains selection
+ scenario.answer('/data/level1_contains', 'c');
+
+ // Change level2_contains
+ scenario.answer('/data/level2_contains', 'ca');
+
+ // Restore availability of original level2_contains selection
+ scenario.answer('/data/level1_contains', 'a');
+
+ expect(scenario.answerOf('/data/level2_contains')).toEqualAnswer(stringAnswer('ca'));
+ });
+
+ /**
+ * Help! I don't know how to write this test! At least with the fixture
+ * as it exists. Here's what I want to validate...
+ *
+ * Given:
+ *
+ * 1. Value from depth 1 filters depth 2.
+ * 2. Value from depth 2 filters depth 3.
+ * 3. Set value at depth 3.
+ * 4. Change depth 1 so that:
+ * - Value of depth 2 changes; WHICH IN TURN
+ * - Filters value selected at depth 3 is deselected, but some other
+ * options are still available at that depth.
+ * 5. Set other value depth 3.
+ * 6. Change depth 1 so that:
+ * - Value of depth 2 changes; WHICH IN TURN
+ * - Restores availability of value selected in step 3; AND
+ * - Value selected in step 5 is still available.
+ *
+ * Assert that: value set in step 5 is unchanged.
+ *
+ * - - -
+ *
+ * Commentary: I'm reasonably confident this test will pass! Trying to
+ * write it just exhausted the state space I could keep in my head.
+ */
+ it.todo(
+ 'does not revert values set explicitly between itemset filter state changes (depth: 3)'
+ );
+ }
+ );
+
/**
* **PORTING NOTES**
*
@@ -1178,47 +1303,31 @@ describe('SelectOneChoiceFilterTest.java', () => {
/**
* **PORTING NOTES**
*
- * Meta-note: this test is **not** ported from JavaRosa, but supplemental to
- * the ported test above. While misleading, reusing the heading is expected
- * to be helpful in compiling notes for more comprehensive analysis,
- * discussion, planning and prioritization once porting is complete.
- *
- * Actual note: this failure is almost certainly rooted in incomplete
- * behavior, deferred to
- * {@link https://github.com/getodk/web-forms/issues/57}.
+ * Supplemental to the ported test above.
*/
- it.fails(
- 'clears values at levels 2 and 3 [currently supplemental, see porting notes on previous teest]',
- async () => {
- const scenario = await Scenario.init('three-level-cascading-select.xml');
+ it('clears values at levels 2 and 3 [currently supplemental, see porting notes on previous teest]', async () => {
+ const scenario = await Scenario.init('three-level-cascading-select.xml');
- expect(scenario.answerOf('/data/level2').getValue()).toBe('');
- expect(scenario.answerOf('/data/level3').getValue()).toBe('');
+ expect(scenario.answerOf('/data/level2').getValue()).toBe('');
+ expect(scenario.answerOf('/data/level3').getValue()).toBe('');
- scenario.answer('/data/level1', 'a');
- scenario.answer('/data/level2', 'aa');
- scenario.answer('/data/level3', 'aab');
+ scenario.answer('/data/level1', 'a');
+ scenario.answer('/data/level2', 'aa');
+ scenario.answer('/data/level3', 'aab');
- scenario.answer('/data/level1', '');
+ scenario.answer('/data/level1', '');
- expect(scenario.answerOf('/data/level2')).toEqualAnswer(stringAnswer(''));
+ expect(scenario.answerOf('/data/level2')).toEqualAnswer(stringAnswer(''));
- scenario.answer('/data/level1', 'b');
- scenario.answer('/data/level2', 'bb');
+ scenario.answer('/data/level1', 'b');
+ scenario.answer('/data/level2', 'bb');
- expect(scenario.answerOf('/data/level3')).toEqualAnswer(stringAnswer(''));
- }
- );
+ expect(scenario.answerOf('/data/level3')).toEqualAnswer(stringAnswer(''));
+ });
});
describe('changing value at level 2', () => {
- /**
- * **PORTING NOTES**
- *
- * Failure likely rooted in incomplete behavior, deferred to
- * {@link https://github.com/getodk/web-forms/issues/57}.
- */
- it.fails('should clear level 3 if choice no longer available', async () => {
+ it('should clear level 3 if choice no longer available', async () => {
const scenario = await Scenario.init('three-level-cascading-select.xml');
scenario.answer('/data/level1_contains', 'a');
@@ -1247,7 +1356,6 @@ describe('SelectOneChoiceFilterTest.java', () => {
/**
* **PORTING NOTES**
*
- *
* Assertions calling {@link SelectChoice.getDisplayText} have been replaced
* with calls to {@link SelectChoice.getValue}. It's highly doubtful that
* they'd produce a meaningful difference, or that their semantic difference
@@ -1462,110 +1570,70 @@ describe('FormEntryPromptTest.java', () => {
});
describe('with translations', () => {
- /**
- * **PORTING NOTES**
- *
- * Fails due to regression, introduced in
- * {@link https://github.com/getodk/web-forms/commit/24277c2f48729c65716fe6b6ea965e8f403872ce | 24277c2}.
- * Briefly: the change causes selected items
- * (`SelectNode.currentState.value`) to reactively update
- * independently of available options
- * (`SelectNode.currentState.valueOptions`).
- *
- * A potential remedy might involve writing simpler values to the
- * client state `value` property, and decoding it back to the
- * `SelectItem`s corresponding to those values. This would have
- * potential overlap with:
- *
- * - Addressing current `InconsistentChildrenStateError` failures
- * - Further generalizing and hardening that concept (engine state ->
- * reactively encode and write to simpler client state type ->
- * decode to client-facing runtime value on read). It seems likely
- * that would help us guard against other unexpected disconnects
- * between internal and client-facing reactivity.
- *
- * We may also consider changing `SelectNode` value state to only deal
- * with the select items' **values**, distinct from their
- * corresponding `SelectItem` representations. This could either
- * become a client-facing API change, or tie in with the potential
- * remedy described above (i.e. store selected **values** in engine
- * _and client_ states, and compute their `SelectItem`s when reading
- * `SelectNode.currentState.value`).
- *
- * Rephrase?
- *
- * - Every test is presumably concerned with the correct behavior.
- *
- * - Unclear if the more verbose description is valuable, but IMO it
- * better completes the BDD-ish format.
- */
- it.fails(
- '[gets?] returns [~~]correct[~~] [the translated label text] translation',
- async () => {
- const scenario = await Scenario.init(
- 'Multilingual dynamic select',
- html(
- head(
- title('Multilingual dynamic select'),
- model(
+ it('[gets?] returns [~~]correct[~~] [the translated label text] translation', async () => {
+ const scenario = await Scenario.init(
+ 'Multilingual dynamic select',
+ html(
+ head(
+ title('Multilingual dynamic select'),
+ model(
+ t(
+ 'itext',
t(
- 'itext',
- t(
- "translation lang='fr'",
- t("text id='choices-0'", t('value', 'A (fr)')),
- t("text id='choices-1'", t('value', 'B (fr)')),
- t("text id='choices-2'", t('value', 'C (fr)'))
- ),
- t(
- "translation lang='en'",
- t("text id='choices-0'", t('value', 'A (en)')),
- t("text id='choices-1'", t('value', 'B (en)')),
- t("text id='choices-2'", t('value', 'C (en)'))
- )
+ "translation lang='fr'",
+ t("text id='choices-0'", t('value', 'A (fr)')),
+ t("text id='choices-1'", t('value', 'B (fr)')),
+ t("text id='choices-2'", t('value', 'C (fr)'))
),
- mainInstance(t("data id='multilingual-select'", t('select', 'b'))),
-
- instance(
- 'choices',
- t('item', t('itextId', 'choices-0'), t('name', 'a')),
- t('item', t('itextId', 'choices-1'), t('name', 'b')),
- t('item', t('itextId', 'choices-2'), t('name', 'c'))
+ t(
+ "translation lang='en'",
+ t("text id='choices-0'", t('value', 'A (en)')),
+ t("text id='choices-1'", t('value', 'B (en)')),
+ t("text id='choices-2'", t('value', 'C (en)'))
)
- )
- ),
- body(
- select1Dynamic(
- '/data/select',
- "instance('choices')/root/item",
- 'name',
- 'jr:itext(itextId)'
+ ),
+ mainInstance(t("data id='multilingual-select'", t('select', 'b'))),
+
+ instance(
+ 'choices',
+ t('item', t('itextId', 'choices-0'), t('name', 'a')),
+ t('item', t('itextId', 'choices-1'), t('name', 'b')),
+ t('item', t('itextId', 'choices-2'), t('name', 'c'))
)
)
+ ),
+ body(
+ select1Dynamic(
+ '/data/select',
+ "instance('choices')/root/item",
+ 'name',
+ 'jr:itext(itextId)'
+ )
)
- );
+ )
+ );
- scenario.setLanguage('en');
+ scenario.setLanguage('en');
- scenario.next('/data/select');
+ scenario.next('/data/select');
- // FormEntryPrompt questionPrompt = scenario.getFormEntryPromptAtIndex();
- // assertThat(questionPrompt.getAnswerText(), is("B (en)"));
- expect(
- scenario.proposed_getSelectedOptionLabelsAsText({
- assertCurrentReference: '/data/select',
- })
- ).toEqual(['B (en)']);
+ // FormEntryPrompt questionPrompt = scenario.getFormEntryPromptAtIndex();
+ // assertThat(questionPrompt.getAnswerText(), is("B (en)"));
+ expect(
+ scenario.proposed_getSelectedOptionLabelsAsText({
+ assertCurrentReference: '/data/select',
+ })
+ ).toEqual(['B (en)']);
- scenario.setLanguage('fr');
+ scenario.setLanguage('fr');
- // assertThat(questionPrompt.getAnswerText(), is("B (fr)"));
- expect(
- scenario.proposed_getSelectedOptionLabelsAsText({
- assertCurrentReference: '/data/select',
- })
- ).toEqual(['B (fr)']);
- }
- );
+ // assertThat(questionPrompt.getAnswerText(), is("B (fr)"));
+ expect(
+ scenario.proposed_getSelectedOptionLabelsAsText({
+ assertCurrentReference: '/data/select',
+ })
+ ).toEqual(['B (fr)']);
+ });
it("gets the available select items' labels (supplemental)", async () => {
const scenario = await Scenario.init(
diff --git a/packages/scenario/test/serialization.test.ts b/packages/scenario/test/serialization.test.ts
index 54e16e1ed..c4e5536d0 100644
--- a/packages/scenario/test/serialization.test.ts
+++ b/packages/scenario/test/serialization.test.ts
@@ -175,10 +175,11 @@ describe('ExternalSecondaryInstanceParseTest.java', () => {
* - Insofar as we may find ourselves implementing similar logic (albeit
* serving other purposes), how can we establish a clear interface
* contract around behaviors like this? Should it be more consistent? Does
- * our current {@link EngineConfig.fetchResource} option—configurable in
- * {@link InitializeFormOptions}—provide enough informational surface area
- * to communicate such intent (and allow both clients and engine alike to
- * have clarity of that intent at call/handling sites)?
+ * our current {@link EngineConfig.fetchFormDefinition}
+ * option—configurable in {@link InitializeFormOptions}—provide enough
+ * informational surface area to communicate such intent (and allow both
+ * clients and engine alike to have clarity of that intent at
+ * call/handling sites)?
*
* - - -
*
diff --git a/packages/scenario/test/smoketests/child-vaccination.test.ts b/packages/scenario/test/smoketests/child-vaccination.test.ts
index 714d05f30..7c3fa527e 100644
--- a/packages/scenario/test/smoketests/child-vaccination.test.ts
+++ b/packages/scenario/test/smoketests/child-vaccination.test.ts
@@ -2,6 +2,7 @@ import { UpsertableMap } from '@getodk/common/lib/collections/UpsertableMap.ts';
import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
import type { AnyNode, RepeatInstanceNode } from '@getodk/xforms-engine';
import { afterEach, assert, describe, expect, it } from 'vitest';
+import type { ValueNodeAnswer } from '../../src/answer/ValueNodeAnswer.ts';
import { LocalDate } from '../../src/java/time/LocalDate.ts';
import { Consumer } from '../../src/java/util/function/Consumer.ts';
import { Scenario as BaseScenario } from '../../src/jr/Scenario.ts';
@@ -24,40 +25,6 @@ const naiveStripPositionalPredicates = (expression: string): string => {
return expression.replaceAll(/\[\d+\]/g, '');
};
-/**
- * **PORTING NOTES**
- *
- * This smoke test is intentionally ported in an incomplete state, pending
- * progress on whichever aspects of functionality are currently blocking it from
- * proceeding. Its incompleteness is an acknowledgement that the set of blocking
- * functionality is not presently known, and that the effort to complete it will
- * be better spent as each terminal blocker is cleared, revealing any remaining
- * blockers as they arise.
- *
- * To the extent possible, it's been ported in a way to make updating it
- * relatively straightforward as the features it depends on become available.
- *
- * The test itself is currently fibbing about its status: it passes, with a call
- * to a locally assigned {@link it.fails}. This allows us to identify the
- * **current expected failure mode**, prompting updates to the test when that
- * known failure mode changes.
- *
- * @todo We'd benefit here and in many other cases from being able to express
- * these semantics with the real {@link baseIt.fails | `it.fails`} API. We
- * should consider whether there is a way to achieve that without fibbing, and
- * possibly file an issue with Vitest if not.
- */
-class IncompleteTestPortError extends Error {
- constructor(pendingFunctionality: string) {
- const message = [
- 'Test port is incomplete. If this error condition has been reached, some aspect previously blocking the test from proceeding is no longer blocking it! Update the test to proceed through the remaining unblocked steps.',
- `Pending functionality: ${pendingFunctionality}`,
- ].join('\n\n');
-
- super(message);
- }
-}
-
const refSingletons = new UpsertableMap();
class JRTreeReference extends BaseJRTreeReference {
@@ -71,10 +38,8 @@ class JRTreeReference extends BaseJRTreeReference {
return this.xpathReference === other.xpathReference;
}
- override toString(includePredicates: true, zeroIndexMult: true): string {
- throw new IncompleteTestPortError(
- `Pending - cast to string with includePredicates: ${includePredicates}, zeroIndexMult: ${zeroIndexMult}`
- );
+ override toString(): string {
+ return this.xpathReference;
}
}
@@ -211,6 +176,30 @@ class Scenario extends BaseScenario {
return super.next(expectReference);
}
+
+ private matchNextReference(possibleReferences: readonly string[]): string | null {
+ const nextReference = this.nextRef().xpathReference;
+
+ for (const possibleReference of possibleReferences) {
+ if (possibleReference === nextReference) {
+ return possibleReference;
+ }
+ }
+
+ return null;
+ }
+
+ answerIfNext(optionalReference: string, answer: unknown): ValueNodeAnswer | null {
+ const nextReference = this.matchNextReference([optionalReference]);
+
+ if (nextReference != null) {
+ this.next(nextReference);
+
+ return this.answer(answer);
+ }
+
+ return null;
+ }
}
const DOB_DAY_MONTH_TYPE_1_REF = getRef('/data/household/child_repeat/dob_day_1');
@@ -255,18 +244,18 @@ class HealthRecord {
break;
case HealthRecordValue.VACCINATION_CARD:
- scenario.next();
+ scenario.next(`${childRepeatPath}/health_card`);
scenario.answer('no');
- scenario.next();
+ scenario.next(`${childRepeatPath}/ever_had_card`);
scenario.answer('yes');
break;
case HealthRecordValue.HEALTH_CLINIC:
- scenario.next();
+ scenario.next(`${childRepeatPath}/health_card`);
scenario.answer('no');
- scenario.next();
+ scenario.next(`${childRepeatPath}/ever_had_card`);
scenario.answer('no');
- scenario.next();
+ scenario.next(`${childRepeatPath}/ever_been_clinic`);
scenario.answer('yes');
break;
@@ -326,20 +315,12 @@ class Vaccine implements VaccineOptions {
this.measles = options.measles;
}
- visit(scenario: Scenario): void {
+ visit(scenario: Scenario, childRepeatPath: string): void {
const { diphteriaFirst, diphteriaThird, measles } = this;
- // Answer questions until there's no more vaccination related questions
- while (!Vaccine.END_OF_VISIT_REFS.includes(scenario.nextRef().genericize())) {
- scenario.next();
- if (scenario.refAtIndex().genericize().equals(VACCINATION_PENTA1_REF)) {
- scenario.answer(diphteriaFirst ? 'yes' : 'no');
- } else if (scenario.refAtIndex().genericize().equals(VACCINATION_PENTA3_REF)) {
- scenario.answer(diphteriaThird ? 'yes' : 'no');
- } else if (scenario.refAtIndex().genericize().equals(VACCINATION_MEASLES_REF)) {
- scenario.answer(measles ? 'yes' : 'no');
- }
- }
+ scenario.answerIfNext(`${childRepeatPath}/penta1`, diphteriaFirst ? 'yes' : 'no');
+ scenario.answerIfNext(`${childRepeatPath}/penta3`, diphteriaThird ? 'yes' : 'no');
+ scenario.answerIfNext(`${childRepeatPath}/mcv1`, measles ? 'yes' : 'no');
}
}
@@ -416,13 +397,17 @@ const answerChild_ageInMonths = (
answerAgeInMonths(scenario, childRepeatPath, ageInMonths);
if (scenario.nextRef().genericize().equals(VACCINATION_PENTA1_REF)) {
- vaccines.visit(scenario);
+ vaccines.visit(scenario, childRepeatPath);
}
- if ([NEXT_CHILD_REF, NEXT_CHILD_NO_MOTHER_REF].includes(scenario.nextRef().genericize())) {
- scenario.next();
- } else if (!scenario.nextRef().genericize().equals(FINAL_FLAT_REF)) {
- expect.fail('Unexpected next ref ' + scenario.nextRef().toString(true, true) + ' at index');
+ const nextRef = scenario.nextRef().genericize();
+
+ if (nextRef.equals(NEXT_CHILD_REF)) {
+ scenario.next(`${childRepeatPath}/nextChild`);
+ } else if (nextRef.equals(NEXT_CHILD_NO_MOTHER_REF)) {
+ scenario.next(`${childRepeatPath}/nextChild_no_mother`);
+ } else if (!nextRef.equals(FINAL_FLAT_REF)) {
+ expect.fail('Unexpected next ref ' + nextRef.toString() + ' at index');
}
});
};
@@ -457,10 +442,11 @@ const answerDateOfBirth = (scenario: Scenario, childRepeatPath: string, dob: Loc
case 4:
case 6:
case 9:
+ case 11:
dobDayPath = `${childRepeatPath}/dob_day_2`;
break;
- default: {
+ case 2: {
const dobYear = dob.year();
const leap = dobYear === 2016 || dobYear === 2020 || dobYear === 2024 || dobYear === 2028;
@@ -469,6 +455,15 @@ const answerDateOfBirth = (scenario: Scenario, childRepeatPath: string, dob: Loc
} else {
dobDayPath = `${childRepeatPath}/dob_day_4`;
}
+
+ break;
+ }
+
+ // This should not happen! But if it does, we've broken something in the
+ // ported test logic as we've updated it.
+ default: {
+ dobDayPath = '/UNREACHABLE';
+ expect(dob.monthValue()).toBeNaN();
}
}
@@ -486,7 +481,7 @@ const getChildRepeatPath = (scenario: Scenario, childIndex: number): string => {
while (currentHousehold == null) {
currentNode = currentNode.parent;
- if (currentNode == null) {
+ if (currentNode == null || currentNode.nodeType === 'root') {
break;
}
@@ -509,20 +504,6 @@ const getChildRepeatPath = (scenario: Scenario, childIndex: number): string => {
return `${currentHouseholdPath}/child_repeat[${childRepeatPosition}]`;
};
-const checkKnownFailure = (scenario: Scenario, childRepeatPath: string) => {
- const repeatInstance = scenario.getInstanceNode(childRepeatPath);
-
- assert(repeatInstance.nodeType === 'repeat-instance');
-
- if (!repeatInstance.currentState.relevant) {
- throw KnownFailureError.from(
- new Error(
- `Repeat ${childRepeatPath} should be relevant. Failure here occurs due to evaluation of relevant expression before repeat instance node is attached to the DOM backing store, and not rerun once attached. This is HIGHLY LIKELY to be solved when we decouple from the browser/XML/WHATWG DOM.`
- )
- );
- }
-};
-
const answerChild_dob = (
scenario: Scenario,
healthRecord: HealthRecord,
@@ -536,8 +517,6 @@ const answerChild_dob = (
const childRepeatPath = getChildRepeatPath(scenario, i);
- checkKnownFailure(scenario, childRepeatPath);
-
scenario.trace(name);
scenario.next(childRepeatPath);
scenario.next(`${childRepeatPath}/childName`);
@@ -551,7 +530,7 @@ const answerChild_dob = (
if (scenario.nextRef().genericize().equals(NOT_ELIG_NOTE_REF)) {
scenario.next();
} else if (scenario.nextRef().genericize().equals(VACCINATION_PENTA1_REF)) {
- vaccines.visit(scenario);
+ vaccines.visit(scenario, childRepeatPath);
}
const nextRef = scenario.nextRef().genericize();
@@ -560,8 +539,8 @@ const answerChild_dob = (
scenario.next(`${childRepeatPath}/nextChild`);
} else if (nextRef.equals(NEXT_CHILD_NO_MOTHER_REF)) {
scenario.next(`${childRepeatPath}/nextChild_no_mother`);
- } else if (!scenario.nextRef().genericize().equals(FINAL_FLAT_REF)) {
- expect.fail('Unexpected next ref ' + scenario.nextRef().toString(true, true) + ' at index');
+ } else if (!nextRef.equals(FINAL_FLAT_REF)) {
+ expect.fail('Unexpected next ref ' + nextRef.toString() + ' at index');
}
});
};
@@ -702,25 +681,26 @@ describe('ChildVaccinationTest.java', () => {
interface FixtureCase {
readonly fixtureName: ChildVaccinationTestFixtureName;
- readonly skipCondition: boolean;
- readonly expectFailure: boolean;
+
+ /**
+ * @see {@link https://github.com/getodk/web-forms/issues/205}
+ */
+ readonly failureMode: 'INFINITE_LOOP' | null;
}
describe.each([
- { fixtureName: 'child_vaccination_VOL_tool_v12.xml', skipCondition: true, expectFailure: true },
+ {
+ fixtureName: 'child_vaccination_VOL_tool_v12.xml',
+ failureMode: 'INFINITE_LOOP',
+ },
{
fixtureName: 'child_vaccination_VOL_tool_v12-alt.xml',
- skipCondition: false,
- expectFailure: true,
+ failureMode: null,
},
- ])('fixture: $fixtureName', ({ fixtureName, skipCondition, expectFailure }) => {
+ ])('fixture: $fixtureName', ({ fixtureName, failureMode }) => {
let testFn: KnownFailureTestAPI;
- if (skipCondition) {
- testFn = (description, fn) => {
- return it.skipIf(skipCondition)(description, fn);
- };
- } else if (expectFailure) {
+ if (failureMode != null) {
testFn = (description, fn) => {
return it(description, async () => {
let unexpectedFailureMessage: string | null = null;
@@ -757,7 +737,27 @@ describe('ChildVaccinationTest.java', () => {
const scenario = await Scenario.init(fixtureName);
scenario.next('/data/building_type');
- scenario.answer('multi');
+
+ const answerBuildTypeMulti = () => {
+ scenario.answer('multi');
+ };
+
+ if (failureMode === 'INFINITE_LOOP') {
+ try {
+ answerBuildTypeMulti();
+
+ throw new Error('Expected failure mode has changed');
+ } catch (error) {
+ if (error instanceof Error && error.message.toLowerCase().includes('infinite loop')) {
+ throw KnownFailureError.from(error);
+ }
+
+ throw error;
+ }
+ } else {
+ answerBuildTypeMulti();
+ }
+
scenario.next('/data/not_single');
scenario.next('/data/not_single/gps');
scenario.answer('1.234 5.678');
@@ -843,12 +843,12 @@ describe('ChildVaccinationTest.java', () => {
// region Go to the end of the form
- scenario.next();
+ scenario.next(`/data/household[${households.length}]/finished2`);
// assertThat(scenario.refAtIndex().genericize(), is(FINISHED_FORM_REF));
expect(scenario.refAtIndex().genericize()).toEqual(FINISHED_FORM_REF);
- scenario.next();
+ scenario.next('END_OF_FORM');
// endregion
});
diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts
index 84aa67f94..edb70f879 100644
--- a/packages/scenario/test/submission.test.ts
+++ b/packages/scenario/test/submission.test.ts
@@ -1,34 +1,38 @@
import {
bind,
body,
+ group,
head,
html,
input,
+ item,
+ label,
mainInstance,
model,
+ repeat,
+ select1,
t,
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
-import { describe, expect, it } from 'vitest';
+import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts';
+import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
+import { createUniqueId } from 'solid-js';
+import { beforeEach, describe, expect, it } from 'vitest';
import { Scenario } from '../src/jr/Scenario.ts';
+import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts';
+import { ReactiveScenario } from '../src/reactive/ReactiveScenario.ts';
describe('Form submission', () => {
describe('XFormSerializingVisitorTest.java', () => {
/**
* **PORTING NOTES**
*
- * - JavaRosa provides this method on an `XFormSerializingVisitor` class. We
- * add a proposed API equivalent on {@link Scenario}. (Direct ported code
- * is preserved, commented out above the proposed API usage.)
- *
- * - Test currently fails pending feature support.
- *
- * - This test is valuable, but we should expand the suite to cover at least
- * general serialization, as well as any other potential edge cases we
- * might anticipate.
+ * JavaRosa provides this method on an `XFormSerializingVisitor` class. We
+ * add a proposed API equivalent on {@link Scenario}. (Direct ported code is
+ * preserved, commented out above the proposed API usage.)
*/
describe('`serializeInstance`', () => {
- it.fails('preserves unicode characters', async () => {
+ it('preserves unicode characters', async () => {
const formDef = html(
head(
title('Some form'),
@@ -51,4 +55,744 @@ describe('Form submission', () => {
});
});
});
+
+ describe('instance serialization', () => {
+ const DEFAULT_INSTANCE_ID = 'uuid:TODO-mock-xpath-functions';
+
+ type TriggerValue = '' | 'OK';
+
+ interface InstanceDefaultValues {
+ readonly inp?: string;
+ readonly sel1?: string;
+ readonly selN?: string;
+ readonly not?: string;
+ readonly trig?: TriggerValue;
+ readonly modelVal?: string;
+ }
+
+ it.each([
+ {},
+ { inp: 'input default' },
+ { sel1: 'a' },
+ { sel1: 'b' },
+ { selN: 'one' },
+ { selN: 'one two' },
+ { not: 'note default' },
+ { trig: 'OK' },
+ { modelVal: 'modelVal default' },
+ {
+ inp: 'input default',
+ sel1: 'b',
+ selN: 'one two',
+ not: 'note default',
+ trig: 'OK',
+ modelVal: 'modelVal default',
+ },
+ ])('serializes default values %j', async (defaults) => {
+ // prettier-ignore
+ const scenario = await Scenario.init('XML serialization - basic, default values', html(
+ head(
+ title('XML serialization - basic, default values'),
+ model(
+ mainInstance(
+ t('data id="xml-serialization-basic-default-values"',
+ t('grp',
+ t('inp', defaults.inp ?? ''),
+ t('sel1', defaults.sel1 ?? ''),
+ t('selN', defaults.selN ?? '')),
+ t('not', defaults.not ?? ''),
+ t('trig', defaults.trig ?? ''),
+ t('subt',
+ t('modelVal', defaults.modelVal ?? '')),
+ t('calc'),
+ t('meta',
+ t('instanceID')))
+ ),
+ bind('/data/grp/inp').type('string'),
+ bind('/data/grp/sel1').type('string'),
+ bind('/data/grp/selN').type('string'),
+ bind('/data/not').type('string').readonly(),
+ bind('/data/trig').type('string'),
+ bind('/data/subt/modelVal').type('string'),
+ bind('/data/calc').calculate('1 + 2'),
+ bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
+ )
+ ),
+ body(
+ group('/data/grp',
+ label('grp (group)'),
+ input('/data/grp/inp',
+ label('inp (group / input)')),
+ select1('/data/grp/sel1',
+ label('sel1 (group / select1)'),
+ item('a', 'A'),
+ item('b', 'B')
+ ),
+ t('select ref="/data/grp/selN"',
+ label('selN (group / select)'),
+ item('one', 'One'),
+ item('two', 'Two'))),
+ input('/data/not',
+ label('not (note)')),
+ t('trigger ref="/data/trig"',
+ label('trig (trigger)'))
+ )
+ ));
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('grp',
+ t('inp', defaults.inp ?? ''),
+ t('sel1', defaults.sel1 ?? ''),
+ t('selN', defaults.selN ?? '')),
+ t('not', defaults.not ?? ''),
+ t('trig', defaults.trig ?? ''),
+ t('subt',
+ t('modelVal', defaults.modelVal ?? '')),
+ t('calc', '3'),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+
+ // The original ported JavaRosa test exercising Unicode support was a good
+ // reminder that we have an outstanding issue to support Unicode
+ // normalization in `@getodk/xpath` (i.e. consistent use of combined forms,
+ // see https://github.com/getodk/web-forms/issues/175).
+ describe('unicode', () => {
+ const decomposed = 'é';
+ const composed = 'é';
+
+ const getUnicodeScenario = async (defaultValue = '') => {
+ // prettier-ignore
+ return Scenario.init('Unicode normalization', html(
+ head(
+ title('Unicode normalization'),
+ model(
+ mainInstance(
+ t('data id="unicode-normalization"',
+ t('rep',
+ t('inp', defaultValue)),
+ t('meta', t('instanceID')))
+ ),
+ bind('/data/rep/inp'),
+ bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
+ )
+ ),
+ body(
+ repeat('/data/rep',
+ label('rep'),
+ input('/data/rep/inp',
+ label('inp'))))
+ ));
+ };
+
+ // Check setup assumptions
+ beforeEach(() => {
+ // 1. `decomposed` and `composed` are equivalent
+ expect(decomposed.normalize()).toBe(composed);
+
+ // 2. `decomposed` and `composed` are NOT equal
+ expect(decomposed).not.toBe(composed);
+ });
+
+ it('normalizes combining characters in a default value to their composed form', async () => {
+ const scenario = await getUnicodeScenario(decomposed);
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('rep',
+ t('inp', composed)),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+
+ it('normalizes combining characters in an assigned value to their composed form', async () => {
+ const scenario = await getUnicodeScenario();
+
+ scenario.answer('/data/rep[1]/inp', decomposed);
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('rep',
+ t('inp', composed)),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+ });
+
+ describe('repeats', () => {
+ let scenario: Scenario;
+
+ beforeEach(async () => {
+ // prettier-ignore
+ scenario = await Scenario.init('XML serialization - repeats', html(
+ head(
+ title('XML serialization - repeats'),
+ model(
+ mainInstance(
+ t('data id="xml-serialization-repeats"',
+ t('rep jr:template=""',
+ t('inp')),
+ t('meta', t('instanceID')))
+ ),
+ bind('/data/rep/inp').type('string'),
+ bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
+ )
+ ),
+ body(
+ group('/data/rep',
+ repeat('/data/rep',
+ input('/data/rep/inp'))))
+ ));
+ });
+
+ it('does not serialize an element for a repeat range', () => {
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+
+ it('serializes each repeat instance and its descendants', () => {
+ scenario.createNewRepeat('/data/rep');
+ scenario.answer('/data/rep[1]/inp', 'a');
+ scenario.createNewRepeat('/data/rep');
+ scenario.answer('/data/rep[2]/inp', 'b');
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('rep',
+ t('inp', 'a')),
+ t('rep',
+ t('inp', 'b')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.removeRepeat('/data/rep[1]');
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('rep',
+ t('inp', 'b')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+ });
+
+ describe('relevance', () => {
+ let scenario: Scenario;
+
+ beforeEach(async () => {
+ // prettier-ignore
+ scenario = await Scenario.init('XML serialization - relevance', html(
+ head(
+ title('XML serialization - relevance'),
+ model(
+ mainInstance(
+ t('data id="xml-serialization-relevance"',
+ t('grp-rel', '1'),
+ t('inp-rel', '1'),
+ t('grp',
+ t('inp', 'inp default value')),
+ t('meta', t('instanceID')))
+ ),
+ bind('/data/grp-rel'),
+ bind('/data/inp-rel'),
+ bind('/data/grp').relevant('/data/grp-rel = 1'),
+ bind('/data/grp/inp').relevant('/data/inp-rel = 1'),
+ bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
+ )
+ ),
+ body(
+ input('/data/grp-rel',
+ label('`grp` is relevant when this value is 1')),
+ input('/data/inp-rel',
+ label('`inp` is relevant when this value is 1')),
+ group('/data/grp',
+ label('grp'),
+
+ input('/data/grp/inp',
+ label('inp'))))
+ ))
+ });
+
+ it('omits non-relevant leaf nodes', () => {
+ scenario.answer('/data/inp-rel', '0');
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('grp-rel', '1'),
+ t('inp-rel', '0'),
+ t('grp'),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+
+ it('omits non-relevant subtree nodes', () => {
+ scenario.answer('/data/grp-rel', '0');
+
+ expect(scenario).toHaveSerializedSubmissionXML(
+ // prettier-ignore
+ t('data',
+ t('grp-rel', '0'),
+ t('inp-rel', '1'),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+ });
+
+ describe('client reactivity', () => {
+ let scenario: ReactiveScenario;
+
+ beforeEach(async () => {
+ scenario = await ReactiveScenario.init(
+ 'XML serialization - client reactivity',
+ // prettier-ignore
+ html(
+ head(
+ title('Relevance XML serialization'),
+ model(
+ mainInstance(
+ t('data id="relevance-xml-serialization"',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp')),
+ t('meta', t('instanceID')))
+ ),
+ bind('/data/rep-inp-rel'),
+ bind('/data/rep/inp').relevant("/data/rep-inp-rel = '' or /data/rep-inp-rel = position(..)"),
+ bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
+ )
+ ),
+ body(
+ input('/data/rep-inp-rel',
+ label('Each /data/rep/inp is relevant when this value is 1')),
+ repeat('/data/rep',
+ label('rep'),
+ input('/data/rep/inp',
+ label('inp'))))
+ )
+ );
+ });
+
+ it('updates XML serialization state on change to string node', () => {
+ let serialized: string | null = null;
+
+ scenario.createEffect(() => {
+ serialized = scenario.proposed_serializeInstance();
+ });
+
+ // Default serialization before any state change
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ // Check reactive update for repeated changes
+ for (let i = 0; i < 10; i += 1) {
+ scenario.answer('/data/rep[1]/inp', `${i}`);
+
+ // After first value change
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp', `${i}`)),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ }
+ });
+
+ it('updates XML serialization state when adding and removing repeat instances', () => {
+ let serialized: string | null = null;
+
+ scenario.createEffect(() => {
+ serialized = scenario.proposed_serializeInstance();
+ });
+
+ // Default serialization before any state change
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.createNewRepeat('/data/rep');
+
+ // First repeat instance added
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp')),
+ t('rep',
+ t('inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.createNewRepeat('/data/rep');
+
+ // Second repeat instance added
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp')),
+ t('rep',
+ t('inp')),
+ t('rep',
+ t('inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.answer('/data/rep[1]/inp', 'rep 1 inp');
+ scenario.answer('/data/rep[2]/inp', 'rep 2 inp');
+ scenario.answer('/data/rep[3]/inp', 'rep 3 inp');
+
+ // Each of the above values set
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp', 'rep 1 inp')),
+ t('rep',
+ t('inp', 'rep 2 inp')),
+ t('rep',
+ t('inp', 'rep 3 inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.removeRepeat('/data/rep[3]');
+
+ // Last repeat instance removed
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp', 'rep 1 inp')),
+ t('rep',
+ t('inp', 'rep 2 inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.removeRepeat('/data/rep[1]');
+
+ // First repeat instance removed
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp', 'rep 2 inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.removeRepeat('/data/rep[1]');
+
+ // All repeat instances removed
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+
+ it('updates XML serialization state when relevance changes', () => {
+ let serialized: string | null = null;
+
+ scenario.createEffect(() => {
+ serialized = scenario.proposed_serializeInstance();
+ });
+
+ scenario.createNewRepeat('/data/rep');
+ scenario.createNewRepeat('/data/rep');
+ scenario.answer('/data/rep[1]/inp', 'rep 1 inp');
+ scenario.answer('/data/rep[2]/inp', 'rep 2 inp');
+ scenario.answer('/data/rep[3]/inp', 'rep 3 inp');
+
+ // Current serialization before any relevance change
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel'),
+ t('rep',
+ t('inp', 'rep 1 inp')),
+ t('rep',
+ t('inp', 'rep 2 inp')),
+ t('rep',
+ t('inp', 'rep 3 inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.answer('/data/rep-inp-rel', '1');
+
+ // Non-relevant /data/rep[position() != '1']/inp omitted
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel', '1'),
+ t('rep',
+ t('inp', 'rep 1 inp')),
+ t('rep'),
+ t('rep'),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+
+ scenario.answer('/data/rep-inp-rel', '3');
+
+ // Non-relevant /data/rep[position() != '3']/inp omitted
+ expect(serialized).toBe(
+ // prettier-ignore
+ t('data',
+ t('rep-inp-rel', '3'),
+ t('rep'),
+ t('rep'),
+ t('rep',
+ t('inp', 'rep 3 inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))).asXml()
+ );
+ });
+ });
+ });
+
+ describe('submission payload', () => {
+ const DEFAULT_INSTANCE_ID = 'uuid:TODO-mock-xpath-functions';
+
+ // prettier-ignore
+ type SubmissionFixtureElements =
+ | readonly []
+ | readonly [XFormsElement];
+
+ interface BuildSubmissionPayloadScenario {
+ readonly submissionElements?: SubmissionFixtureElements;
+ }
+
+ const buildSubmissionPayloadScenario = async (
+ options?: BuildSubmissionPayloadScenario
+ ): Promise => {
+ const scenario = await Scenario.init(
+ 'Prepare for submission',
+ html(
+ head(
+ title('Prepare for submission'),
+ model(
+ mainInstance(
+ t(
+ 'data id="prepare-for-submission"',
+ t('rep', t('inp')),
+ t('meta', t('instanceID'))
+ )
+ ),
+ ...(options?.submissionElements ?? []),
+ bind('/data/rep/inp').required(),
+ bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`)
+ )
+ ),
+ body(repeat('/data/rep', label('rep'), input('/data/rep/inp', label('inp'))))
+ )
+ );
+
+ return scenario;
+ };
+
+ describe('submission definition', () => {
+ it('includes a default submission definition', async () => {
+ const scenario = await buildSubmissionPayloadScenario();
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult.definition).toMatchObject({
+ submissionAction: null,
+ submissionMethod: 'post',
+ encryptionKey: null,
+ });
+ });
+
+ it('includes a form-specified submission definition URL', async () => {
+ const submissionAction = 'https://example.org';
+ const scenario = await buildSubmissionPayloadScenario({
+ submissionElements: [t(`submission action="${submissionAction}"`)],
+ });
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult.definition).toMatchObject({
+ submissionAction: new URL(submissionAction),
+ });
+ });
+
+ it('accepts an explicit method="post" as post', async () => {
+ const scenario = await buildSubmissionPayloadScenario({
+ submissionElements: [t('submission method="post"')],
+ });
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult.definition).toMatchObject({
+ submissionMethod: 'post',
+ });
+ });
+
+ it('treats method="form-data-post" as method="post"', async () => {
+ const scenario = await buildSubmissionPayloadScenario({
+ submissionElements: [t('submission method="form-data-post"')],
+ });
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult.definition).toMatchObject({
+ submissionMethod: 'post',
+ });
+ });
+
+ it.each(['nope', 'not-this-either', 'poast'])(
+ 'fails to load when form specifies unsupported submission method',
+ async (otherMethod) => {
+ const init = async () => {
+ await buildSubmissionPayloadScenario({
+ submissionElements: [t(`submission method="${otherMethod}"`)],
+ });
+ };
+
+ await expect(init).rejects.toThrow();
+ }
+ );
+
+ it('includes a form-specified `base64RsaPublicKey` as encryptionKey', async () => {
+ const base64RsaPublicKey = btoa(createUniqueId());
+ const scenario = await buildSubmissionPayloadScenario({
+ submissionElements: [
+ // Note: `t()` fails here, presumably because the ported JavaRosa
+ // `parseAttributes` doesn't expect equals signs as produced in
+ // the trailing base64 value.
+ new TagXFormsElement(
+ 'submission',
+ new Map([['base64RsaPublicKey', base64RsaPublicKey]]),
+ []
+ ),
+ ],
+ });
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult.definition).toMatchObject({
+ encryptionKey: base64RsaPublicKey,
+ });
+ });
+ });
+
+ describe('for a single (monolithic) request', () => {
+ describe('valid submission state', () => {
+ let scenario: Scenario;
+ let validSubmissionXML: string;
+
+ beforeEach(async () => {
+ scenario = await buildSubmissionPayloadScenario();
+
+ scenario.answer('/data/rep[1]/inp', 'rep 1 inp');
+ scenario.createNewRepeat('/data/rep');
+ scenario.answer('/data/rep[2]/inp', 'rep 2 inp');
+
+ // Check assumption: form state is valid
+ expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_OK);
+
+ // prettier-ignore
+ validSubmissionXML = t('data',
+ t('rep',
+ t('inp', 'rep 1 inp')),
+ t('rep',
+ t('inp', 'rep 2 inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))
+ ).asXml();
+ });
+
+ it('is ready for submission when instance state is valid', async () => {
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult).toBeReadyForSubmission();
+ });
+
+ it('includes submission instance XML file data', async () => {
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ await expect(submissionResult).toHavePreparedSubmissionXML(validSubmissionXML);
+ });
+ });
+
+ describe('invalid submission state', () => {
+ let scenario: Scenario;
+ let invalidSubmissionXML: string;
+
+ beforeEach(async () => {
+ scenario = await buildSubmissionPayloadScenario();
+
+ scenario.answer('/data/rep[1]/inp', 'rep 1 inp');
+ scenario.createNewRepeat('/data/rep');
+
+ // Check assumption: form state is valid
+ expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY);
+
+ // prettier-ignore
+ invalidSubmissionXML = t('data',
+ t('rep',
+ t('inp', 'rep 1 inp')),
+ t('rep',
+ t('inp')),
+ t('meta',
+ t('instanceID', DEFAULT_INSTANCE_ID))
+ ).asXml();
+ });
+
+ it('is pending submission with violations', async () => {
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ expect(submissionResult).toBePendingSubmissionWithViolations();
+ });
+
+ it('produces submission instance XML file data even when current instance state is invalid', async () => {
+ const submissionResult = await scenario.prepareWebFormsSubmission();
+
+ await expect(submissionResult).toHavePreparedSubmissionXML(invalidSubmissionXML);
+ });
+ });
+ });
+
+ describe.todo('for multiple requests, chunked by maximum size');
+ });
});
diff --git a/packages/ui-solid/CHANGELOG.md b/packages/ui-solid/CHANGELOG.md
index 9d2c816b8..2fde2b793 100644
--- a/packages/ui-solid/CHANGELOG.md
+++ b/packages/ui-solid/CHANGELOG.md
@@ -1,5 +1,17 @@
# @getodk/ui-solid
+## 0.2.3
+
+### Patch Changes
+
+- 8edf375: Initial engine support for preparing submissions
+- Updated dependencies [fcda4d8]
+- Updated dependencies [2fddcec]
+- Updated dependencies [e636a9c]
+- Updated dependencies [8edf375]
+ - @getodk/xforms-engine@0.5.0
+ - @getodk/common@0.5.0
+
## 0.2.2
### Patch Changes
diff --git a/packages/ui-solid/package.json b/packages/ui-solid/package.json
index 10848a4b3..23aec95ac 100644
--- a/packages/ui-solid/package.json
+++ b/packages/ui-solid/package.json
@@ -1,6 +1,6 @@
{
"name": "@getodk/ui-solid",
- "version": "0.2.2",
+ "version": "0.2.3",
"license": "Apache-2.0",
"description": "Solid UI client for ODK Web Forms",
"type": "module",
@@ -46,8 +46,8 @@
},
"dependencies": {
"@fontsource/roboto": "^5.1.0",
- "@getodk/common": "0.4.0",
- "@getodk/xforms-engine": "0.4.0",
+ "@getodk/common": "0.5.0",
+ "@getodk/xforms-engine": "0.5.0",
"@solidjs/router": "^0.14.5",
"@suid/icons-material": "^0.8.1",
"@suid/material": "^0.18.0",
diff --git a/packages/ui-solid/src/components/XForm/XFormDetails.tsx b/packages/ui-solid/src/components/XForm/XFormDetails.tsx
index 800a36e10..b7d140c45 100644
--- a/packages/ui-solid/src/components/XForm/XFormDetails.tsx
+++ b/packages/ui-solid/src/components/XForm/XFormDetails.tsx
@@ -1,6 +1,6 @@
-import type { AnyNode, RootNode } from '@getodk/xforms-engine';
+import type { RootNode } from '@getodk/xforms-engine';
import { styled } from '@suid/material';
-import { Show, createMemo, createSignal } from 'solid-js';
+import { Show, createSignal } from 'solid-js';
const Details = styled('details')({
position: 'relative',
@@ -24,66 +24,6 @@ export interface XFormDetailsProps {
readonly root: RootNode;
}
-let xmlEscaper: Element | null = null;
-
-const getEscaper = (): Element => {
- xmlEscaper = xmlEscaper ?? document.createElement('esc-aper');
-
- return xmlEscaper;
-};
-
-const escapeXMLText = (value: string) => {
- const escaper = getEscaper();
-
- escaper.textContent = value;
-
- const { innerHTML } = escaper;
-
- escaper.textContent = '';
-
- return innerHTML;
-};
-
-type FakeSerializationInterface = AnyNode & {
- readonly contextNode: {
- readonly textContent: string | null;
- };
-};
-
-const indentLine = (depth: number, line: string) => {
- const indentation = ''.padStart(depth, ' ');
-
- return `${indentation}${line}`;
-};
-
-const serializeNode = (node: AnyNode, depth = 0): string => {
- node = node as FakeSerializationInterface;
-
- const { currentState, definition } = node;
- const { children } = currentState;
- const { nodeName } = definition;
-
- if (children == null) {
- // Just read it to make it reactive...
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- read == subscribe
- currentState.value;
-
- const serializedLeafNode = `<${nodeName}>${escapeXMLText((node as FakeSerializationInterface).contextNode.textContent ?? '')}${nodeName}>`;
-
- return indentLine(depth, serializedLeafNode);
- }
-
- return [
- indentLine(depth, `<${nodeName}>`),
- children.map((child) => {
- return serializeNode(child, depth + 1);
- }),
- indentLine(depth, `${nodeName}>`),
- ]
- .flat()
- .join('\n');
-};
-
export const XFormDetails = (props: XFormDetailsProps) => {
const [showSubmissionState, setShowSubmissionState] = createSignal(false);
@@ -97,11 +37,7 @@ export const XFormDetails = (props: XFormDetailsProps) => {
Submission state (XML)
{(_) => {
- const submissionState = createMemo(() => {
- return serializeNode(props.root);
- });
-
- return {submissionState()}
;
+ return {props.root.submissionState.submissionXML}
;
}}
diff --git a/packages/web-forms/CHANGELOG.md b/packages/web-forms/CHANGELOG.md
index d7e1926a6..329868755 100644
--- a/packages/web-forms/CHANGELOG.md
+++ b/packages/web-forms/CHANGELOG.md
@@ -1,5 +1,17 @@
# @getodk/web-forms
+## 0.6.0
+
+### Minor Changes
+
+- fcda4d8: Support for numeric controls:
+
+ - inputs with `int` and `decimal` types
+ - inputs with `appearance="numbers"`
+ - (all types) support for `appearance="thousands-sep"`
+
+- 2fddcec: Support for external secondary instances (XML, CSV, GeoJSON)
+
## 0.5.0
### Minor Changes
diff --git a/packages/web-forms/e2e/build/wf-preview.test.ts b/packages/web-forms/e2e/build/wf-preview.test.ts
new file mode 100644
index 000000000..4d6c28df5
--- /dev/null
+++ b/packages/web-forms/e2e/build/wf-preview.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from '@playwright/test';
+
+test('Web Forms Preview: demo forms load', async ({ context, page }) => {
+ await page.goto('http://localhost:5174/');
+
+ const formPreviewLinks = await page.locator('.form-preview-link').all();
+
+ expect(formPreviewLinks.length).toBeGreaterThan(0);
+
+ for await (const link of formPreviewLinks) {
+ const [previewPage] = await Promise.all([context.waitForEvent('page'), link.click()]);
+
+ await previewPage.waitForSelector(
+ '.form-initialization-status.error, .form-initialization-status.ready',
+ {
+ state: 'attached',
+ }
+ );
+
+ const [failureDialog] = await previewPage.locator('.form-load-failure-dialog').all();
+
+ expect(failureDialog).toBeUndefined();
+
+ await previewPage.close();
+ }
+});
diff --git a/packages/web-forms/package.json b/packages/web-forms/package.json
index 2ccad287d..ddb30abbd 100644
--- a/packages/web-forms/package.json
+++ b/packages/web-forms/package.json
@@ -1,6 +1,6 @@
{
"name": "@getodk/web-forms",
- "version": "0.5.0",
+ "version": "0.6.0",
"license": "Apache-2.0",
"description": "ODK Web Forms",
"author": "getodk",
@@ -52,7 +52,7 @@
"@faker-js/faker": "^9.0.2",
"@fontsource/hanken-grotesk": "^5.1.0",
"@fontsource/roboto": "^5.1.0",
- "@getodk/xforms-engine": "0.4.0",
+ "@getodk/xforms-engine": "0.5.0",
"@playwright/test": "^1.47.2",
"@types/ramda": "^0.30.2",
"@vitejs/plugin-vue": "^5.1.4",
diff --git a/packages/web-forms/src/components/FormQuestion.vue b/packages/web-forms/src/components/FormQuestion.vue
index cc17ac8e6..fd5e89fda 100644
--- a/packages/web-forms/src/components/FormQuestion.vue
+++ b/packages/web-forms/src/components/FormQuestion.vue
@@ -1,13 +1,13 @@
@@ -38,7 +55,9 @@ const isModelOnlyNode = (node: GeneralChildNode): node is ModelValueNode | Subtr
-
+
+
+
diff --git a/packages/web-forms/src/components/controls/Input/InputControl.vue b/packages/web-forms/src/components/controls/Input/InputControl.vue
new file mode 100644
index 000000000..d5e9fcc5a
--- /dev/null
+++ b/packages/web-forms/src/components/controls/Input/InputControl.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-forms/src/components/controls/Input/InputDecimal.vue b/packages/web-forms/src/components/controls/Input/InputDecimal.vue
new file mode 100644
index 000000000..2793ecbda
--- /dev/null
+++ b/packages/web-forms/src/components/controls/Input/InputDecimal.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/packages/web-forms/src/components/controls/Input/InputInt.vue b/packages/web-forms/src/components/controls/Input/InputInt.vue
new file mode 100644
index 000000000..9a9e7e8fc
--- /dev/null
+++ b/packages/web-forms/src/components/controls/Input/InputInt.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/packages/web-forms/src/components/controls/Input/InputNumbersAppearance.vue b/packages/web-forms/src/components/controls/Input/InputNumbersAppearance.vue
new file mode 100644
index 000000000..22ffabad0
--- /dev/null
+++ b/packages/web-forms/src/components/controls/Input/InputNumbersAppearance.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/packages/web-forms/src/components/controls/Input/InputNumeric.vue b/packages/web-forms/src/components/controls/Input/InputNumeric.vue
new file mode 100644
index 000000000..772823247
--- /dev/null
+++ b/packages/web-forms/src/components/controls/Input/InputNumeric.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
diff --git a/packages/web-forms/src/components/controls/Input/InputText.vue b/packages/web-forms/src/components/controls/Input/InputText.vue
new file mode 100644
index 000000000..aed0b2897
--- /dev/null
+++ b/packages/web-forms/src/components/controls/Input/InputText.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
diff --git a/packages/web-forms/src/components/controls/InputText.vue b/packages/web-forms/src/components/controls/InputText.vue
deleted file mode 100644
index 6e42a4b57..000000000
--- a/packages/web-forms/src/components/controls/InputText.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/web-forms/src/components/dev-only/ExpectModelNode.vue b/packages/web-forms/src/components/dev-only/ExpectModelNode.vue
new file mode 100644
index 000000000..ab8feb04c
--- /dev/null
+++ b/packages/web-forms/src/components/dev-only/ExpectModelNode.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+ {{ displayMessage }}
+
+
+
diff --git a/packages/web-forms/src/demo/DemoForm.vue b/packages/web-forms/src/demo/DemoForm.vue
index 5acfca450..f56f70372 100644
--- a/packages/web-forms/src/demo/DemoForm.vue
+++ b/packages/web-forms/src/demo/DemoForm.vue
@@ -44,7 +44,7 @@ const formXls = computed(() => {
-
+
diff --git a/packages/web-forms/src/demo/FormPreview.vue b/packages/web-forms/src/demo/FormPreview.vue
index 8517b88d9..506c66a72 100644
--- a/packages/web-forms/src/demo/FormPreview.vue
+++ b/packages/web-forms/src/demo/FormPreview.vue
@@ -1,5 +1,7 @@
-
-
+
+
diff --git a/packages/web-forms/tests/components/ControlLabel.test.ts b/packages/web-forms/tests/components/ControlLabel.test.ts
index cac00c01d..bdbbf0f20 100644
--- a/packages/web-forms/tests/components/ControlLabel.test.ts
+++ b/packages/web-forms/tests/components/ControlLabel.test.ts
@@ -1,18 +1,18 @@
-import type { StringNode } from '@getodk/xforms-engine';
+import type { AnyInputNode } from '@getodk/xforms-engine';
import { mount } from '@vue/test-utils';
import { assocPath } from 'ramda';
import { describe, expect, it } from 'vitest';
import ControlLabel from '../../src/components/ControlLabel.vue';
const baseQuestion = {
- nodeType: 'string',
+ nodeType: 'input',
currentState: {
required: true,
label: {
asString: 'First Name',
},
},
-} as StringNode;
+} as AnyInputNode;
describe('ControlLabel', () => {
it('shows asterisk with field is required', () => {
diff --git a/packages/web-forms/tests/components/FormQuestion.test.ts b/packages/web-forms/tests/components/FormQuestion.test.ts
index a6e531207..d025a966b 100644
--- a/packages/web-forms/tests/components/FormQuestion.test.ts
+++ b/packages/web-forms/tests/components/FormQuestion.test.ts
@@ -1,4 +1,4 @@
-import InputText from '@/components/controls/InputText.vue';
+import InputControl from '@/components/controls/Input/InputControl.vue';
import SelectControl from '@/components/controls/SelectControl.vue';
import UnsupportedControl from '@/components/controls/UnsupportedControl.vue';
import type { SelectNode } from '@getodk/xforms-engine';
@@ -19,12 +19,12 @@ const mountComponent = async (formPath: string, questionNumber: number) => {
};
describe('FormQuestion', () => {
- it('shows InputText control for string nodes', async () => {
+ it('shows InputControl control for string nodes', async () => {
const component = await mountComponent('minimal.xform.xml', 0);
- const inputText = component.findComponent(InputText);
+ const inputControl = component.findComponent(InputControl);
- expect(inputText.exists()).toBe(true);
+ expect(inputControl.exists()).toBe(true);
expect(component.text()).toBe('First question');
});
diff --git a/packages/web-forms/tests/components/OdkWebForm.test.ts b/packages/web-forms/tests/components/OdkWebForm.test.ts
index fc94b62f8..0c49a7689 100644
--- a/packages/web-forms/tests/components/OdkWebForm.test.ts
+++ b/packages/web-forms/tests/components/OdkWebForm.test.ts
@@ -13,6 +13,9 @@ const mountComponent = (formXML: string) => {
const component = mount(OdkWebForm, {
props: {
formXml: formXML,
+ fetchFormAttachment: () => {
+ throw new Error('Not exercised here');
+ },
},
global: globalMountOptions,
attachTo: document.body,
diff --git a/packages/web-forms/tests/components/controls/InputText.test.ts b/packages/web-forms/tests/components/controls/Input/InputControl.test.ts
similarity index 80%
rename from packages/web-forms/tests/components/controls/InputText.test.ts
rename to packages/web-forms/tests/components/controls/Input/InputControl.test.ts
index 12e4c8a8a..8e8375e6f 100644
--- a/packages/web-forms/tests/components/controls/InputText.test.ts
+++ b/packages/web-forms/tests/components/controls/Input/InputControl.test.ts
@@ -1,22 +1,24 @@
-import InputText from '@/components/controls/InputText.vue';
-import type { StringNode } from '@getodk/xforms-engine';
+import InputControl from '@/components/controls/Input/InputControl.vue';
import { mount } from '@vue/test-utils';
-import { describe, expect, it } from 'vitest';
-import { getReactiveForm, globalMountOptions } from '../../helpers';
+import { assert, describe, expect, it } from 'vitest';
+import { getReactiveForm, globalMountOptions } from '../../../helpers';
const mountComponent = async (questionNumber: number, submitPressed = false) => {
const xform = await getReactiveForm('1-validation.xml');
+ const node = xform.currentState.children[questionNumber];
- return mount(InputText, {
+ assert(node.nodeType === 'input');
+
+ return mount(InputControl, {
props: {
- question: xform.currentState.children[questionNumber] as StringNode,
+ node,
},
global: { ...globalMountOptions, provide: { submitPressed } },
attachTo: document.body,
});
};
-describe('InputText', () => {
+describe('InputControl', () => {
describe('validation', () => {
it('does not show validation message on init', async () => {
const component = await mountComponent(0);
diff --git a/packages/xforms-engine/CHANGELOG.md b/packages/xforms-engine/CHANGELOG.md
index 9c71d8e1b..79568e122 100644
--- a/packages/xforms-engine/CHANGELOG.md
+++ b/packages/xforms-engine/CHANGELOG.md
@@ -1,5 +1,14 @@
# @getodk/xforms-engine
+## 0.5.0
+
+### Minor Changes
+
+- fcda4d8: Support for decimal and int bind types (model values and inputs)
+- 2fddcec: Support for external secondary instances (XML, CSV, GeoJSON)
+- e636a9c: XPath support for evaluation of arbitrary DOM implementations (XPathDOMAdapter)
+- 8edf375: Initial engine support for preparing submissions
+
## 0.4.0
### Minor Changes
diff --git a/packages/xforms-engine/package.json b/packages/xforms-engine/package.json
index d4b22415c..b9a3b515b 100644
--- a/packages/xforms-engine/package.json
+++ b/packages/xforms-engine/package.json
@@ -1,6 +1,6 @@
{
"name": "@getodk/xforms-engine",
- "version": "0.4.0",
+ "version": "0.5.0",
"license": "Apache-2.0",
"description": "XForms engine for ODK Web Forms",
"type": "module",
@@ -54,13 +54,15 @@
"test:types": "tsc --project ./tsconfig.json --emitDeclarationOnly false --noEmit"
},
"dependencies": {
+ "papaparse": "^5.4.1",
"solid-js": "^1.9.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@getodk/tree-sitter-xpath": "0.1.2",
- "@getodk/xpath": "0.2.1",
+ "@getodk/xpath": "0.3.0",
"@playwright/test": "^1.47.2",
+ "@types/papaparse": "^5.3.15",
"@vitest/browser": "^2.1.1",
"babel-plugin-transform-jsbi-to-bigint": "^1.4.0",
"http-server": "^14.1.1",
diff --git a/packages/xforms-engine/src/client/BaseNode.ts b/packages/xforms-engine/src/client/BaseNode.ts
index 7c06ca7be..62545794d 100644
--- a/packages/xforms-engine/src/client/BaseNode.ts
+++ b/packages/xforms-engine/src/client/BaseNode.ts
@@ -3,7 +3,9 @@ import type { AnyNodeDefinition } from '../parse/model/NodeDefinition.ts';
import type { NodeAppearances } from './NodeAppearances.ts';
import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts';
import type { TextRange } from './TextRange.ts';
+import type { FormNodeID } from './identity.ts';
import type { InstanceNodeType } from './node-types.ts';
+import type { SubmissionState } from './submission/SubmissionState.ts';
import type {
AncestorNodeValidationState,
LeafNodeValidationState,
@@ -129,8 +131,6 @@ export interface BaseNodeState {
get value(): unknown;
}
-type FormNodeID = string;
-
/**
* Base interface for common/shared aspects of any node type.
*/
@@ -189,15 +189,13 @@ export interface BaseNode {
// 2. If `parent` does become nullable state, how best to convey the same
// information for removed descendants. Some ideas:
//
- // - Apply null-as-removed recursively. This wouldn't technically be true
- // for the engine's current use of a DOM backing store (but that's an
- // implementation detail clients don't/shouldn't care about).
+ // - Apply null-as-removed recursively.
//
// - Borrow the browser DOM's notion of node "connected"-ness. When a node
// is removed, its `isConnected` property is `false`. The same is true
// for any of its descendants, even though they retain their own direct
// parent reference.
- readonly parent: BaseNode | null;
+ readonly parent: unknown;
/**
* Each node provides a discrete object representing the stateful aspects\* of
@@ -251,4 +249,16 @@ export interface BaseNode {
* clients to explicitly pause and resume recomputation.
*/
readonly validationState: NodeValidationState;
+
+ /**
+ * Represents the current submission state of the node.
+ *
+ * @see {@link SubmissionState.submissionXML} for additional detail.
+ *
+ * @todo Consider whether this can (should) be merged with
+ * {@link currentState}, while providing the same client-reactivity
+ * guarantees. (The challenge there is in defining client-reactive state which
+ * self-referentially derives state from its own definition.)
+ */
+ readonly submissionState: SubmissionState;
}
diff --git a/packages/xforms-engine/src/client/BaseValueNode.ts b/packages/xforms-engine/src/client/BaseValueNode.ts
new file mode 100644
index 000000000..2a376aaaf
--- /dev/null
+++ b/packages/xforms-engine/src/client/BaseValueNode.ts
@@ -0,0 +1,35 @@
+import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
+import type { BaseNode, BaseNodeState } from './BaseNode.ts';
+import type { GeneralParentNode } from './hierarchy.ts';
+import type { LeafNodeType } from './node-types.ts';
+import type { LeafNodeValidationState } from './validation.ts';
+import type { ValueType } from './ValueType.ts';
+
+export interface BaseValueNodeState
extends BaseNodeState {
+ get children(): null;
+ get valueOptions(): null;
+ get value(): Value;
+
+ /**
+ * Reflects the serialized string representation of a {@link BaseValueNode}'s
+ * {@link value} state. This representation allows access to the node's value
+ * _as primary instance state_. In other words, this is the value which:
+ *
+ * - would be serialized as a text node in
+ * {@link SubmissionState.submissionXML} (note: this value is **NOT**
+ * escaped for XML serialization, as it is there)
+ *
+ * - is used when the node's value is referenced in any of a form's XPath
+ * expressions
+ */
+ get instanceValue(): string;
+}
+
+export interface BaseValueNode extends BaseNode {
+ readonly nodeType: LeafNodeType;
+ readonly valueType: V;
+ readonly definition: LeafNodeDefinition;
+ readonly parent: GeneralParentNode;
+ readonly currentState: BaseValueNodeState;
+ readonly validationState: LeafNodeValidationState;
+}
diff --git a/packages/xforms-engine/src/client/EngineConfig.ts b/packages/xforms-engine/src/client/EngineConfig.ts
index c346a30e3..81e6e3d46 100644
--- a/packages/xforms-engine/src/client/EngineConfig.ts
+++ b/packages/xforms-engine/src/client/EngineConfig.ts
@@ -1,35 +1,7 @@
+import type { initializeForm } from '../instance/index.ts';
+import type { MissingResourceBehavior, MissingResourceBehaviorDefault } from './constants.ts';
import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts';
-
-/**
- * @todo this is currently a strict subset of the web standard `Response`. Is it
- * sufficient? Ways it might not be:
- *
- * - No way to convey metadata about the resource
- * - Ambiguous if a client supplies an alternative implementation which doesn't
- * exhaust the body on access
- */
-export interface FetchResourceResponse {
- readonly ok?: boolean;
- readonly body?: ReadableStream | null;
- readonly bodyUsed?: boolean;
-
- readonly blob: () => Promise;
- readonly text: () => Promise;
-}
-
-/**
- * @todo this is a strict subset of the web standard `fetch` interface. It
- * implicitly assumes that the engine itself will only ever issue `GET`-like
- * requests. It also provides no further request-like semantics to the engine.
- * This is presumed sufficient for now, but notably doesn't expose any notion of
- * content negotiation (e.g. the ability to supply `Accept` headers).
- *
- * This also completely ignores any notion of mapping
- * {@link https://getodk.github.io/xforms-spec/#uris | `jr:` URLs} to their
- * actual resources (likely, but not necessarily, accessed at a corresponding
- * HTTP URL).
- */
-export type FetchResource = (resource: URL) => Promise;
+import type { FetchFormAttachment, FetchResource } from './resources.ts';
/**
* Options provided by a client to specify certain aspects of engine runtime
@@ -55,29 +27,58 @@ export interface EngineConfig {
readonly stateFactory?: OpaqueReactiveObjectFactory;
/**
- * A client may specify a generic function for retrieving resources referenced
- * by a form, such as:
*
- * - Form definitions themselves (if not provided directly to the engine by
- * the client)
- * - External secondary instances
- * - Media (images, audio, video, etc.)
+ * A client may specify an arbitrary {@link fetch}-like function for retrieving an XML XForm form
+ * definition.
+ *
+ * Notes:
+ *
+ * - This configuration will only be consuled for calls to
+ * {@link initializeForm} with a URL referencing an XML XForm definition. It
+ * will be ignored for calls passing an XML XForm form definition directly.
+ *
+ * - For calls to {@link initializeForm} with a URL, if this configuration is
+ * not specified it will default to the global {@link fetch} function (if
+ * one is defined).
+ */
+ readonly fetchFormDefinition?: FetchResource;
+
+ /**
+ * A client may specify an arbitrary {@link fetch}-like function to retrieve a
+ * form's attachments, i.e. any `jr:` URL referenced by the form (as specified
+ * by {@link https://getodk.github.io/xforms-spec/ | ODK XForms}).
+ *
+ * Notes:
*
- * The function is expected to be a subset of the
- * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API},
- * performing `GET` requests for:
+ * - This configuration will be consulted for all supported form attachments,
+ * as a part of {@link initializeForm | form initialization}.
*
- * - Text resources (e.g. XML, CSV, JSON/GeoJSON)
- * - Binary resources (e.g. media)
- * - Optionally streamed binary data of either (e.g. for optimized
- * presentation of audio/video)
+ * - If this configuration is not specified it will default to the global
+ * {@link fetch} function (if one is defined).
+ *
+ * This default behavior will typically result in failure to load form
+ * attachments—and in most cases this will also cause
+ * {@link initializeForm | form initialization} to fail overall—with the
+ * following exceptions:
+ *
+ * - **CLIENT-SPECIFIC:** Usage in coordination with a client-implemented
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API | Serivce Worker},
+ * which can intercept network requests **generally**. Clients already using
+ * a Service Worker may opt for the convenience of handling network requests
+ * for `jr:` URLs along with any other network interception logic. Client
+ * implementors should be warned, however, that such `jr:` URLs are not
+ * namespaced or otherwise scoped to a particular form; such a client would
+ * therefore inherently need to coordinate state between the Service Worker
+ * and the main thread (or whatever other realm calls
+ * {@link initializeForm}).
+ */
+ readonly fetchFormAttachment?: FetchFormAttachment;
+
+ /**
+ * @see {@link MissingResourceBehavior}
+ * @see {@link MissingResourceBehaviorDefault}
*
- * If provided by a client, this function will be used by the engine to
- * retrieve any such resource, as required for engine functionality. If
- * absent, the engine will use the native `fetch` function (if available, a
- * polyfill otherwise). Clients may use this function to provide resources
- * from sources other than the network, (or even in a test client to provide
- * e.g. resources from test fixtures).
+ * @default MissingResourceBehaviorDefault
*/
- readonly fetchResource?: FetchResource;
+ readonly missingResourceBehavior?: MissingResourceBehavior;
}
diff --git a/packages/xforms-engine/src/client/InputNode.ts b/packages/xforms-engine/src/client/InputNode.ts
new file mode 100644
index 000000000..de8c3a62e
--- /dev/null
+++ b/packages/xforms-engine/src/client/InputNode.ts
@@ -0,0 +1,77 @@
+import type { RuntimeInputValue, RuntimeValue } from '../lib/codecs/getSharedValueCodec.ts';
+import type { InputControlDefinition } from '../parse/body/control/InputControlDefinition.ts';
+import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
+import type { BaseValueNode, BaseValueNodeState } from './BaseValueNode.ts';
+import type { NodeAppearances } from './NodeAppearances.ts';
+import type { RootNode } from './RootNode.ts';
+import type { ValueType } from './ValueType.ts';
+import type { GeneralParentNode } from './hierarchy.ts';
+import type { LeafNodeValidationState } from './validation.ts';
+
+export type InputValue = RuntimeValue;
+
+export type InputNodeInputValue = RuntimeInputValue;
+
+export interface InputNodeState extends BaseValueNodeState> {
+ get children(): null;
+ get valueOptions(): null;
+
+ /**
+ * Reflects the current value of a {@link InputNode}. This value may be
+ * populated when a form is loaded, and it may be updated by certain
+ * computations defined by the form. It may also be updated by a client, using
+ * the {@link InputNode.setValue} method.
+ */
+ get value(): InputValue;
+}
+
+export interface InputDefinition extends LeafNodeDefinition {
+ readonly bodyElement: InputControlDefinition;
+}
+
+export type InputNodeAppearances = NodeAppearances;
+
+/**
+ * A node corresponding to form field defined as an
+ * {@link https://getodk.github.io/xforms-spec/#body-elements | XForms ``},
+ * which a user-facing client would likely present for a user to fill..
+ */
+export interface InputNode
+ extends BaseValueNode> {
+ readonly nodeType: 'input';
+ readonly valueType: V;
+ readonly appearances: InputNodeAppearances;
+ readonly definition: InputDefinition;
+ readonly root: RootNode;
+ readonly parent: GeneralParentNode;
+ readonly currentState: InputNodeState;
+ readonly validationState: LeafNodeValidationState;
+
+ /**
+ * For use by a client to update the value of an {@link InputNode}.
+ */
+ setValue(value: InputNodeInputValue): RootNode;
+}
+
+export type StringInputNode = InputNode<'string'>;
+export type IntInputNode = InputNode<'int'>;
+export type DecimalInputNode = InputNode<'decimal'>;
+
+// prettier-ignore
+type SupportedInputValueType =
+ // eslint-disable-next-line @typescript-eslint/sort-type-constituents
+ | 'string'
+ | 'int'
+ | 'decimal';
+
+type TemporaryStringValueType = Exclude;
+
+export type TemporaryStringValueInputNode = InputNode;
+
+// prettier-ignore
+export type AnyInputNode =
+ // eslint-disable-next-line @typescript-eslint/sort-type-constituents
+ | StringInputNode
+ | IntInputNode
+ | DecimalInputNode
+ | TemporaryStringValueInputNode;
diff --git a/packages/xforms-engine/src/client/ModelValueNode.ts b/packages/xforms-engine/src/client/ModelValueNode.ts
index 509fcd1db..0398fd418 100644
--- a/packages/xforms-engine/src/client/ModelValueNode.ts
+++ b/packages/xforms-engine/src/client/ModelValueNode.ts
@@ -1,10 +1,15 @@
+import type { RuntimeValue } from '../lib/codecs/getSharedValueCodec.ts';
import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
-import type { BaseNode, BaseNodeState } from './BaseNode.ts';
+import type { BaseValueNode, BaseValueNodeState } from './BaseValueNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { RootNode } from './RootNode.ts';
import type { LeafNodeValidationState } from './validation.ts';
+import type { ValueType } from './ValueType.ts';
-export interface ModelValueNodeState extends BaseNodeState {
+export type ModelValue = RuntimeValue;
+
+export interface ModelValueNodeState
+ extends BaseValueNodeState> {
get label(): null;
get hint(): null;
get children(): null;
@@ -15,10 +20,11 @@ export interface ModelValueNodeState extends BaseNodeState {
* populated when a form is loaded, and it may be updated by certain
* computations defined by the form.
*/
- get value(): string;
+ get value(): ModelValue;
}
-export interface ModelValueDefinition extends LeafNodeDefinition {
+export interface ModelValueDefinition
+ extends LeafNodeDefinition {
readonly bodyElement: null;
}
@@ -29,12 +35,29 @@ export interface ModelValueDefinition extends LeafNodeDefinition {
* - a leaf/value node (i.e. it has no element children; it may be defined in
* the form's `` as either an {@link Element} or {@link Attr})
*/
-export interface ModelValueNode extends BaseNode {
+export interface ModelValueNode
+ extends BaseValueNode> {
readonly nodeType: 'model-value';
+ readonly valueType: V;
readonly appearances: null;
- readonly definition: ModelValueDefinition;
+ readonly definition: ModelValueDefinition;
readonly root: RootNode;
readonly parent: GeneralParentNode;
- readonly currentState: ModelValueNodeState;
+ readonly currentState: ModelValueNodeState;
readonly validationState: LeafNodeValidationState;
}
+
+export type AnyModelValueNode =
+ | ModelValueNode<'barcode'>
+ | ModelValueNode<'binary'>
+ | ModelValueNode<'boolean'>
+ | ModelValueNode<'date'>
+ | ModelValueNode<'dateTime'>
+ | ModelValueNode<'decimal'>
+ | ModelValueNode<'geopoint'>
+ | ModelValueNode<'geoshape'>
+ | ModelValueNode<'geotrace'>
+ | ModelValueNode<'int'>
+ | ModelValueNode<'intent'>
+ | ModelValueNode<'string'>
+ | ModelValueNode<'time'>;
diff --git a/packages/xforms-engine/src/client/NoteNode.ts b/packages/xforms-engine/src/client/NoteNode.ts
index f881d5d5b..d2cc3c68c 100644
--- a/packages/xforms-engine/src/client/NoteNode.ts
+++ b/packages/xforms-engine/src/client/NoteNode.ts
@@ -1,4 +1,4 @@
-import type { InputDefinition } from '../parse/body/control/InputDefinition.ts';
+import type { InputControlDefinition } from '../parse/body/control/InputControlDefinition.ts';
import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
@@ -52,7 +52,7 @@ export interface NoteNodeState extends BaseNodeState {
}
export interface NoteDefinition extends LeafNodeDefinition {
- readonly bodyElement: InputDefinition;
+ readonly bodyElement: InputControlDefinition;
}
export type NoteNodeAppearances = NodeAppearances;
diff --git a/packages/xforms-engine/src/client/RootNode.ts b/packages/xforms-engine/src/client/RootNode.ts
index 32adee1a7..5e3dcffca 100644
--- a/packages/xforms-engine/src/client/RootNode.ts
+++ b/packages/xforms-engine/src/client/RootNode.ts
@@ -3,14 +3,14 @@ import type { RootDefinition } from '../parse/model/RootDefinition.ts';
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { ActiveLanguage, FormLanguage, FormLanguages } from './FormLanguage.ts';
import type { GeneralChildNode } from './hierarchy.ts';
+import type { SubmissionChunkedType, SubmissionOptions } from './submission/SubmissionOptions.ts';
+import type { SubmissionResult } from './submission/SubmissionResult.ts';
import type { AncestorNodeValidationState } from './validation.ts';
export interface RootNodeState extends BaseNodeState {
/**
- * This, along with {@link RootNode.languages} is the most significant break
- in consistency across node types' state and static properties. Exposing it
- across all node types seems like a point of potential confusion, so this
- particular divergence seems like the most reasonable compromise.
+ * @todo If we ever expose an interface to the primary instance document, it
+ * would make sense to move this state up.
*/
get activeLanguage(): ActiveLanguage;
@@ -46,15 +46,45 @@ export interface RootNode extends BaseNode {
readonly definition: RootDefinition;
readonly root: RootNode;
- readonly parent: null;
+ readonly parent: unknown;
readonly currentState: RootNodeState;
readonly validationState: AncestorNodeValidationState;
/**
- * @todo as with {@link RootNodeState.activeLanguage}, this is the most
- * significant break in consistency across node types.
+ * @todo as discussed on {@link RootNodeState.activeLanguage}
*/
readonly languages: FormLanguages;
setLanguage(language: FormLanguage): RootNode;
+
+ /**
+ * Prepares the current form instance state for submission.
+ *
+ * A {@link SubmissionResult} will be prepared even if the current form state
+ * includes `constraint` or `required` violations. This is intended to serve
+ * two purposes:
+ *
+ * - A client may effectively use this method as a part of its own "submit"
+ * routine, and use any violations included in the {@link SubmissionResult}
+ * to prompt users to address those violations.
+ *
+ * - A client may inspect the submission state of a form at any time.
+ * Depending on the client and use case, this may be a convenience (e.g. for
+ * developers to inspect that form state at a current point in time); or it
+ * may provide necessary functionality (e.g. for test or tooling clients).
+ *
+ * Note on asynchrony: preparing a {@link SubmissionResult} is expected to be
+ * a fast operation. It may even be nearly instantaneous, or roughly
+ * proportionate to the size of the form itself. However, this method is
+ * designed to be asynchronous out of an abundance of caution, anticipating
+ * that some as-yet undeveloped operations on binary data (e.g. form
+ * attachments) may themselves impose asynchrony (i.e. by interfaces provided
+ * by the platform and/or external dependencies).
+ *
+ * A client may specify {@link SubmissionOptions<'chunked'>}, in which case a
+ * {@link SubmissionResult<'chunked'>} will be produced, with form attachments
+ */
+ prepareSubmission(
+ options?: SubmissionOptions
+ ): Promise>;
}
diff --git a/packages/xforms-engine/src/client/SelectNode.ts b/packages/xforms-engine/src/client/SelectNode.ts
index 295820d29..13cfb9d91 100644
--- a/packages/xforms-engine/src/client/SelectNode.ts
+++ b/packages/xforms-engine/src/client/SelectNode.ts
@@ -1,9 +1,9 @@
import type { AnySelectDefinition } from '../parse/body/control/select/SelectDefinition.ts';
import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
+import type { InputNode } from './InputNode.ts';
import type { NodeAppearances } from './NodeAppearances.ts';
import type { RootNode } from './RootNode.ts';
-import type { StringNode } from './StringNode.ts';
import type { TextRange } from './TextRange.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { LeafNodeValidationState } from './validation.ts';
@@ -60,7 +60,7 @@ export interface SelectNode extends BaseNode {
* - For fields defined with an XForms ``, calling this method will
* replace the selection (if any).
*
- * @todo @see {@link StringNode.setValue} re: write restrictions
+ * @todo @see {@link InputNode.setValue} re: write restrictions
* @todo @see {@link SelectNodeState.value} re: breaking up the types
*/
select(item: SelectItem): RootNode;
@@ -69,7 +69,7 @@ export interface SelectNode extends BaseNode {
* For use by a client to remove an item from the node's
* {@link SelectNodeState.value}.
*
- * @todo @see {@link StringNode.setValue} re: write restrictions
+ * @todo @see {@link InputNode.setValue} re: write restrictions
*/
deselect(item: SelectItem): RootNode;
}
diff --git a/packages/xforms-engine/src/client/StringNode.ts b/packages/xforms-engine/src/client/StringNode.ts
deleted file mode 100644
index c3da2da80..000000000
--- a/packages/xforms-engine/src/client/StringNode.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { InputDefinition } from '../parse/body/control/InputDefinition.ts';
-import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
-import type { BaseNode, BaseNodeState } from './BaseNode.ts';
-import type { NodeAppearances } from './NodeAppearances.ts';
-import type { RootNode } from './RootNode.ts';
-import type { GeneralParentNode } from './hierarchy.ts';
-import type { LeafNodeValidationState } from './validation.ts';
-
-export interface StringNodeState extends BaseNodeState {
- get children(): null;
- get valueOptions(): null;
-
- /**
- * Reflects the current value of a {@link StringNode}. This value may be
- * populated when a form is loaded, and it may be updated by certain
- * computations defined by the form. It may also be updated by a client, using
- * the {@link StringNode.setValue} method.
- */
- get value(): string;
-}
-
-export interface StringDefinition extends LeafNodeDefinition {
- readonly bodyElement: InputDefinition;
-}
-
-export type StringNodeAppearances = NodeAppearances;
-
-/**
- * A node which can be assigned a string/text value. A string node **MAY**
- * correspond to form field defined as an XForms ``, which a user-facing
- * client would likely present for a user to fill. It may not correspond to an
- * ``, or necessarily have any presentational implications for a client
- * (for instance if the node is bound to an XForms `calculate` expression).
- */
-export interface StringNode extends BaseNode {
- readonly nodeType: 'string';
- readonly appearances: StringNodeAppearances;
- readonly definition: StringDefinition;
- readonly root: RootNode;
- readonly parent: GeneralParentNode;
- readonly currentState: StringNodeState;
- readonly validationState: LeafNodeValidationState;
-
- /**
- * For use by a client to update the value of a string node.
- *
- * @todo [how] should we express write restrictions to a client? E.g. what
- * happens when a string node is readonly, and a client attempts to call this
- * method?
- */
- setValue(value: string): RootNode;
-}
diff --git a/packages/xforms-engine/src/client/ValueType.ts b/packages/xforms-engine/src/client/ValueType.ts
new file mode 100644
index 000000000..6ca02f2cc
--- /dev/null
+++ b/packages/xforms-engine/src/client/ValueType.ts
@@ -0,0 +1,4 @@
+import type { ExpandUnion } from '@getodk/common/types/helpers.js';
+import type { BindType } from '../parse/model/BindTypeDefinition.ts';
+
+export type ValueType = ExpandUnion;
diff --git a/packages/xforms-engine/src/client/constants.ts b/packages/xforms-engine/src/client/constants.ts
index 0fb7f1a15..676454453 100644
--- a/packages/xforms-engine/src/client/constants.ts
+++ b/packages/xforms-engine/src/client/constants.ts
@@ -1,5 +1,66 @@
+import type { PrimaryInstance } from '../instance/PrimaryInstance.ts';
+import type { InitializeForm } from './index.ts';
import type { ValidationTextRole } from './TextRange.ts';
+export const MISSING_RESOURCE_BEHAVIOR = {
+ /**
+ * When this behavior is configured, {@link InitializeForm | initializing} a
+ * {@link PrimaryInstance} for a form which references any **missing**
+ * resources will fail, producing an error to the calling client.
+ *
+ * @see {@link MissingResourceBehavior}
+ */
+ ERROR: 'ERROR',
+
+ /**
+ * When this behavior is configured, {@link InitializeForm | initializing} a
+ * {@link PrimaryInstance} for a form which references any **missing**
+ * resources will succeed (producing a warning).
+ *
+ * Such missing resources will be parsed as if they are blank, as appropriate
+ * for the resource's XForm semantic usage and/or format.
+ *
+ * @see {@link MissingResourceBehavior}
+ */
+ BLANK: 'BLANK',
+
+ /**
+ * @see {@link MISSING_RESOURCE_BEHAVIOR.ERROR}
+ */
+ get DEFAULT(): 'ERROR' {
+ return MISSING_RESOURCE_BEHAVIOR.ERROR;
+ },
+} as const;
+
+export type MissingResourceBehaviorError = typeof MISSING_RESOURCE_BEHAVIOR.ERROR;
+
+export type MissingResourceBehaviorBlank = typeof MISSING_RESOURCE_BEHAVIOR.BLANK;
+
+export type MissingResourceBehaviorDefault = typeof MISSING_RESOURCE_BEHAVIOR.DEFAULT;
+
+/**
+ * Specifies behavior for {@link InitializeForm | initializing} a form's
+ * {@link PrimaryInstance} which references any **missing** resources.
+ *
+ * Here the term "missing" is consistent with
+ * {@link https://www.rfc-editor.org/rfc/rfc9110#status.404 | HTTP 404 status}
+ * semantics. Clients which provide access to form attachments by performing
+ * HTTP network requests (e.g. with {@link fetch}) can generally convey this
+ * semantic meaning with a standard {@link Response}.
+ *
+ * **IMPORTANT**
+ *
+ * The term "missing" is distinct from other network/IO failures, e.g. when
+ * network access itself is unavailable. In these cases, the engine will
+ * consider a resource's availability **ambiguous**, producing an error
+ * regardless of the configured behavior for **missing** resources.
+ */
+// prettier-ignore
+export type MissingResourceBehavior =
+ // eslint-disable-next-line @typescript-eslint/sort-type-constituents
+ | MissingResourceBehaviorError
+ | MissingResourceBehaviorBlank;
+
export const VALIDATION_TEXT = {
constraintMsg: 'Condition not satisfied: constraint',
requiredMsg: 'Condition not satisfied: required',
@@ -8,3 +69,9 @@ export const VALIDATION_TEXT = {
type ValidationTextDefaults = typeof VALIDATION_TEXT;
export type ValidationTextDefault = ValidationTextDefaults[Role];
+
+export const SUBMISSION_INSTANCE_FILE_NAME = 'xml_submission_file';
+export type SubmissionInstanceFileName = typeof SUBMISSION_INSTANCE_FILE_NAME;
+
+export const SUBMISSION_INSTANCE_FILE_TYPE = 'text/xml';
+export type SubmissionInstanceFileType = typeof SUBMISSION_INSTANCE_FILE_TYPE;
diff --git a/packages/xforms-engine/src/client/hierarchy.ts b/packages/xforms-engine/src/client/hierarchy.ts
index 57d02a859..73c510a51 100644
--- a/packages/xforms-engine/src/client/hierarchy.ts
+++ b/packages/xforms-engine/src/client/hierarchy.ts
@@ -1,13 +1,13 @@
import type { ExpandUnion } from '@getodk/common/types/helpers.d.ts';
import type { GroupNode } from './GroupNode.ts';
-import type { ModelValueNode } from './ModelValueNode.ts';
+import type { AnyInputNode } from './InputNode.ts';
+import type { AnyModelValueNode } from './ModelValueNode.ts';
import type { NoteNode } from './NoteNode.ts';
import type { RepeatInstanceNode } from './repeat/RepeatInstanceNode.ts';
import type { RepeatRangeControlledNode } from './repeat/RepeatRangeControlledNode.ts';
import type { RepeatRangeUncontrolledNode } from './repeat/RepeatRangeUncontrolledNode.ts';
import type { RootNode } from './RootNode.ts';
import type { SelectNode } from './SelectNode.ts';
-import type { StringNode } from './StringNode.ts';
import type { SubtreeNode } from './SubtreeNode.ts';
import type { TriggerNode } from './TriggerNode.ts';
import type { RangeNode } from './unsupported/RangeNode.ts';
@@ -22,16 +22,16 @@ export type AnyUnsupportedControlNode =
// prettier-ignore
export type AnyControlNode =
+ | AnyInputNode
| NoteNode
| SelectNode
- | StringNode
| TriggerNode;
// prettier-ignore
export type AnyLeafNode =
| AnyControlNode
- | AnyUnsupportedControlNode
- | ModelValueNode;
+ | AnyModelValueNode
+ | AnyUnsupportedControlNode;
// prettier-ignore
export type RepeatRangeNode =
diff --git a/packages/xforms-engine/src/client/identity.ts b/packages/xforms-engine/src/client/identity.ts
new file mode 100644
index 000000000..e685582f6
--- /dev/null
+++ b/packages/xforms-engine/src/client/identity.ts
@@ -0,0 +1,16 @@
+type ODKXFormsUUID = `uuid:${string}`;
+
+/**
+ * @see {@link https://getodk.github.io/xforms-spec/#metadata}
+ */
+export type InstanceID = ODKXFormsUUID;
+
+/**
+ * @see {@link https://getodk.github.io/xforms-spec/#metadata}
+ */
+export type DeprecatedID = ODKXFormsUUID;
+
+/**
+ * Represents a session-stable identifier for any particular node i
+ */
+export type FormNodeID = `node:${string}`;
diff --git a/packages/xforms-engine/src/client/node-types.ts b/packages/xforms-engine/src/client/node-types.ts
index e5be00b22..055a462be 100644
--- a/packages/xforms-engine/src/client/node-types.ts
+++ b/packages/xforms-engine/src/client/node-types.ts
@@ -7,7 +7,17 @@ export type UnsupportedControlNodeType =
// prettier-ignore
export type RepeatRangeNodeType =
| 'repeat-range:controlled'
- | 'repeat-range:uncontrolled'
+ | 'repeat-range:uncontrolled';
+
+// prettier-ignore
+export type LeafNodeType =
+ // eslint-disable-next-line @typescript-eslint/sort-type-constituents
+ | 'model-value'
+ | 'note'
+ | 'select'
+ | 'input'
+ | 'trigger'
+ | UnsupportedControlNodeType;
// prettier-ignore
export type InstanceNodeType =
@@ -17,9 +27,5 @@ export type InstanceNodeType =
| 'repeat-instance'
| 'group'
| 'subtree'
- | 'model-value'
- | 'note'
- | 'select'
- | 'string'
- | 'trigger'
+ | LeafNodeType
| UnsupportedControlNodeType;
diff --git a/packages/xforms-engine/src/client/resources.ts b/packages/xforms-engine/src/client/resources.ts
new file mode 100644
index 000000000..288cb7ead
--- /dev/null
+++ b/packages/xforms-engine/src/client/resources.ts
@@ -0,0 +1,118 @@
+import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts';
+import type { initializeForm } from '../instance/index.ts';
+
+interface FetchResourceHeadersIterator
+ extends IteratorObject<
+ T,
+ // Note: we use this weird TypeScript intrinsic type so a built-in
+ // `HeadersIterator` is assignable regardless of a client's configured
+ // TypeScript or linting strictness. We don't actually care about the type, or
+ // consume the value it represents.
+ BuiltinIteratorReturn,
+ unknown
+ > {
+ [Symbol.iterator](): FetchResourceHeadersIterator;
+}
+
+type FetchResourceHeadersForEachCallbackFn = (
+ value: string,
+ key: string,
+ parent: FetchResourceResponseHeaders
+) => void;
+
+/**
+ * A read-only strict subset of the web standard {@link Headers}.
+ *
+ * Note that the engine may make the following assumptions about
+ * {@link FetchResourceResponse.headers}:
+ *
+ * - If {@link FetchResourceResponse} is an instance of {@link Response}, it
+ * will be assumed its {@link FetchResourceResponse.headers | headers object}
+ * _is present_, and that it is an instance of {@link Headers}. In other
+ * words: for the purposes of resource resolution, we explicitly expect that
+ * clients using APIs provided by the runtime platform (or polyfills thereof)
+ * will not monkey-patch properties of values produced by those APIs.
+ *
+ * - If the object is an instance of {@link Headers} (whether by inference as a
+ * property of {@link Response}, or by a direct instance type check), the
+ * engine will assume it is safe to treat header names as case insensitive for
+ * any lookups it may perform. In other words: we explicitly expect that
+ * clients _providing access_ to APIs rovided by the runtime platform (or
+ * polyfills thereof) will not alter the guarantees of those APIs.
+ *
+ * - If the object is not an instance of {@link Headers}, it will be treated as
+ * a {@link ReadonlyMap}. In other words: we explicitly expect
+ * that clients, when providing a bespoke implementation of
+ * {@link FetchResourceResponse} and its constituent parts, will likely
+ * implement them partially (and in the case of
+ * {@link FetchResourceResponse.headers}, with the nearest common idiom
+ * available). In this case, we will favor a best effort at correctness,
+ * generally at some expense of performance.
+ */
+export interface FetchResourceResponseHeaders {
+ [Symbol.iterator](): FetchResourceHeadersIterator<[string, string]>;
+
+ entries(): FetchResourceHeadersIterator<[string, string]>;
+ keys(): FetchResourceHeadersIterator;
+ values(): FetchResourceHeadersIterator;
+
+ get(name: string): string | null;
+ has(name: string): boolean;
+ forEach(callbackfn: FetchResourceHeadersForEachCallbackFn): void;
+}
+
+/**
+ * This is a strict subset of the web standard {@link Response}. Clients are
+ * encouraged to use the global {@link Response} constructor (as provided by the
+ * runtime platform, or by a global runtime polyfill), but may also provide a
+ * bespoke implementation if it suits their needs.
+ *
+ * Since we cannot assume a client's implementation will always be an instance
+ * of {@link Response}, we make some assumptions about its {@link headers}
+ * object (if available, as detailed on {@link FetchResourceResponseHeaders}).
+ *
+ * For other properties, we make the following assumptions (all of which are
+ * assumptions we would make about a platform-provided/polyfilled
+ * {@link Response}, but are explicitly stated for the benefit of confidence in
+ * client implementations):
+ *
+ * - If we read {@link body} directly, we will assume it is consumed on first
+ * read, and will not read it again.
+ *
+ * - We assume that {@link blob} and {@link text} indirectly consume
+ * {@link body} on first read as well, and will only ever read one of each of
+ * these properties, and only ever once.
+ *
+ * Furthermore, if the engine intends to read {@link body} (or its indirect
+ * {@link blob} or {@link text} consumers), it will do so in the course of a
+ * client's call to {@link initializeForm}, and before the
+ * {@link Promise} returned by that call is resolved.
+ */
+export interface FetchResourceResponse {
+ readonly ok?: boolean;
+ readonly status?: number;
+ readonly body?: ReadableStream | null;
+ readonly bodyUsed?: boolean;
+ readonly headers?: FetchResourceResponseHeaders;
+
+ readonly blob: () => Promise;
+ readonly text: () => Promise;
+}
+
+/**
+ * This is a strict subset of the web standard `fetch` interface. It implicitly
+ * assumes that the engine itself will only ever perform `GET`-like network/IO
+ * requests. It also provides no further request-like semantics to the engine.
+ *
+ * This is presumed sufficient for now, but notably doesn't expose any notion of
+ * content negotiation (e.g. the ability for the engine to include `Accept`
+ * headers in resource requests issued to a client's {@link FetchResource}
+ * implementation).
+ */
+export type FetchResource = (
+ resource: Resource
+) => Promise;
+
+export type FormAttachmentURL = JRResourceURL;
+
+export type FetchFormAttachment = FetchResource;
diff --git a/packages/xforms-engine/src/client/submission/SubmissionData.ts b/packages/xforms-engine/src/client/submission/SubmissionData.ts
new file mode 100644
index 000000000..d9dcb3408
--- /dev/null
+++ b/packages/xforms-engine/src/client/submission/SubmissionData.ts
@@ -0,0 +1,12 @@
+import type {
+ SubmissionInstanceFile,
+ SubmissionInstanceFileName,
+} from './SubmissionInstanceFile.ts';
+
+export interface SubmissionData extends FormData {
+ get(name: SubmissionInstanceFileName): SubmissionInstanceFile;
+ get(name: string): FormDataEntryValue | null;
+
+ has(name: SubmissionInstanceFileName): true;
+ has(name: string): boolean;
+}
diff --git a/packages/xforms-engine/src/client/submission/SubmissionDefinition.ts b/packages/xforms-engine/src/client/submission/SubmissionDefinition.ts
new file mode 100644
index 000000000..d458f77fd
--- /dev/null
+++ b/packages/xforms-engine/src/client/submission/SubmissionDefinition.ts
@@ -0,0 +1,16 @@
+export interface SubmissionDefinition {
+ /**
+ * @see {@link https://getodk.github.io/xforms-spec/#submission-attributes | `action` submission attribute}
+ */
+ readonly submissionAction: URL | null;
+
+ /**
+ * @see {@link https://getodk.github.io/xforms-spec/#submission-attributes | `method` submission attribute}
+ */
+ readonly submissionMethod: 'post';
+
+ /**
+ * @see {@link https://getodk.github.io/xforms-spec/#submission-attributes | `base64RsaPublicKey` submission attribute}
+ */
+ readonly encryptionKey: string | null;
+}
diff --git a/packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts b/packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts
new file mode 100644
index 000000000..6cd115a27
--- /dev/null
+++ b/packages/xforms-engine/src/client/submission/SubmissionInstanceFile.ts
@@ -0,0 +1,9 @@
+import type { SubmissionInstanceFileName, SubmissionInstanceFileType } from '../constants.ts';
+
+// Re-export for convenient `SubmissionInstanceFile` construction/access flows
+export type { SubmissionInstanceFileName, SubmissionInstanceFileType };
+
+export interface SubmissionInstanceFile extends File {
+ readonly name: SubmissionInstanceFileName;
+ readonly type: SubmissionInstanceFileType;
+}
diff --git a/packages/xforms-engine/src/client/submission/SubmissionOptions.ts b/packages/xforms-engine/src/client/submission/SubmissionOptions.ts
new file mode 100644
index 000000000..13184d379
--- /dev/null
+++ b/packages/xforms-engine/src/client/submission/SubmissionOptions.ts
@@ -0,0 +1,28 @@
+export type SubmissionChunkedType = 'chunked' | 'monolithic';
+
+interface BaseSubmissionOptions {
+ readonly chunked?: ChunkedType | undefined;
+
+ /**
+ * As described in the
+ * {@link https://docs.getodk.org/openrosa-form-submission/#extended-transmission-considerations | OpenRosa Form Submission API},
+ * clients may obtain this value from an OpenRosa server's
+ * `X-OpenRosa-Accept-Content-Length` header.
+ */
+ readonly maxSize?: number;
+}
+
+interface ChunkedSubmissionOptions extends BaseSubmissionOptions<'chunked'> {
+ readonly maxSize: number;
+}
+
+interface MonolithicSubmissionOptions extends BaseSubmissionOptions<'monolithic'> {
+ readonly chunked?: 'monolithic' | undefined;
+ readonly maxSize?: never;
+}
+
+// prettier-ignore
+export type SubmissionOptions = {
+ chunked: ChunkedSubmissionOptions;
+ monolithic: MonolithicSubmissionOptions;
+}[ChunkedType];
diff --git a/packages/xforms-engine/src/client/submission/SubmissionResult.ts b/packages/xforms-engine/src/client/submission/SubmissionResult.ts
new file mode 100644
index 000000000..54437d416
--- /dev/null
+++ b/packages/xforms-engine/src/client/submission/SubmissionResult.ts
@@ -0,0 +1,124 @@
+import type { AnyViolation, DescendantNodeViolationReference } from '../validation.ts';
+import type { SubmissionData } from './SubmissionData.ts';
+import type { SubmissionDefinition } from './SubmissionDefinition.ts';
+import type { SubmissionChunkedType, SubmissionOptions } from './SubmissionOptions.ts';
+
+// prettier-ignore
+export type SubmissionResultStatus =
+ // eslint-disable-next-line @typescript-eslint/sort-type-constituents
+ | 'pending'
+ | 'max-size-exceeded'
+ | 'ready';
+
+// prettier-ignore
+type SubmissionResultData = {
+ chunked: readonly [SubmissionData, ...SubmissionData[]];
+ monolithic: SubmissionData;
+}[ChunkedType];
+
+/**
+ * Provides detail about an individual submission attachment {@link File}s which
+ * exceeds the client-specified {@link maxSize} for a
+ * {@link SubmissionResult<'chunked'> | chunked submission result}. Clients may
+ * use this value to provide guidance to users.
+ *
+ * @todo We may want to consider (a) making {@link maxSize} a configuration the
+ * client can provide when initializing a form instance, rather than only on
+ * submission; and then (b) treating a maximum size violation as another kind of
+ * node-level violation. This would go beyond the kinds of validation specified
+ * by ODK XForms, but it would make a lot of _conceptual sense_.
+ *
+ * It would almost certainly be helpful to alert users to violations as the
+ * occur, rather than only at submission time (where they have likely already
+ * moved on). This is something clients can do without engine support, but it
+ * would likely promote good usability patterns if the engine makes it an
+ * obvious and uniform option at the main engine/client entrypoint.
+ *
+ * @todo If we consider the above, we'd want to reframe _this interface_ to
+ * match the shape of other {@link AnyViolation | violations} (adding it as a
+ * member of that union). We'd also likely eliminate
+ * {@link MaxSizeExceededResult} in the process, since
+ * {@link PendingSubmissionResult} would then cover the case.
+ */
+interface MaxSizeViolation {
+ /**
+ * Specifies the index of
+ * {@link SubmissionResultData<'chunked'> | chunked submission data} where a
+ * submission attachment {@link File} exceeds the client-specified
+ * {@link maxSize}.
+ */
+ readonly dataIndex: number;
+
+ /**
+ * Specifies the name of the file which exceeds the client-specified
+ * {@link maxSize}. This name can also be used as a key to access the
+ * violating {@link File}/submission attachment, in the {@link SubmissionData}
+ * at the specified {@link dataIndex}.
+ */
+ readonly fileName: string;
+
+ /**
+ * Reflects the client-specified maximum size for each chunk of a
+ * {@link SubmissionResult<'chunked'> | chunked submission result}.
+ */
+ readonly maxSize: number;
+
+ /**
+ * Details the actual size of the violating {@link File}/submission
+ * attachment. Along with {@link maxSize}. Clients may use the delta between
+ * this value and {@link maxSize} to provide detailed guidance to users.
+ */
+ readonly actualSize: number;
+}
+
+// prettier-ignore
+type SubmissionResultViolation =
+ | DescendantNodeViolationReference
+ | MaxSizeViolation;
+
+interface BaseSubmissionResult {
+ readonly status: SubmissionResultStatus;
+ readonly definition: SubmissionDefinition;
+ get violations(): readonly SubmissionResultViolation[] | null;
+
+ /**
+ * Submission data may be chunked according to the
+ * {@link SubmissionOptions.maxSize | maxSize submission option}
+ */
+ readonly data: SubmissionResultData;
+}
+
+interface PendingSubmissionResult
+ extends BaseSubmissionResult {
+ readonly status: 'pending';
+ get violations(): readonly DescendantNodeViolationReference[];
+}
+
+interface MaxSizeExceededResult extends BaseSubmissionResult<'chunked'> {
+ readonly status: 'max-size-exceeded';
+ get violations(): readonly MaxSizeViolation[];
+}
+
+interface ReadySubmissionResult
+ extends BaseSubmissionResult {
+ readonly status: 'ready';
+ get violations(): null;
+}
+
+// prettier-ignore
+type CommonSubmissionResult =
+ | PendingSubmissionResult
+ | ReadySubmissionResult;
+
+// prettier-ignore
+export type ChunkedSubmissionResult =
+ | CommonSubmissionResult<'chunked'>
+ | MaxSizeExceededResult;
+
+export type MonolithicSubmissionResult = CommonSubmissionResult<'monolithic'>;
+
+// prettier-ignore
+export type SubmissionResult = {
+ chunked: ChunkedSubmissionResult;
+ monolithic: MonolithicSubmissionResult;
+}[ChunkedType];
diff --git a/packages/xforms-engine/src/client/submission/SubmissionState.ts b/packages/xforms-engine/src/client/submission/SubmissionState.ts
new file mode 100644
index 000000000..5944fd845
--- /dev/null
+++ b/packages/xforms-engine/src/client/submission/SubmissionState.ts
@@ -0,0 +1,14 @@
+import type { RootNode } from '../RootNode.ts';
+
+export interface SubmissionState {
+ /**
+ * Represents the serialized XML state of a given node, as it will be prepared
+ * for submission. The value produced in {@link RootNode.submissionState} is
+ * the same serialization which will be produced for the complete submission.
+ *
+ * @todo Note that this particular aspect of the design doesn't yet address
+ * production of unique file names. As such, this may change as we introduce
+ * affected data types (and their supporting nodes).
+ */
+ get submissionXML(): string;
+}
diff --git a/packages/xforms-engine/src/client/validation.ts b/packages/xforms-engine/src/client/validation.ts
index e29b009d9..99b4bc73b 100644
--- a/packages/xforms-engine/src/client/validation.ts
+++ b/packages/xforms-engine/src/client/validation.ts
@@ -1,5 +1,5 @@
-import type { NodeID } from '../instance/identity.ts';
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
+import type { FormNodeID } from './identity.ts';
import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts';
import type { RootNode } from './RootNode.ts';
import type { TextRange } from './TextRange.ts';
@@ -176,7 +176,7 @@ export interface LeafNodeValidationState {
* each property will be directly computed from the affected node.
*/
export interface DescendantNodeViolationReference {
- readonly nodeId: NodeID;
+ readonly nodeId: FormNodeID;
get reference(): string;
get violation(): AnyViolation;
diff --git a/packages/xforms-engine/src/error/ErrorProductionDesignPendingError.ts b/packages/xforms-engine/src/error/ErrorProductionDesignPendingError.ts
new file mode 100644
index 000000000..bd61f227e
--- /dev/null
+++ b/packages/xforms-engine/src/error/ErrorProductionDesignPendingError.ts
@@ -0,0 +1,6 @@
+/**
+ * Temporary representation of newly introduced error conditions which we may
+ * produce to clients (and beyond). Intentionally a stub pending a more concrete
+ * design and implementation of our approach to producing Result types.
+ */
+export class ErrorProductionDesignPendingError extends Error {}
diff --git a/packages/xforms-engine/src/error/ValueTypeInvariantError.ts b/packages/xforms-engine/src/error/ValueTypeInvariantError.ts
new file mode 100644
index 000000000..389df11d1
--- /dev/null
+++ b/packages/xforms-engine/src/error/ValueTypeInvariantError.ts
@@ -0,0 +1,22 @@
+import type { ValueType } from '../client/ValueType.ts';
+import { ErrorProductionDesignPendingError } from './ErrorProductionDesignPendingError.ts';
+
+/**
+ * Temporary(?) representation of error conditions arising from violation of
+ * invariants specific to {@link valueType}.
+ *
+ * @todo The intent of this distinct subclass of
+ * {@link ErrorProductionDesignPendingError} is to call out cases where we may
+ * want to represent such errors to clients as **validation errors** in the
+ * future. We identified this as probably desirable in the initial work on `int`
+ * and `decimal` value types, but held off on that aspect of implementation to
+ * tame scope.
+ */
+export class ValueTypeInvariantError extends ErrorProductionDesignPendingError {
+ constructor(
+ readonly valueType: ValueType,
+ message: string
+ ) {
+ super(`(${valueType}) ${message}`);
+ }
+}
diff --git a/packages/xforms-engine/src/error/XPathFunctionalityError.ts b/packages/xforms-engine/src/error/XPathFunctionalityError.ts
new file mode 100644
index 000000000..7ff98ef77
--- /dev/null
+++ b/packages/xforms-engine/src/error/XPathFunctionalityError.ts
@@ -0,0 +1,26 @@
+import type { AnyFunction, ExpandUnion } from '@getodk/common/types/helpers.js';
+import type { XPathDOMAdapter, XPathNode } from '@getodk/xpath';
+
+/**
+ * @todo this is general enough to go in `@getodk/common`. Holding off until
+ * it's clear we actually benefit from this particular type gymnastic.
+ */
+// prettier-ignore
+type MethodNameOf = {
+ [K in keyof T]:
+ T[K] extends AnyFunction
+ ? K
+ : never;
+}[keyof T];
+
+// prettier-ignore
+export type XPathFunctionalityErrorCategory = ExpandUnion<
+ | MethodNameOf>
+ | 'processing-instruction'
+>;
+
+export abstract class XPathFunctionalityError extends Error {
+ constructor(functionalityMessagePrefix: string, category: XPathFunctionalityErrorCategory) {
+ super(`${functionalityMessagePrefix}${category}`);
+ }
+}
diff --git a/packages/xforms-engine/src/error/XPathFunctionalityNotSupportedError.ts b/packages/xforms-engine/src/error/XPathFunctionalityNotSupportedError.ts
new file mode 100644
index 000000000..6014c6849
--- /dev/null
+++ b/packages/xforms-engine/src/error/XPathFunctionalityNotSupportedError.ts
@@ -0,0 +1,18 @@
+import type { XPathFunctionalityErrorCategory } from './XPathFunctionalityError.ts';
+import { XPathFunctionalityError } from './XPathFunctionalityError.ts';
+
+type XPathFunctionalityNotSupportedStub = () => never;
+
+export class XPathFunctionalityNotSupportedError extends XPathFunctionalityError {
+ static createStubImplementation(
+ category: XPathFunctionalityErrorCategory
+ ): XPathFunctionalityNotSupportedStub {
+ return () => {
+ throw new this(category);
+ };
+ }
+
+ private constructor(category: XPathFunctionalityErrorCategory) {
+ super('XPath functionality not supported: ', category);
+ }
+}
diff --git a/packages/xforms-engine/src/error/XPathFunctionalityPendingError.ts b/packages/xforms-engine/src/error/XPathFunctionalityPendingError.ts
new file mode 100644
index 000000000..20302fce1
--- /dev/null
+++ b/packages/xforms-engine/src/error/XPathFunctionalityPendingError.ts
@@ -0,0 +1,18 @@
+import type { XPathFunctionalityErrorCategory } from './XPathFunctionalityError.ts';
+import { XPathFunctionalityError } from './XPathFunctionalityError.ts';
+
+type XPathFunctionalityPendingStub = () => never;
+
+export class XPathFunctionalityPendingError extends XPathFunctionalityError {
+ static createStubImplementation(
+ category: XPathFunctionalityErrorCategory
+ ): XPathFunctionalityPendingStub {
+ return () => {
+ throw new this(category);
+ };
+ }
+
+ private constructor(category: XPathFunctionalityErrorCategory) {
+ super('XPath functionality pending: ', category);
+ }
+}
diff --git a/packages/xforms-engine/src/index.ts b/packages/xforms-engine/src/index.ts
index a30bbe9fd..c93f0eb15 100644
--- a/packages/xforms-engine/src/index.ts
+++ b/packages/xforms-engine/src/index.ts
@@ -3,6 +3,7 @@ import { initializeForm as engine__initializeForm } from './instance/index.ts';
export const initializeForm: InitializeForm = engine__initializeForm;
+export type * from './client/constants.ts';
export * as constants from './client/constants.ts';
export type * from './client/EngineConfig.ts';
export type * from './client/FormLanguage.ts';
@@ -19,15 +20,22 @@ export type {
RepeatRangeNode,
} from './client/hierarchy.ts';
export type * from './client/index.ts';
+export type * from './client/InputNode.ts';
export type * from './client/ModelValueNode.ts';
export type * from './client/NoteNode.ts';
export type * from './client/OpaqueReactiveObjectFactory.ts';
export type * from './client/repeat/RepeatInstanceNode.ts';
export type * from './client/repeat/RepeatRangeControlledNode.ts';
export type * from './client/repeat/RepeatRangeUncontrolledNode.ts';
+export type * from './client/resources.ts';
export type * from './client/RootNode.ts';
export type * from './client/SelectNode.ts';
-export type * from './client/StringNode.ts';
+export type * from './client/submission/SubmissionData.ts';
+export type * from './client/submission/SubmissionDefinition.ts';
+export type * from './client/submission/SubmissionInstanceFile.ts';
+export type * from './client/submission/SubmissionOptions.ts';
+export type * from './client/submission/SubmissionResult.ts';
+export type * from './client/submission/SubmissionState.ts';
export type * from './client/SubtreeNode.ts';
export type * from './client/TextRange.ts';
export type * from './client/TriggerNode.ts';
@@ -35,6 +43,7 @@ export type * from './client/unsupported/RangeNode.ts';
export type * from './client/unsupported/RankNode.ts';
export type * from './client/unsupported/UploadNode.ts';
export type * from './client/validation.ts';
+export type * from './client/ValueType.ts';
// TODO: notwithstanding potential conflicts with parallel work on `web-forms`
// (former `ui-vue`), these are the last remaining references **outside of
diff --git a/packages/xforms-engine/src/instance/Group.ts b/packages/xforms-engine/src/instance/Group.ts
index 44b08f913..181e38bf7 100644
--- a/packages/xforms-engine/src/instance/Group.ts
+++ b/packages/xforms-engine/src/instance/Group.ts
@@ -1,7 +1,12 @@
+import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
import type { GroupDefinition, GroupNode, GroupNodeAppearances } from '../client/GroupNode.ts';
+import type { FormNodeID } from '../client/identity.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
import type { TextRange } from '../client/TextRange.ts';
import type { AncestorNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts';
import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts';
import { createChildrenState } from '../lib/reactivity/createChildrenState.ts';
import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts';
@@ -16,25 +21,30 @@ import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts
import { DescendantNode } from './abstract/DescendantNode.ts';
import { buildChildren } from './children.ts';
import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts';
-import type { NodeID } from './identity.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
+import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts';
// prettier-ignore
interface GroupStateSpec extends DescendantNodeSharedStateSpec {
readonly label: Accessor | null>;
readonly hint: null;
- readonly children: Accessor;
+ readonly children: Accessor;
readonly valueOptions: null;
readonly value: null;
}
export class Group
- extends DescendantNode
- implements GroupNode, EvaluationContext, SubscribableDependency
+ extends DescendantNode
+ implements
+ GroupNode,
+ XFormsXPathElement,
+ EvaluationContext,
+ ClientReactiveSubmittableParentNode
{
private readonly childrenState: ChildrenState;
+ override readonly [XPathNodeKindKey] = 'element';
+
// InstanceNode
protected readonly state: SharedNodeState;
protected override engineState: EngineState;
@@ -44,6 +54,7 @@ export class Group
readonly appearances: GroupNodeAppearances;
readonly currentState: MaterializedChildren, GeneralChildNode>;
readonly validationState: AncestorNodeValidationState;
+ readonly submissionState: SubmissionState;
constructor(parent: GeneralParentNode, definition: GroupDefinition) {
super(parent, definition);
@@ -85,6 +96,7 @@ export class Group
childrenState.setChildren(buildChildren(this));
this.validationState = createAggregatedViolations(this, sharedStateOptions);
+ this.submissionState = createParentNodeSubmissionState(this);
}
getChildren(): readonly GeneralChildNode[] {
diff --git a/packages/xforms-engine/src/instance/InputControl.ts b/packages/xforms-engine/src/instance/InputControl.ts
new file mode 100644
index 000000000..d4d916a74
--- /dev/null
+++ b/packages/xforms-engine/src/instance/InputControl.ts
@@ -0,0 +1,119 @@
+import { XPathNodeKindKey } from '@getodk/xpath';
+import type { Accessor } from 'solid-js';
+import type {
+ InputDefinition,
+ InputNode,
+ InputNodeAppearances,
+ InputNodeInputValue,
+} from '../client/InputNode.ts';
+import type { TextRange } from '../client/TextRange.ts';
+import type { ValueType } from '../client/ValueType.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import type { RuntimeInputValue, RuntimeValue } from '../lib/codecs/getSharedValueCodec.ts';
+import { getSharedValueCodec } from '../lib/codecs/getSharedValueCodec.ts';
+import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
+import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts';
+import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
+import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
+import { createFieldHint } from '../lib/reactivity/text/createFieldHint.ts';
+import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts';
+import type { Root } from './Root.ts';
+import { ValueNode, type ValueNodeStateSpec } from './abstract/ValueNode.ts';
+import type { GeneralParentNode } from './hierarchy.ts';
+import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
+import type { ValidationContext } from './internal-api/ValidationContext.ts';
+import type { ClientReactiveSubmittableValueNode } from './internal-api/submission/ClientReactiveSubmittableValueNode.ts';
+
+export type AnyInputDefinition = {
+ [V in ValueType]: InputDefinition;
+}[ValueType];
+
+interface InputControlStateSpec extends ValueNodeStateSpec> {
+ readonly label: Accessor | null>;
+ readonly hint: Accessor | null>;
+}
+
+export class InputControl
+ extends ValueNode, RuntimeValue, RuntimeInputValue>
+ implements
+ InputNode,
+ XFormsXPathElement,
+ EvaluationContext,
+ ValidationContext,
+ ClientReactiveSubmittableValueNode
+{
+ static from(parent: GeneralParentNode, definition: InputDefinition): AnyInputControl;
+ static from(
+ parent: GeneralParentNode,
+ definition: InputDefinition
+ ): InputControl {
+ return new this(parent, definition);
+ }
+
+ // XFormsXPathElement
+ override readonly [XPathNodeKindKey] = 'element';
+
+ // InstanceNode
+ protected readonly state: SharedNodeState>;
+ protected readonly engineState: EngineState>;
+
+ // InputNode
+ readonly nodeType = 'input';
+ readonly appearances: InputNodeAppearances;
+ readonly currentState: CurrentState>;
+
+ constructor(parent: GeneralParentNode, definition: InputDefinition) {
+ const codec = getSharedValueCodec(definition.valueType);
+
+ super(parent, definition, codec);
+
+ this.appearances = definition.bodyElement.appearances;
+
+ const sharedStateOptions = {
+ clientStateFactory: this.engineConfig.stateFactory,
+ };
+
+ const state = createSharedNodeState(
+ this.scope,
+ {
+ reference: this.contextReference,
+ readonly: this.isReadonly,
+ relevant: this.isRelevant,
+ required: this.isRequired,
+
+ label: createNodeLabel(this, definition),
+ hint: createFieldHint(this, definition),
+ children: null,
+ valueOptions: null,
+ value: this.valueState,
+ instanceValue: this.getInstanceValue,
+ },
+ sharedStateOptions
+ );
+
+ this.state = state;
+ this.engineState = state.engineState;
+ this.currentState = state.currentState;
+ }
+
+ setValue(value: InputNodeInputValue): Root {
+ this.setValueState(value);
+
+ return this.root;
+ }
+}
+
+export type AnyInputControl =
+ | InputControl<'barcode'>
+ | InputControl<'binary'>
+ | InputControl<'boolean'>
+ | InputControl<'date'>
+ | InputControl<'dateTime'>
+ | InputControl<'decimal'>
+ | InputControl<'geopoint'>
+ | InputControl<'geoshape'>
+ | InputControl<'geotrace'>
+ | InputControl<'int'>
+ | InputControl<'intent'>
+ | InputControl<'string'>
+ | InputControl<'time'>;
diff --git a/packages/xforms-engine/src/instance/ModelValue.ts b/packages/xforms-engine/src/instance/ModelValue.ts
index 1d6ed3da5..58d315450 100644
--- a/packages/xforms-engine/src/instance/ModelValue.ts
+++ b/packages/xforms-engine/src/instance/ModelValue.ts
@@ -1,65 +1,57 @@
-import { identity } from '@getodk/common/lib/identity.ts';
-import type { ModelValueNode } from '../client/ModelValueNode.ts';
-import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts';
-import { createValueState } from '../lib/reactivity/createValueState.ts';
+import { XPathNodeKindKey } from '@getodk/xpath';
+import type { ModelValueDefinition, ModelValueNode } from '../client/ModelValueNode.ts';
+import type { ValueType } from '../client/ValueType.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import type { RuntimeInputValue, RuntimeValue } from '../lib/codecs/getSharedValueCodec.ts';
+import { getSharedValueCodec } from '../lib/codecs/getSharedValueCodec.ts';
import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts';
import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
-import type { SimpleAtomicState } from '../lib/reactivity/types.ts';
-import type { SharedValidationState } from '../lib/reactivity/validation/createValidation.ts';
-import { createValidationState } from '../lib/reactivity/validation/createValidation.ts';
-import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
-import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts';
-import { DescendantNode } from './abstract/DescendantNode.ts';
+import { ValueNode, type ValueNodeStateSpec } from './abstract/ValueNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
import type { ValidationContext } from './internal-api/ValidationContext.ts';
-import type { ValueContext } from './internal-api/ValueContext.ts';
+import type { ClientReactiveSubmittableValueNode } from './internal-api/submission/ClientReactiveSubmittableValueNode.ts';
-export interface ModelValueDefinition extends LeafNodeDefinition {
- readonly bodyElement: null;
-}
-
-interface ModelValueStateSpec extends DescendantNodeStateSpec {
+interface ModelValueStateSpec extends ValueNodeStateSpec> {
readonly label: null;
readonly hint: null;
- readonly children: null;
- readonly value: SimpleAtomicState;
- readonly valueOptions: null;
}
-export class ModelValue
- extends DescendantNode
+export class ModelValue
+ extends ValueNode, RuntimeValue, RuntimeInputValue>
implements
- ModelValueNode,
+ ModelValueNode,
+ XFormsXPathElement,
EvaluationContext,
- SubscribableDependency,
ValidationContext,
- ValueContext
+ ClientReactiveSubmittableValueNode
{
- private readonly validation: SharedValidationState;
- protected readonly state: SharedNodeState;
+ static from(parent: GeneralParentNode, definition: ModelValueDefinition): AnyModelValue;
+ static from(
+ parent: GeneralParentNode,
+ definition: ModelValueDefinition
+ ): ModelValue {
+ return new this(parent, definition);
+ }
+
+ // XFormsXPathElement
+ override readonly [XPathNodeKindKey] = 'element';
// InstanceNode
- protected engineState: EngineState;
+ protected readonly state: SharedNodeState>;
+ protected readonly engineState: EngineState>;
// ModelValueNode
readonly nodeType = 'model-value';
readonly appearances = null;
- readonly currentState: CurrentState;
+ readonly currentState: CurrentState>;
- get validationState(): LeafNodeValidationState {
- return this.validation.currentState;
- }
-
- // ValueContext
- readonly encodeValue = identity;
- readonly decodeValue = identity;
+ constructor(parent: GeneralParentNode, definition: ModelValueDefinition) {
+ const codec = getSharedValueCodec(definition.valueType);
- constructor(parent: GeneralParentNode, definition: ModelValueDefinition) {
- super(parent, definition);
+ super(parent, definition, codec);
const sharedStateOptions = {
clientStateFactory: this.engineConfig.stateFactory,
@@ -77,7 +69,8 @@ export class ModelValue
hint: null,
children: null,
valueOptions: null,
- value: createValueState(this),
+ value: this.valueState,
+ instanceValue: this.getInstanceValue,
},
sharedStateOptions
);
@@ -85,20 +78,20 @@ export class ModelValue
this.state = state;
this.engineState = state.engineState;
this.currentState = state.currentState;
- this.validation = createValidationState(this, sharedStateOptions);
- }
-
- // ValidationContext
- getViolation(): AnyViolation | null {
- return this.validation.engineState.violation;
- }
-
- isBlank(): boolean {
- return this.engineState.value === '';
- }
-
- // InstanceNode
- getChildren(): readonly [] {
- return [];
}
}
+
+export type AnyModelValue =
+ | ModelValue<'barcode'>
+ | ModelValue<'binary'>
+ | ModelValue<'boolean'>
+ | ModelValue<'date'>
+ | ModelValue<'dateTime'>
+ | ModelValue<'decimal'>
+ | ModelValue<'geopoint'>
+ | ModelValue<'geoshape'>
+ | ModelValue<'geotrace'>
+ | ModelValue<'int'>
+ | ModelValue<'intent'>
+ | ModelValue<'string'>
+ | ModelValue<'time'>;
diff --git a/packages/xforms-engine/src/instance/Note.ts b/packages/xforms-engine/src/instance/Note.ts
index 6b6dfec98..d664c097c 100644
--- a/packages/xforms-engine/src/instance/Note.ts
+++ b/packages/xforms-engine/src/instance/Note.ts
@@ -1,9 +1,13 @@
import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
import { identity } from '@getodk/common/lib/identity.ts';
+import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
import type { NoteNode, NoteNodeAppearances } from '../client/NoteNode.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
import type { TextRange } from '../client/TextRange.ts';
import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts';
import { createNoteReadonlyThunk } from '../lib/reactivity/createNoteReadonlyThunk.ts';
import { createValueState } from '../lib/reactivity/createValueState.ts';
import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
@@ -21,7 +25,7 @@ import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts';
import { DescendantNode } from './abstract/DescendantNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
+import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts';
import type { ValidationContext } from './internal-api/ValidationContext.ts';
import type { ValueContext } from './internal-api/ValueContext.ts';
@@ -36,19 +40,23 @@ interface NoteStateSpec extends DescendantNodeStateSpec {
}
export class Note
- extends DescendantNode
+ extends DescendantNode
implements
NoteNode,
+ XFormsXPathElement,
EvaluationContext,
- SubscribableDependency,
ValidationContext,
- ValueContext
+ ValueContext,
+ ClientReactiveSubmittableLeafNode
{
private readonly validation: SharedValidationState;
- protected readonly state: SharedNodeState;
+
+ // XFormsXPathElement
+ override readonly [XPathNodeKindKey] = 'element';
// InstanceNode
- protected engineState: EngineState;
+ protected readonly state: SharedNodeState;
+ protected readonly engineState: EngineState;
// NoteNode
readonly nodeType = 'note';
@@ -59,9 +67,11 @@ export class Note
return this.validation.currentState;
}
+ readonly submissionState: SubmissionState;
+
// ValueContext
+ override readonly contextNode = this;
readonly encodeValue = identity;
-
readonly decodeValue = identity;
constructor(parent: GeneralParentNode, definition: NoteNodeDefinition) {
@@ -73,7 +83,7 @@ export class Note
clientStateFactory: this.engineConfig.stateFactory,
};
- const isReadonly = createNoteReadonlyThunk(this, definition.bind.readonly);
+ const isReadonly = createNoteReadonlyThunk(this, definition);
const noteTextComputation = createNoteText(this, definition.noteTextDefinition);
let noteText: ComputedNoteText;
@@ -124,6 +134,12 @@ export class Note
this.engineState = state.engineState;
this.currentState = state.currentState;
this.validation = createValidationState(this, sharedStateOptions);
+ this.submissionState = createLeafNodeSubmissionState(this);
+ }
+
+ // XFormsXPathElement
+ override getXPathValue(): string {
+ return this.engineState.value;
}
// ValidationContext
diff --git a/packages/xforms-engine/src/instance/PrimaryInstance.ts b/packages/xforms-engine/src/instance/PrimaryInstance.ts
new file mode 100644
index 000000000..f416296c4
--- /dev/null
+++ b/packages/xforms-engine/src/instance/PrimaryInstance.ts
@@ -0,0 +1,244 @@
+import { XPathNodeKindKey } from '@getodk/xpath';
+import type { Accessor } from 'solid-js';
+import { createSignal } from 'solid-js';
+import type { ActiveLanguage, FormLanguage, FormLanguages } from '../client/FormLanguage.ts';
+import type { FormNodeID } from '../client/identity.ts';
+import type { RootNode } from '../client/RootNode.ts';
+import type {
+ SubmissionChunkedType,
+ SubmissionOptions,
+} from '../client/submission/SubmissionOptions.ts';
+import type { SubmissionResult } from '../client/submission/SubmissionResult.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
+import type { AncestorNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathDocument } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { EngineXPathEvaluator } from '../integration/xpath/EngineXPathEvaluator.ts';
+import { createInstanceSubmissionState } from '../lib/client-reactivity/submission/createInstanceSubmissionState.ts';
+import { prepareSubmission } from '../lib/client-reactivity/submission/prepareSubmission.ts';
+import { createChildrenState } from '../lib/reactivity/createChildrenState.ts';
+import { createTranslationState } from '../lib/reactivity/createTranslationState.ts';
+import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts';
+import { materializeCurrentStateChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts';
+import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
+import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts';
+import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
+import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
+import type { ReactiveScope } from '../lib/reactivity/scope.ts';
+import type { SimpleAtomicStateSetter } from '../lib/reactivity/types.ts';
+import type { BodyClassList } from '../parse/body/BodyDefinition.ts';
+import type { ModelDefinition } from '../parse/model/ModelDefinition.ts';
+import type { RootDefinition } from '../parse/model/RootDefinition.ts';
+import type { SecondaryInstancesDefinition } from '../parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts';
+import { InstanceNode } from './abstract/InstanceNode.ts';
+import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
+import type { InstanceConfig } from './internal-api/InstanceConfig.ts';
+import type { PrimaryInstanceDocument } from './internal-api/PrimaryInstanceDocument.ts';
+import type { ClientReactiveSubmittableInstance } from './internal-api/submission/ClientReactiveSubmittableInstance.ts';
+import type { TranslationContext } from './internal-api/TranslationContext.ts';
+import { Root } from './Root.ts';
+
+/**
+ * As specified by {@link | XPath 1.0}:
+ *
+ * > A `/` by itself selects the root node of the document containing the
+ * > context node.
+ *
+ * Important note: "root node" here references the same semantics currently
+ * represented by `@getodk/xpath` as {@link XPathDocument}. It is an infortunate
+ * historical mistake that our internal representation of a primary instance has
+ * conflated the same "root" term to represent the "document element" with the
+ * same semantic name.
+ *
+ * {@link PrimaryInstance} exists specifically to provide XPath document
+ * semantics. At time of writing, it is expected that every XPath evaluation
+ * will be performed with a context node either:
+ *
+ * - somewhere in this primary instance document's hierarchy; or
+ * - in a related subtree where the primary instance document is still
+ * semantically represented as the context root node
+ */
+const PRIMARY_INSTANCE_REFERENCE = '/';
+
+interface PrimaryInstanceStateSpec {
+ readonly reference: string;
+ readonly readonly: boolean;
+ readonly relevant: boolean;
+ readonly required: boolean;
+ readonly label: null;
+ readonly hint: null;
+ readonly children: Accessor;
+ readonly valueOptions: null;
+ readonly value: null;
+
+ // Root-specific
+ readonly activeLanguage: Accessor;
+}
+
+export class PrimaryInstance
+ extends InstanceNode
+ implements
+ PrimaryInstanceDocument,
+ XFormsXPathDocument,
+ TranslationContext,
+ EvaluationContext,
+ ClientReactiveSubmittableInstance
+{
+ // InstanceNode
+ protected readonly state: SharedNodeState;
+ protected readonly engineState: EngineState;
+ readonly getChildren: Accessor;
+
+ readonly hasReadonlyAncestor = () => false;
+ readonly isReadonly = () => false;
+ readonly hasNonRelevantAncestor = () => false;
+ readonly isRelevant = () => true;
+
+ // TranslationContext (support)
+ private readonly setActiveLanguage: SimpleAtomicStateSetter;
+
+ // XFormsXPathDocument
+ readonly [XPathNodeKindKey] = 'document';
+
+ // PrimaryInstanceDocument, ClientReactiveSubmittableInstance
+ readonly nodeType = 'primary-instance';
+ readonly appearances = null;
+ readonly classes: BodyClassList;
+ readonly root: Root;
+ readonly currentState: MaterializedChildren, Root>;
+ readonly validationState: AncestorNodeValidationState;
+ readonly submissionState: SubmissionState;
+ readonly languages: FormLanguages;
+
+ // TranslationContext (+ EvaluationContext)
+ readonly getActiveLanguage: Accessor;
+
+ // EvaluationContext
+ readonly isAttached: Accessor;
+ readonly evaluator: EngineXPathEvaluator;
+ override readonly contextNode = this;
+
+ constructor(
+ scope: ReactiveScope,
+ model: ModelDefinition,
+ secondaryInstances: SecondaryInstancesDefinition,
+ engineConfig: InstanceConfig
+ ) {
+ const { root: definition } = model;
+
+ super(engineConfig, null, definition, {
+ scope,
+ computeReference: () => PRIMARY_INSTANCE_REFERENCE,
+ });
+
+ const [isAttached, setIsAttached] = createSignal(false);
+
+ this.isAttached = isAttached;
+
+ const evaluator = new EngineXPathEvaluator({
+ rootNode: this,
+ itextTranslationsByLanguage: model.itextTranslations,
+ secondaryInstancesById: secondaryInstances,
+ });
+
+ const { languages, getActiveLanguage, setActiveLanguage } = createTranslationState(
+ scope,
+ evaluator
+ );
+
+ this.languages = languages;
+ this.getActiveLanguage = getActiveLanguage;
+ this.setActiveLanguage = setActiveLanguage;
+
+ this.evaluator = evaluator;
+ this.classes = definition.classes;
+
+ const childrenState = createChildrenState(this);
+
+ this.getChildren = childrenState.getChildren;
+
+ const stateSpec: PrimaryInstanceStateSpec = {
+ activeLanguage: getActiveLanguage,
+ reference: PRIMARY_INSTANCE_REFERENCE,
+ label: null,
+ hint: null,
+ readonly: false,
+ relevant: true,
+ required: false,
+ valueOptions: null,
+ value: null,
+ children: childrenState.childIds,
+ };
+
+ const state = createSharedNodeState(scope, stateSpec, {
+ clientStateFactory: engineConfig.stateFactory,
+ });
+
+ this.state = state;
+ this.engineState = state.engineState;
+ this.currentState = materializeCurrentStateChildren(scope, state.currentState, childrenState);
+
+ const root = new Root(this);
+
+ this.root = root;
+
+ this.validationState = {
+ get violations() {
+ return root.validationState.violations;
+ },
+ };
+ this.submissionState = createInstanceSubmissionState(this);
+
+ childrenState.setChildren([root]);
+ setIsAttached(true);
+ }
+
+ // PrimaryInstanceDocument
+ /**
+ * @todo Note that this method's signature is intentionally derived from
+ * {@link RootNode.setLanguage}, but its return type differs! The design
+ * intent of returning {@link RootNode} from all of the client-facing state
+ * setter methods has proven… interesting philosophically. But nothing
+ * downstream has availed itself of that philosophy, and otherwise it's not
+ * particularly pragmatic or ergonomic (internally or for clients alike).
+ *
+ * Since this class is (currently) engine-internal, this seems like an
+ * excellent place to start a discussion around what we want longer term for
+ * state setter signatures in _client-facing_ APIs. As a first pass, it seems
+ * reasonable to borrow the idiomatic convention of returning the effective
+ * value assigned by the setter.
+ *
+ * @see
+ * {@link https://github.com/getodk/web-forms/issues/45#issuecomment-1967932261 | Initial read interface design between engine and UI - design summary comment}
+ * (and some of the comments leading up to it) for background on the
+ * philosophical reasoning behind the existing signature convention.
+ */
+ setLanguage(language: FormLanguage): FormLanguage {
+ const availableFormLanguage = this.languages.find(
+ (formLanguage): formLanguage is FormLanguage => {
+ return (
+ formLanguage.isSyntheticDefault == null && formLanguage.language === language.language
+ );
+ }
+ );
+
+ if (availableFormLanguage == null) {
+ throw new Error(`Language "${language.language}" not available`);
+ }
+
+ this.evaluator.setActiveLanguage(availableFormLanguage.language);
+
+ return this.setActiveLanguage(availableFormLanguage);
+ }
+
+ // PrimaryInstanceDocument, ClientReactiveSubmittableInstance
+ prepareSubmission(
+ options?: SubmissionOptions
+ ): Promise> {
+ const result = prepareSubmission(this, {
+ chunked: (options?.chunked ?? 'monolithic') as ChunkedType,
+ maxSize: options?.maxSize ?? Infinity,
+ });
+
+ return Promise.resolve(result);
+ }
+}
diff --git a/packages/xforms-engine/src/instance/Root.ts b/packages/xforms-engine/src/instance/Root.ts
index 4828eb4e8..016355276 100644
--- a/packages/xforms-engine/src/instance/Root.ts
+++ b/packages/xforms-engine/src/instance/Root.ts
@@ -1,9 +1,18 @@
-import type { XFormsXPathEvaluator } from '@getodk/xpath';
-import type { Accessor, Signal } from 'solid-js';
-import { createSignal } from 'solid-js';
+import { XPathNodeKindKey } from '@getodk/xpath';
+import type { Accessor } from 'solid-js';
import type { ActiveLanguage, FormLanguage, FormLanguages } from '../client/FormLanguage.ts';
+import type { FormNodeID } from '../client/identity.ts';
import type { RootNode } from '../client/RootNode.ts';
+import type { SubmissionDefinition } from '../client/submission/SubmissionDefinition.ts';
+import type {
+ SubmissionChunkedType,
+ SubmissionOptions,
+} from '../client/submission/SubmissionOptions.ts';
+import type { SubmissionResult } from '../client/submission/SubmissionResult.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
import type { AncestorNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts';
import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts';
import { createChildrenState } from '../lib/reactivity/createChildrenState.ts';
import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts';
@@ -15,146 +24,87 @@ import { createSharedNodeState } from '../lib/reactivity/node-state/createShared
import { createAggregatedViolations } from '../lib/reactivity/validation/createAggregatedViolations.ts';
import type { BodyClassList } from '../parse/body/BodyDefinition.ts';
import type { RootDefinition } from '../parse/model/RootDefinition.ts';
-import type { XFormDOM } from '../parse/XFormDOM.ts';
-import { InstanceNode } from './abstract/InstanceNode.ts';
+import { DescendantNode } from './abstract/DescendantNode.ts';
import { buildChildren } from './children.ts';
import type { GeneralChildNode } from './hierarchy.ts';
-import type { NodeID } from './identity.ts';
-import type { EvaluationContext, EvaluationContextRoot } from './internal-api/EvaluationContext.ts';
-import type { InstanceConfig } from './internal-api/InstanceConfig.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
+import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
+import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts';
import type { TranslationContext } from './internal-api/TranslationContext.ts';
+import type { PrimaryInstance } from './PrimaryInstance.ts';
interface RootStateSpec {
- readonly reference: string;
- readonly readonly: boolean;
- readonly relevant: boolean;
- readonly required: boolean;
+ readonly reference: Accessor;
+ readonly readonly: Accessor;
+ readonly relevant: Accessor;
+ readonly required: Accessor;
readonly label: null;
readonly hint: null;
- readonly children: Accessor;
+ readonly children: Accessor;
readonly valueOptions: null;
readonly value: null;
// Root-specific
- readonly activeLanguage: Signal;
+ readonly activeLanguage: Accessor;
}
-// Subset of types expected from evaluator
-interface ItextTranslations {
- getActiveLanguage(): string | null;
- getLanguages(): readonly string[];
-}
-
-interface InitialLanguageState {
- readonly defaultLanguage: ActiveLanguage;
- readonly languages: FormLanguages;
-}
-
-// TODO: it's really very silly that the XPath evaluator is the current
-// definitional source of truth for translation stuff... even though it currently makes sense that that's where it's first derived.
-const getInitialLanguageState = (translations: ItextTranslations): InitialLanguageState => {
- const activeLanguageName = translations.getActiveLanguage();
-
- if (activeLanguageName == null) {
- const defaultLanguage: ActiveLanguage = {
- isSyntheticDefault: true,
- language: '',
- };
- const languages = [defaultLanguage] satisfies FormLanguages;
-
- return {
- defaultLanguage,
- languages,
- };
- }
-
- const languageNames = translations.getLanguages();
-
- const inactiveLanguages = languageNames
- .filter((languageName) => {
- return languageName !== activeLanguageName;
- })
- .map((language): FormLanguage => {
- return { language };
- });
-
- const languages = [
- { language: activeLanguageName } satisfies FormLanguage,
-
- ...inactiveLanguages,
- ] satisfies FormLanguages;
- const [defaultLanguage] = languages;
-
- return {
- defaultLanguage,
- languages,
- };
-};
-
export class Root
- extends InstanceNode
+ extends DescendantNode
implements
RootNode,
+ XFormsXPathElement,
EvaluationContext,
- EvaluationContextRoot,
- SubscribableDependency,
- TranslationContext
+ TranslationContext,
+ ClientReactiveSubmittableParentNode
{
private readonly childrenState: ChildrenState;
- // InstanceNode
- readonly hasReadonlyAncestor = () => false;
- readonly isReadonly = () => false;
- readonly hasNonRelevantAncestor = () => false;
- readonly isRelevant = () => true;
+ // XFormsXPathElement
+ override readonly [XPathNodeKindKey] = 'element';
+
+ // DescendantNode
protected readonly state: SharedNodeState;
protected readonly engineState: EngineState;
+ override readonly hasReadonlyAncestor = () => false;
+ override readonly isSelfReadonly = () => false;
+ override readonly isReadonly = () => false;
+ override readonly hasNonRelevantAncestor = () => false;
+ override readonly isSelfRelevant = () => true;
+ override readonly isRelevant = () => true;
+ override readonly isRequired = () => false;
+
// RootNode
readonly nodeType = 'root';
readonly appearances = null;
readonly classes: BodyClassList;
readonly currentState: MaterializedChildren, GeneralChildNode>;
readonly validationState: AncestorNodeValidationState;
+ readonly submissionState: SubmissionState;
- protected readonly instanceDOM: XFormDOM;
-
- // BaseNode
- readonly root = this;
-
- // EvaluationContext
- readonly evaluator: XFormsXPathEvaluator;
-
- readonly contextNode: Element;
+ // ClientReactiveSubmittableInstance
+ get submissionDefinition(): SubmissionDefinition {
+ return this.definition.submission;
+ }
// RootNode
- override readonly parent = null;
-
readonly languages: FormLanguages;
- // TranslationContext
- get activeLanguage(): ActiveLanguage {
- return this.engineState.activeLanguage;
- }
-
- constructor(xformDOM: XFormDOM, definition: RootDefinition, engineConfig: InstanceConfig) {
- const reference = definition.nodeset;
+ constructor(parent: PrimaryInstance) {
+ const { definition } = parent;
+ const { nodeset: reference } = definition;
+ const computeReference: Accessor = () => reference;
- super(engineConfig, null, definition, {
- computeReference: () => reference,
+ super(parent, definition, {
+ computeReference,
});
- this.classes = definition.classes;
+ this.classes = parent.classes;
const childrenState = createChildrenState(this);
this.childrenState = childrenState;
+ this.languages = parent.languages;
- const instanceDOM = xformDOM.createInstance();
- const evaluator = instanceDOM.primaryInstanceEvaluator;
- const { translations } = evaluator;
- const { defaultLanguage, languages } = getInitialLanguageState(translations);
const sharedStateOptions = {
clientStateFactory: this.engineConfig.stateFactory,
};
@@ -162,13 +112,13 @@ export class Root
const state = createSharedNodeState(
this.scope,
{
- activeLanguage: createSignal(defaultLanguage),
- reference,
+ activeLanguage: parent.getActiveLanguage,
+ reference: computeReference,
label: null,
hint: null,
- readonly: false,
- relevant: true,
- required: false,
+ readonly: () => false,
+ relevant: () => true,
+ required: () => false,
valueOptions: null,
value: null,
children: childrenState.childIds,
@@ -184,17 +134,9 @@ export class Root
childrenState
);
- const contextNode = instanceDOM.xformDocument.createElement(definition.nodeName);
-
- instanceDOM.primaryInstanceRoot.replaceWith(contextNode);
-
- this.evaluator = evaluator;
- this.contextNode = contextNode;
- this.instanceDOM = instanceDOM;
- this.languages = languages;
-
childrenState.setChildren(buildChildren(this));
this.validationState = createAggregatedViolations(this, sharedStateOptions);
+ this.submissionState = createParentNodeSubmissionState(this);
}
getChildren(): readonly GeneralChildNode[] {
@@ -203,28 +145,14 @@ export class Root
// RootNode
setLanguage(language: FormLanguage): Root {
- const activeLanguage = this.languages.find((formLanguage) => {
- return formLanguage.language === language.language;
- });
-
- if (activeLanguage == null) {
- throw new Error(`Language "${language.language}" not available`);
- }
-
- this.evaluator.translations.setActiveLanguage(activeLanguage.language);
- this.state.setProperty('activeLanguage', activeLanguage);
+ this.rootDocument.setLanguage(language);
return this;
}
- // SubscribableDependency
- override subscribe(): void {
- super.subscribe();
-
- // TODO: typescript-eslint is right to object to this! We should _at least_
- // make internal reactive reads obvious, i.e. function calls.
- //
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- read == subscribe
- this.engineState.activeLanguage;
+ prepareSubmission(
+ options?: SubmissionOptions
+ ): Promise> {
+ return this.rootDocument.prepareSubmission(options);
}
}
diff --git a/packages/xforms-engine/src/instance/SelectField.ts b/packages/xforms-engine/src/instance/SelectField.ts
index 89222ab51..175f73708 100644
--- a/packages/xforms-engine/src/instance/SelectField.ts
+++ b/packages/xforms-engine/src/instance/SelectField.ts
@@ -1,9 +1,13 @@
import { xmlXPathWhitespaceSeparatedList } from '@getodk/common/lib/string/whitespace.ts';
+import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
-import { untrack } from 'solid-js';
+import { createMemo, untrack } from 'solid-js';
import type { SelectItem, SelectNode, SelectNodeAppearances } from '../client/SelectNode.ts';
import type { TextRange } from '../client/TextRange.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts';
import { createSelectItems } from '../lib/reactivity/createSelectItems.ts';
import { createValueState } from '../lib/reactivity/createValueState.ts';
import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
@@ -22,9 +26,9 @@ import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts';
import { DescendantNode } from './abstract/DescendantNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
import type { ValidationContext } from './internal-api/ValidationContext.ts';
import type { ValueContext } from './internal-api/ValueContext.ts';
+import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts';
export interface SelectFieldDefinition extends LeafNodeDefinition {
readonly bodyElement: AnySelectDefinition;
@@ -39,17 +43,21 @@ interface SelectFieldStateSpec extends DescendantNodeStateSpec
+ extends DescendantNode
implements
SelectNode,
+ XFormsXPathElement,
EvaluationContext,
- SubscribableDependency,
ValidationContext,
- ValueContext
+ ValueContext,
+ ClientReactiveSubmittableLeafNode
{
private readonly selectExclusive: boolean;
private readonly validation: SharedValidationState;
+ // XFormsXPathElement
+ override readonly [XPathNodeKindKey] = 'element';
+
// InstanceNode
protected readonly state: SharedNodeState;
protected override engineState: EngineState;
@@ -63,32 +71,35 @@ export class SelectField
return this.validation.currentState;
}
+ readonly submissionState: SubmissionState;
+
// ValueContext
+ override readonly contextNode = this;
+
readonly encodeValue = (runtimeValue: readonly SelectItem[]): string => {
const itemValues = new Set(runtimeValue.map(({ value }) => value));
+ const selectedItems = this.getValueOptions().filter(({ value }) => {
+ return itemValues.has(value);
+ });
- return Array.from(itemValues).join(' ');
+ return selectedItems.map(({ value }) => value).join(' ');
};
readonly decodeValue = (instanceValue: string): readonly SelectItem[] => {
- return this.scope.runTask(() => {
- const values = xmlXPathWhitespaceSeparatedList(instanceValue, {
+ const itemValues = new Set(
+ xmlXPathWhitespaceSeparatedList(instanceValue, {
ignoreEmpty: true,
- });
-
- const items = this.getSelectItemsByValue();
+ })
+ );
- return values
- .map((value) => {
- return items.get(value);
- })
- .filter((item): item is SelectItem => {
- return item != null;
- });
+ // TODO: also want set-like behavior, probably?
+ return this.getValueOptions().filter((option) => {
+ return itemValues.has(option.value);
});
};
protected readonly getValueOptions: Accessor;
+ protected readonly getValue: Accessor;
constructor(parent: GeneralParentNode, definition: SelectFieldDefinition) {
super(parent, definition);
@@ -100,6 +111,26 @@ export class SelectField
this.getValueOptions = valueOptions;
+ const [baseGetValue, setValue] = createValueState(this);
+
+ const getValue = this.scope.runTask(() => {
+ const selectItemsByValue = createMemo((): ReadonlyMap => {
+ return new Map(valueOptions().map((item) => [item.value, item]));
+ });
+
+ return createMemo(() => {
+ const items = selectItemsByValue();
+
+ return baseGetValue().filter((item) => {
+ return items.has(item.value);
+ });
+ });
+ });
+
+ this.getValue = getValue;
+
+ const valueState: SimpleAtomicState = [getValue, setValue];
+
const sharedStateOptions = {
clientStateFactory: this.engineConfig.stateFactory,
};
@@ -115,7 +146,7 @@ export class SelectField
label: createNodeLabel(this, definition),
hint: createFieldHint(this, definition),
children: null,
- value: createValueState(this),
+ value: valueState,
valueOptions,
},
sharedStateOptions
@@ -125,6 +156,7 @@ export class SelectField
this.engineState = state.engineState;
this.currentState = state.currentState;
this.validation = createValidationState(this, sharedStateOptions);
+ this.submissionState = createLeafNodeSubmissionState(this);
}
protected getSelectItemsByValue(
@@ -163,6 +195,11 @@ export class SelectField
this.updateSelectedItemValues([value]);
}
+ // XFormsXPathElement
+ override getXPathValue(): string {
+ return this.encodeValue(this.engineState.value);
+ }
+
// SelectNode
select(selectedItem: SelectItem): Root {
const { engineState, root } = this;
diff --git a/packages/xforms-engine/src/instance/StringField.ts b/packages/xforms-engine/src/instance/StringField.ts
deleted file mode 100644
index 1e035c4b0..000000000
--- a/packages/xforms-engine/src/instance/StringField.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { identity } from '@getodk/common/lib/identity.ts';
-import type { Accessor } from 'solid-js';
-import type { StringNode, StringNodeAppearances } from '../client/StringNode.ts';
-import type { TextRange } from '../client/TextRange.ts';
-import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts';
-import { createValueState } from '../lib/reactivity/createValueState.ts';
-import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
-import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts';
-import type { SharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
-import { createSharedNodeState } from '../lib/reactivity/node-state/createSharedNodeState.ts';
-import { createFieldHint } from '../lib/reactivity/text/createFieldHint.ts';
-import { createNodeLabel } from '../lib/reactivity/text/createNodeLabel.ts';
-import type { SimpleAtomicState } from '../lib/reactivity/types.ts';
-import type { SharedValidationState } from '../lib/reactivity/validation/createValidation.ts';
-import { createValidationState } from '../lib/reactivity/validation/createValidation.ts';
-import type { InputDefinition } from '../parse/body/control/InputDefinition.ts';
-import type { LeafNodeDefinition } from '../parse/model/LeafNodeDefinition.ts';
-import type { Root } from './Root.ts';
-import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts';
-import { DescendantNode } from './abstract/DescendantNode.ts';
-import type { GeneralParentNode } from './hierarchy.ts';
-import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
-import type { ValidationContext } from './internal-api/ValidationContext.ts';
-import type { ValueContext } from './internal-api/ValueContext.ts';
-
-export interface StringFieldDefinition extends LeafNodeDefinition {
- readonly bodyElement: InputDefinition;
-}
-
-interface StringFieldStateSpec extends DescendantNodeStateSpec {
- readonly label: Accessor | null>;
- readonly hint: Accessor | null>;
- readonly children: null;
- readonly value: SimpleAtomicState;
- readonly valueOptions: null;
-}
-
-export class StringField
- extends DescendantNode
- implements
- StringNode,
- EvaluationContext,
- SubscribableDependency,
- ValidationContext,
- ValueContext
-{
- private readonly validation: SharedValidationState;
- protected readonly state: SharedNodeState;
-
- // InstanceNode
- protected engineState: EngineState;
-
- // StringNode
- readonly nodeType = 'string';
- readonly appearances: StringNodeAppearances;
- readonly currentState: CurrentState;
-
- get validationState(): LeafNodeValidationState {
- return this.validation.currentState;
- }
-
- // ValueContext
- readonly encodeValue = identity;
-
- readonly decodeValue = identity;
-
- constructor(parent: GeneralParentNode, definition: StringFieldDefinition) {
- super(parent, definition);
-
- this.appearances = definition.bodyElement.appearances;
-
- const sharedStateOptions = {
- clientStateFactory: this.engineConfig.stateFactory,
- };
-
- const state = createSharedNodeState(
- this.scope,
- {
- reference: this.contextReference,
- readonly: this.isReadonly,
- relevant: this.isRelevant,
- required: this.isRequired,
-
- label: createNodeLabel(this, definition),
- hint: createFieldHint(this, definition),
- children: null,
- valueOptions: null,
- value: createValueState(this),
- },
- sharedStateOptions
- );
-
- this.state = state;
- this.engineState = state.engineState;
- this.currentState = state.currentState;
- this.validation = createValidationState(this, sharedStateOptions);
- }
-
- // ValidationContext
- getViolation(): AnyViolation | null {
- return this.validation.engineState.violation;
- }
-
- isBlank(): boolean {
- return this.engineState.value === '';
- }
-
- // InstanceNode
- getChildren(): readonly [] {
- return [];
- }
-
- // StringNode
- setValue(value: string): Root {
- this.state.setProperty('value', value);
-
- return this.root;
- }
-}
diff --git a/packages/xforms-engine/src/instance/Subtree.ts b/packages/xforms-engine/src/instance/Subtree.ts
index ea05774ad..2f02cc678 100644
--- a/packages/xforms-engine/src/instance/Subtree.ts
+++ b/packages/xforms-engine/src/instance/Subtree.ts
@@ -1,6 +1,11 @@
-import { type Accessor } from 'solid-js';
+import { XPathNodeKindKey } from '@getodk/xpath';
+import type { Accessor } from 'solid-js';
+import type { FormNodeID } from '../client/identity.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
import type { SubtreeDefinition, SubtreeNode } from '../client/SubtreeNode.ts';
import type { AncestorNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createParentNodeSubmissionState } from '../lib/client-reactivity/submission/createParentNodeSubmissionState.ts';
import type { ChildrenState } from '../lib/reactivity/createChildrenState.ts';
import { createChildrenState } from '../lib/reactivity/createChildrenState.ts';
import type { MaterializedChildren } from '../lib/reactivity/materializeCurrentStateChildren.ts';
@@ -14,33 +19,39 @@ import type { DescendantNodeSharedStateSpec } from './abstract/DescendantNode.ts
import { DescendantNode } from './abstract/DescendantNode.ts';
import { buildChildren } from './children.ts';
import type { GeneralChildNode, GeneralParentNode } from './hierarchy.ts';
-import type { NodeID } from './identity.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
+import type { ClientReactiveSubmittableParentNode } from './internal-api/submission/ClientReactiveSubmittableParentNode.ts';
interface SubtreeStateSpec extends DescendantNodeSharedStateSpec {
readonly label: null;
readonly hint: null;
- readonly children: Accessor;
+ readonly children: Accessor;
readonly valueOptions: null;
readonly value: null;
}
export class Subtree
- extends DescendantNode
- implements SubtreeNode, EvaluationContext, SubscribableDependency
+ extends DescendantNode
+ implements
+ SubtreeNode,
+ XFormsXPathElement,
+ EvaluationContext,
+ ClientReactiveSubmittableParentNode
{
private readonly childrenState: ChildrenState;
+ override readonly [XPathNodeKindKey] = 'element';
+
// InstanceNode
protected readonly state: SharedNodeState;
- protected engineState: EngineState;
+ protected readonly engineState: EngineState;
// SubtreeNode
readonly nodeType = 'subtree';
readonly appearances = null;
readonly currentState: MaterializedChildren, GeneralChildNode>;
readonly validationState: AncestorNodeValidationState;
+ readonly submissionState: SubmissionState;
constructor(parent: GeneralParentNode, definition: SubtreeDefinition) {
super(parent, definition);
@@ -80,6 +91,7 @@ export class Subtree
childrenState.setChildren(buildChildren(this));
this.validationState = createAggregatedViolations(this, sharedStateOptions);
+ this.submissionState = createParentNodeSubmissionState(this);
}
getChildren(): readonly GeneralChildNode[] {
diff --git a/packages/xforms-engine/src/instance/TriggerControl.ts b/packages/xforms-engine/src/instance/TriggerControl.ts
index 2e82d83fa..e4a4fb1b5 100644
--- a/packages/xforms-engine/src/instance/TriggerControl.ts
+++ b/packages/xforms-engine/src/instance/TriggerControl.ts
@@ -1,7 +1,11 @@
+import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
import type { TextRange } from '../client/TextRange.ts';
import type { TriggerNode, TriggerNodeDefinition } from '../client/TriggerNode.ts';
+import type { SubmissionState } from '../client/submission/SubmissionState.ts';
import type { AnyViolation, LeafNodeValidationState } from '../client/validation.ts';
+import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createLeafNodeSubmissionState } from '../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts';
import { createValueState } from '../lib/reactivity/createValueState.ts';
import type { CurrentState } from '../lib/reactivity/node-state/createCurrentState.ts';
import type { EngineState } from '../lib/reactivity/node-state/createEngineState.ts';
@@ -18,9 +22,9 @@ import type { DescendantNodeStateSpec } from './abstract/DescendantNode.ts';
import { DescendantNode } from './abstract/DescendantNode.ts';
import type { GeneralParentNode } from './hierarchy.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from './internal-api/SubscribableDependency.ts';
import type { ValidationContext } from './internal-api/ValidationContext.ts';
import type { ValueContext } from './internal-api/ValueContext.ts';
+import type { ClientReactiveSubmittableLeafNode } from './internal-api/submission/ClientReactiveSubmittableLeafNode.ts';
interface TriggerControlStateSpec extends DescendantNodeStateSpec {
readonly label: Accessor | null>;
@@ -33,19 +37,23 @@ interface TriggerControlStateSpec extends DescendantNodeStateSpec {
const TRIGGER_ASSIGNED_VALUE = 'OK';
export class TriggerControl
- extends DescendantNode
+ extends DescendantNode
implements
TriggerNode,
+ XFormsXPathElement,
EvaluationContext,
- SubscribableDependency,
ValidationContext,
- ValueContext
+ ValueContext,
+ ClientReactiveSubmittableLeafNode
{
private readonly validation: SharedValidationState;
- protected readonly state: SharedNodeState;
+
+ // XFormsXPathElement
+ override readonly [XPathNodeKindKey] = 'element';
// InstanceNode
- protected engineState: EngineState;
+ protected readonly state: SharedNodeState;
+ protected readonly engineState: EngineState;
// TriggerNode
readonly nodeType = 'trigger';
@@ -56,7 +64,10 @@ export class TriggerControl
return this.validation.currentState;
}
+ readonly submissionState: SubmissionState;
+
// ValueContext
+ override readonly contextNode = this;
readonly encodeValue: (runtimeValue: boolean) => string;
readonly decodeValue: (instanceValue: string) => boolean;
@@ -109,6 +120,12 @@ export class TriggerControl
this.engineState = state.engineState;
this.currentState = state.currentState;
this.validation = createValidationState(this, sharedStateOptions);
+ this.submissionState = createLeafNodeSubmissionState(this);
+ }
+
+ // XFormsXPathElement
+ override getXPathValue(): string {
+ return this.encodeValue(this.engineState.value);
}
// ValidationContext
diff --git a/packages/xforms-engine/src/instance/abstract/DescendantNode.ts b/packages/xforms-engine/src/instance/abstract/DescendantNode.ts
index 03f8b1e03..0d223862f 100644
--- a/packages/xforms-engine/src/instance/abstract/DescendantNode.ts
+++ b/packages/xforms-engine/src/instance/abstract/DescendantNode.ts
@@ -1,15 +1,22 @@
-import type { XFormsXPathEvaluator } from '@getodk/xpath';
+import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
+import { createMemo } from 'solid-js';
import type { BaseNode } from '../../client/BaseNode.ts';
+import type { ActiveLanguage } from '../../client/FormLanguage.ts';
+import type { InstanceNodeType } from '../../client/node-types.ts';
+import type { PrimaryInstanceXPathChildNode } from '../../integration/xpath/adapter/kind.ts';
+import type {
+ XFormsXPathPrimaryInstanceDescendantNode,
+ XFormsXPathPrimaryInstanceDescendantNodeKind,
+} from '../../integration/xpath/adapter/XFormsXPathNode.ts';
+import { XFORMS_XPATH_NODE_RANGE_KIND } from '../../integration/xpath/adapter/XFormsXPathNode.ts';
+import type { EngineXPathEvaluator } from '../../integration/xpath/EngineXPathEvaluator.ts';
import { createComputedExpression } from '../../lib/reactivity/createComputedExpression.ts';
import type { ReactiveScope } from '../../lib/reactivity/scope.ts';
import type { AnyDescendantNodeDefinition } from '../../parse/model/DescendentNodeDefinition.ts';
-import type { LeafNodeDefinition } from '../../parse/model/LeafNodeDefinition.ts';
import type { AnyNodeDefinition } from '../../parse/model/NodeDefinition.ts';
-import type { RepeatInstanceDefinition } from '../../parse/model/RepeatInstanceDefinition.ts';
-import type { AnyChildNode, GeneralParentNode, RepeatRange } from '../hierarchy.ts';
+import type { AnyChildNode, AnyParentNode, RepeatRange } from '../hierarchy.ts';
import type { EvaluationContext } from '../internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts';
import type { RepeatInstance } from '../repeat/RepeatInstance.ts';
import type { Root } from '../Root.ts';
import type { InstanceNodeStateSpec } from './InstanceNode.ts';
@@ -34,19 +41,13 @@ export type DescendantNodeDefinition = Extract<
AnyDescendantNodeDefinition
>;
-// prettier-ignore
-export type DescendantNodeParent =
- Definition extends LeafNodeDefinition
- ? GeneralParentNode
- : Definition extends RepeatInstanceDefinition
- ? RepeatRange
- : GeneralParentNode;
-
export type AnyDescendantNode = DescendantNode<
DescendantNodeDefinition,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DescendantNodeStateSpec,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+ any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
any
>;
@@ -58,11 +59,18 @@ export abstract class DescendantNode<
Definition extends DescendantNodeDefinition,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Spec extends DescendantNodeStateSpec,
+ Parent extends AnyParentNode,
Child extends AnyChildNode | null = null,
>
- extends InstanceNode
- implements BaseNode, EvaluationContext, SubscribableDependency
+ extends InstanceNode
+ implements BaseNode, XFormsXPathPrimaryInstanceDescendantNode, EvaluationContext
{
+ /**
+ * Partial implementation of {@link isAttached}, used to check whether `this`
+ * is present in {@link parent}'s children state.
+ */
+ protected readonly isAttachedDescendant: Accessor;
+
readonly hasReadonlyAncestor: Accessor = () => {
const { parent } = this;
@@ -97,51 +105,88 @@ export abstract class DescendantNode<
readonly isRequired: Accessor;
+ // XFormsXPathPrimaryInstanceDescendantNode
+
+ /**
+ * WARNING! Ideally, this would be an abstract property, defined by each
+ * concrete subclass (or other intermediate abstract classes, where
+ * appropriate). Unfortunately it must be assigned here, so it will be present
+ * for certain XPath DOM adapter functionality **during** each concrete node's
+ * construction.
+ *
+ * Those subclasses nevertheless override this same property, assigning the
+ * same value, for the purposes of narrowing the XPath node kind semantics
+ * appropriate for each node type.
+ */
+ override readonly [XPathNodeKindKey]: XFormsXPathPrimaryInstanceDescendantNodeKind;
readonly root: Root;
- readonly evaluator: XFormsXPathEvaluator;
- readonly contextNode: Element;
+
+ // BaseNode
+ abstract override readonly nodeType: InstanceNodeType;
+
+ // EvaluationContext
+ readonly isAttached: Accessor;
+ readonly evaluator: EngineXPathEvaluator;
+ override readonly contextNode: PrimaryInstanceXPathChildNode =
+ this as AnyDescendantNode as PrimaryInstanceXPathChildNode;
+ readonly getActiveLanguage: Accessor;
constructor(
- override readonly parent: DescendantNodeParent,
+ override readonly parent: Parent,
override readonly definition: Definition,
options?: DescendantNodeOptions
) {
super(parent.engineConfig, parent, definition, options);
- const { evaluator, root } = parent;
+ if (this.isRoot()) {
+ this.root = this;
+ } else {
+ this.root = parent.root;
+ }
- this.root = root;
- this.evaluator = evaluator;
- this.contextNode = this.initializeContextNode(parent.contextNode, definition.nodeName);
+ const { evaluator } = parent;
- const { readonly, relevant, required } = definition.bind;
+ // See notes on property declaration
+ if (definition.type === 'repeat-range') {
+ this[XPathNodeKindKey] = XFORMS_XPATH_NODE_RANGE_KIND;
+ } else {
+ this[XPathNodeKindKey] = 'element';
+ }
- this.isSelfReadonly = createComputedExpression(this, readonly);
- this.isSelfRelevant = createComputedExpression(this, relevant);
- this.isRequired = createComputedExpression(this, required);
- }
+ const self = this as AnyDescendantNode as AnyChildNode;
- protected createContextNode(parentContextNode: Element, nodeName: string): Element {
- return parentContextNode.ownerDocument.createElement(nodeName);
- }
+ this.isAttachedDescendant = this.scope.runTask(() => {
+ return createMemo(() => {
+ for (const child of parent.getChildren()) {
+ if (child === self) {
+ return true;
+ }
+ }
- /**
- * Currently expected to be overridden by...
- *
- * - Repeat range: returns its parent's context node, because it doesn't have
- * a node in the primary instance tree.
- *
- * - Repeat instance: returns its created context node, but overrides handles
- * appending behavior separately (for inserting at the end of its parent
- * range, or even at an arbitrary index within the range, after instance
- * creation is has completed).
- */
- protected initializeContextNode(parentContextNode: Element, nodeName: string): Element {
- const element = this.createContextNode(parentContextNode, nodeName);
+ return false;
+ });
+ });
+
+ this.isAttached = this.scope.runTask(() => {
+ return createMemo(() => {
+ return this.parent.isAttached() && this.isAttachedDescendant();
+ });
+ });
+
+ this.evaluator = evaluator;
+ this.getActiveLanguage = parent.getActiveLanguage;
- parentContextNode.append(element);
+ const { readonly, relevant, required } = definition.bind;
- return element;
+ this.isSelfReadonly = createComputedExpression(this, readonly, {
+ defaultValue: true,
+ });
+ this.isSelfRelevant = createComputedExpression(this, relevant, {
+ defaultValue: false,
+ });
+ this.isRequired = createComputedExpression(this, required, {
+ defaultValue: false,
+ });
}
/**
diff --git a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts
index 543988f3d..180331af5 100644
--- a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts
+++ b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts
@@ -1,10 +1,18 @@
-import type { XFormsXPathEvaluator } from '@getodk/xpath';
+import type { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor, Signal } from 'solid-js';
import type { BaseNode } from '../../client/BaseNode.ts';
import type { NodeAppearances } from '../../client/NodeAppearances.ts';
-import type { InstanceNodeType } from '../../client/node-types.ts';
+import type { FormNodeID } from '../../client/identity.ts';
+import type { InstanceNodeType as ClientInstanceNodeType } from '../../client/node-types.ts';
+import type { SubmissionState } from '../../client/submission/SubmissionState.ts';
import type { NodeValidationState } from '../../client/validation.ts';
-import type { TextRange } from '../../index.ts';
+import type { ActiveLanguage, TextRange } from '../../index.ts';
+import type { EngineXPathEvaluator } from '../../integration/xpath/EngineXPathEvaluator.ts';
+import type {
+ XFormsXPathPrimaryInstanceNode,
+ XFormsXPathPrimaryInstanceNodeKind,
+} from '../../integration/xpath/adapter/XFormsXPathNode.ts';
+import type { PrimaryInstanceXPathNode } from '../../integration/xpath/adapter/kind.ts';
import type { MaterializedChildren } from '../../lib/reactivity/materializeCurrentStateChildren.ts';
import type { CurrentState } from '../../lib/reactivity/node-state/createCurrentState.ts';
import type { EngineState } from '../../lib/reactivity/node-state/createEngineState.ts';
@@ -13,13 +21,18 @@ import type { ReactiveScope } from '../../lib/reactivity/scope.ts';
import { createReactiveScope } from '../../lib/reactivity/scope.ts';
import type { SimpleAtomicState } from '../../lib/reactivity/types.ts';
import type { AnyNodeDefinition } from '../../parse/model/NodeDefinition.ts';
+import type { PrimaryInstance } from '../PrimaryInstance.ts';
import type { Root } from '../Root.ts';
import type { AnyChildNode, AnyNode, AnyParentNode } from '../hierarchy.ts';
-import type { NodeID } from '../identity.ts';
-import { declareNodeID } from '../identity.ts';
+import { nodeID } from '../identity.ts';
import type { EvaluationContext } from '../internal-api/EvaluationContext.ts';
import type { InstanceConfig } from '../internal-api/InstanceConfig.ts';
-import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts';
+
+export type EngineInstanceNodeType = ClientInstanceNodeType | 'primary-instance';
+
+export interface BaseEngineNode extends Omit {
+ readonly nodeType: EngineInstanceNodeType;
+}
export interface InstanceNodeStateSpec {
readonly reference: Accessor | string;
@@ -28,7 +41,7 @@ export interface InstanceNodeStateSpec {
readonly required: Accessor | boolean;
readonly label: Accessor | null> | null;
readonly hint: Accessor | null> | null;
- readonly children: Accessor | null;
+ readonly children: Accessor | null;
readonly valueOptions: Accessor | Accessor | null;
readonly value: Signal | SimpleAtomicState | null;
}
@@ -71,19 +84,26 @@ type ComputeInstanceNodeReference = (
export interface InstanceNodeOptions {
readonly computeReference?: () => string;
+ readonly scope?: ReactiveScope;
}
export abstract class InstanceNode<
Definition extends AnyNodeDefinition,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Spec extends InstanceNodeStateSpec,
+ Parent extends AnyParentNode | null,
Child extends AnyChildNode | null = null,
>
- implements BaseNode, EvaluationContext, SubscribableDependency
+ implements BaseEngineNode, XFormsXPathPrimaryInstanceNode, EvaluationContext
{
protected abstract readonly state: SharedNodeState;
protected abstract readonly engineState: EngineState;
+ // XFormsXPathPrimaryInstanceNode
+ abstract readonly [XPathNodeKindKey]: XFormsXPathPrimaryInstanceNodeKind;
+ readonly rootDocument: PrimaryInstance;
+ abstract readonly root: Root;
+
/**
* @package Exposed on every node type to facilitate inheritance, as well as
* conditional behavior for value nodes.
@@ -109,10 +129,10 @@ export abstract class InstanceNode<
abstract readonly isRelevant: Accessor;
// BaseNode: identity
- readonly nodeId: NodeID;
+ readonly nodeId: FormNodeID;
// BaseNode: node types and variants (e.g. for narrowing)
- abstract readonly nodeType: InstanceNodeType;
+ abstract readonly nodeType: EngineInstanceNodeType;
abstract readonly appearances: NodeAppearances;
@@ -120,15 +140,15 @@ export abstract class InstanceNode<
abstract readonly validationState: NodeValidationState;
- // BaseNode: structural
- abstract readonly root: Root;
+ abstract readonly submissionState: SubmissionState;
// EvaluationContext: instance-global/shared
- abstract readonly evaluator: XFormsXPathEvaluator;
+ abstract readonly evaluator: EngineXPathEvaluator;
+ abstract readonly getActiveLanguage: Accessor;
- // EvaluationContext *and* Subscribable: node-specific
+ // EvaluationContext: node-specific
+ abstract readonly isAttached: Accessor;
readonly scope: ReactiveScope;
-
readonly computeReference: ComputeInstanceNodeReference;
protected readonly computeChildStepReference: ComputeInstanceNodeReference = (
@@ -149,114 +169,92 @@ export abstract class InstanceNode<
return this.computeReference(this.parent, this.definition);
};
- abstract readonly contextNode: Element;
+ /**
+ * Note: it is expected that at least some node subclasses will override this
+ * to reflect (or in the case of intermediate abstract base classes, to
+ * constrain) their more specific `this` type.
+ */
+ readonly contextNode: PrimaryInstanceXPathNode =
+ this as AnyInstanceNode as PrimaryInstanceXPathNode;
constructor(
readonly engineConfig: InstanceConfig,
- readonly parent: AnyParentNode | null,
+ readonly parent: Parent,
readonly definition: Definition,
options?: InstanceNodeOptions
) {
- this.computeReference = options?.computeReference ?? this.computeChildStepReference;
-
- this.scope = createReactiveScope();
- this.engineConfig = engineConfig;
- this.nodeId = declareNodeID(engineConfig.createUniqueId());
- this.definition = definition;
- }
-
- /**
- * @package This presently serves a few internal use cases, where certain
- * behaviors depend on arbitrary traversal from any point in the instance
- * tree, without particular regard for the visited node type. It isn't
- * intended for external traversal or any other means of consuming children by
- * a client. This return type intentionally deviates from one structural
- * expectation, requiring even leaf nodes to return an array (though for those
- * nodes it will always be empty). This affords consistency and efficiency of
- * interface for those internal uses.
- */
- abstract getChildren(this: AnyInstanceNode): readonly AnyChildNode[];
+ const self = this as AnyInstanceNode as AnyNode;
- getNodesByReference(
- this: AnyNode,
- visited: WeakSet,
- dependencyReference: string
- ): readonly SubscribableDependency[] {
- if (visited.has(this)) {
- return [];
- }
-
- visited.add(this);
-
- const { nodeset } = this.definition;
-
- if (dependencyReference === nodeset) {
- if (this.nodeType === 'repeat-instance') {
- return [this.parent];
+ if (parent == null) {
+ if (!self.isPrimaryInstance()) {
+ throw new Error(
+ 'Failed to construct node: not a primary instance, no parent node specified'
+ );
}
- return [this];
+ this.rootDocument = self;
+ } else {
+ this.rootDocument = parent.rootDocument;
}
- if (
- dependencyReference.startsWith(`${nodeset}/`) ||
- dependencyReference.startsWith(`${nodeset}[`)
- ) {
- return this.getChildren().flatMap((child) => {
- return child.getNodesByReference(visited, dependencyReference);
- });
- }
+ this.computeReference = options?.computeReference ?? this.computeChildStepReference;
- return this.parent?.getNodesByReference(visited, dependencyReference) ?? [];
+ this.scope = options?.scope ?? createReactiveScope();
+ this.engineConfig = engineConfig;
+ this.nodeId = nodeID(engineConfig.createUniqueId());
+ this.definition = definition;
}
- // EvaluationContext: node-relative
- getSubscribableDependenciesByReference(
- this: AnyNode,
- reference: string
- ): readonly SubscribableDependency[] {
- if (this.nodeType === 'root') {
- const visited = new WeakSet();
-
- return this.getNodesByReference(visited, reference);
- }
+ /** @package */
+ isPrimaryInstance(): this is PrimaryInstance {
+ return this.parent == null;
+ }
- return this.root.getSubscribableDependenciesByReference(reference);
+ /** @package */
+ isRoot(): this is Root {
+ return this.parent?.nodeType === 'primary-instance';
}
- // SubscribableDependency
/**
- * This is a default implementation suitable for most node types. The rest
- * (currently: `Root`, `RepeatRange`, `RepeatInstance`) should likely extend
- * this behavior, rather than simply overriding it.
+ * @package This presently serves a growing variety of internal use cases,
+ * where certain behaviors depend on arbitrary traversal from any point in the
+ * instance tree, without particular regard for the visited node type. It
+ * isn't intended for external traversal or any other means of consuming
+ * children by a client. This return type intentionally deviates from one
+ * structural expectation, requiring even leaf nodes to return an array
+ * (though for those nodes it will always be empty). This affords consistency
+ * and efficiency of interface for those internal uses.
*/
- subscribe(): void {
- const { engineState } = this;
-
- // Note: a previous iteration of this default implementation guarded these
- // reactive reads behind a relevance check. This caused timing issues for
- // downstream computations referencing a node whose relevance changes.
- //
- // That original guard was intended to reduce excessive redundant
- // computations, and so removing it is intended as a naive compromise of
- // performance for obvious correctness improvements.
- //
- // This compromise, like many others, will be moot if/when we decide to
- // decouple XPath evaluation from the browser/XML DOM: reactive
- // subscriptions would be established by evaluation of the expressions
- // themselves (as they traverse instance state and access values), rather
- // than this safer/less focused approach.
-
- // TODO: typescript-eslint is right to object to these! We should _at least_
- // make internal reactive reads obvious, i.e. function calls.
-
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- engineState.reference;
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- engineState.relevant;
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- engineState.children;
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- engineState.value;
+ abstract getChildren(this: AnyInstanceNode): readonly AnyChildNode[];
+
+ // XFormsXPathNode
+ /**
+ * @todo Values as text nodes(?)
+ */
+ getXPathChildNodes(): readonly AnyChildNode[] {
+ return (this as AnyInstanceNode).getChildren().flatMap((child) => {
+ switch (child.nodeType) {
+ case 'repeat-range:controlled':
+ case 'repeat-range:uncontrolled': {
+ const repeatInstances = child.getXPathChildNodes();
+
+ if (repeatInstances.length > 0) {
+ return repeatInstances;
+ }
+
+ return child;
+ }
+
+ default:
+ return child;
+ }
+ });
+ }
+
+ getXPathValue(): string {
+ return (this as AnyInstanceNode as AnyNode)
+ .getXPathChildNodes()
+ .map((child) => child.getXPathValue())
+ .join('');
}
}
diff --git a/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts b/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts
index 9ebfc0188..a815cdf1d 100644
--- a/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts
+++ b/packages/xforms-engine/src/instance/abstract/UnsupportedControl.ts
@@ -1,6 +1,8 @@
import { identity } from '@getodk/common/lib/identity.ts';
+import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
import type { UnsupportedControlNodeType } from '../../client/node-types.ts';
+import type { SubmissionState } from '../../client/submission/SubmissionState.ts';
import type { TextRange } from '../../client/TextRange.ts';
import type {
UnsupportedControlDefinition,
@@ -8,6 +10,8 @@ import type {
UnsupportedControlNode,
} from '../../client/unsupported/UnsupportedControlNode.ts';
import type { AnyViolation, LeafNodeValidationState } from '../../client/validation.ts';
+import type { XFormsXPathElement } from '../../integration/xpath/adapter/XFormsXPathNode.ts';
+import { createLeafNodeSubmissionState } from '../../lib/client-reactivity/submission/createLeafNodeSubmissionState.ts';
import { createValueState } from '../../lib/reactivity/createValueState.ts';
import type { CurrentState } from '../../lib/reactivity/node-state/createCurrentState.ts';
import type { EngineState } from '../../lib/reactivity/node-state/createEngineState.ts';
@@ -23,9 +27,9 @@ import {
type SharedValidationState,
} from '../../lib/reactivity/validation/createValidation.ts';
import type { UnknownAppearanceDefinition } from '../../parse/body/appearance/unknownAppearanceParser.ts';
-import type { GeneralParentNode } from '../hierarchy.ts';
+import type { AnyUnsupportedControl, GeneralParentNode } from '../hierarchy.ts';
import type { EvaluationContext } from '../internal-api/EvaluationContext.ts';
-import type { SubscribableDependency } from '../internal-api/SubscribableDependency.ts';
+import type { ClientReactiveSubmittableLeafNode } from '../internal-api/submission/ClientReactiveSubmittableLeafNode.ts';
import type { ValidationContext } from '../internal-api/ValidationContext.ts';
import type { ValueContext } from '../internal-api/ValueContext.ts';
import { DescendantNode, type DescendantNodeStateSpec } from './DescendantNode.ts';
@@ -61,17 +65,26 @@ class UnsupportedControlWriteError extends Error {
}
export abstract class UnsupportedControl