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}`; + } +} + +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 ?? '')}`; - - return indentLine(depth, serializedLeafNode); - } - - return [ - indentLine(depth, `<${nodeName}>`), - children.map((child) => { - return serializeNode(child, depth + 1); - }), - indentLine(depth, ``), - ] - .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 @@ + + + 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 @@