Skip to content

Commit 1f894d1

Browse files
Merge pull request #352 from getodk/edit-epic/deprecatedId-instanceId-switcheroo
[Edits] Support for `deprecatedID` and `instanceID` edit semantics
2 parents cbfe1d3 + 4b3283f commit 1f894d1

File tree

18 files changed

+1276
-204
lines changed

18 files changed

+1276
-204
lines changed

.changeset/selfish-insects-fry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@getodk/xforms-engine': minor
3+
---
4+
5+
- Edited instance `instanceID` metadata is transfered to `deprecatedID`
6+
- On forms defining `instanceID` to be computed by `preload="uid"`, edited instance `instanceID` metadata is recomputed after its previous value is transferred to `deprecatedID`
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const PRELOAD_UID_PATTERN =
2+
/^uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;

packages/scenario/src/assertion/extensions/submission.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { PRELOAD_UID_PATTERN } from '@getodk/common/constants/regex.ts';
2+
import {
3+
OPENROSA_XFORMS_NAMESPACE_URI,
4+
XFORMS_NAMESPACE_URI,
5+
} from '@getodk/common/constants/xmlns.ts';
16
import { assertUnknownArray } from '@getodk/common/lib/type-assertions/assertUnknownArray.ts';
27
import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts';
38
import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
9+
import type * as CommonAssertionHelpers from '@getodk/common/test/assertions/helpers.ts';
410
import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts';
511
import {
612
ArbitraryConditionExpectExtension,
@@ -11,6 +17,7 @@ import {
1117
} from '@getodk/common/test/assertions/helpers.ts';
1218
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts';
1319
import type { AssertIs } from '@getodk/common/types/assertions/AssertIs.ts';
20+
import type { ExpandUnion } from '@getodk/common/types/helpers.js';
1421
import type { InstanceFile, InstancePayload, InstancePayloadType } from '@getodk/xforms-engine';
1522
import { constants } from '@getodk/xforms-engine';
1623
import { assert, expect } from 'vitest';
@@ -87,6 +94,159 @@ const getInstanceFile = (payload: AnyInstancePayload): InstanceFile => {
8794
return file;
8895
};
8996

97+
const META_NAMESPACE_URIS = [OPENROSA_XFORMS_NAMESPACE_URI, XFORMS_NAMESPACE_URI] as const;
98+
99+
type MetaNamespaceURI = (typeof META_NAMESPACE_URIS)[number];
100+
101+
type AssertEnumeratedString<T extends string> = (actual: unknown) => asserts actual is T;
102+
103+
/**
104+
* @todo This is probably general enough to be exported from {@link CommonAssertionHelpers}
105+
*/
106+
const enumeratedStringAssertion = <T extends string>(
107+
expected: readonly T[]
108+
): AssertEnumeratedString<T> => {
109+
return (actual) => {
110+
assertString(actual);
111+
112+
expect(expected).toContain(actual);
113+
};
114+
};
115+
116+
const assertMetaNamespaceURI: AssertEnumeratedString<MetaNamespaceURI> =
117+
enumeratedStringAssertion(META_NAMESPACE_URIS);
118+
119+
interface SerializedMetaChildValues {
120+
readonly instanceID: string | null;
121+
readonly deprecatedID: string | null;
122+
}
123+
124+
type MetaChildLocalName = ExpandUnion<keyof SerializedMetaChildValues>;
125+
126+
interface SerializedMeta extends SerializedMetaChildValues {
127+
readonly meta: Element | null;
128+
}
129+
130+
type MetaElementLocalName = ExpandUnion<keyof SerializedMeta>;
131+
132+
/**
133+
* @todo this is general enough to go in `common` package, and would probably
134+
* find reuse pretty quickly. Holding off for now because it has overlap with
135+
* several even more general tuple-length narrowing cases:
136+
*
137+
* - Unbounded length -> 1-ary tuple
138+
* - Unbounded length -> parameterized N-ary tuple
139+
* - Unbounded length -> partially bounded (min-N, max-N) tuple
140+
* - Type guards (predicate) and `asserts` equivalents of each
141+
*
142+
* Each of these cases comes up frequently! I've written them at least a few
143+
* dozen times, and always back out to more specific logic for pragmatic
144+
* reasons. But having these generalizations would allow pretty significant
145+
* simplification of a lot of their use cases.
146+
*/
147+
const findExclusiveMatch = <T>(
148+
values: readonly T[],
149+
predicate: (value: T) => boolean
150+
): T | null => {
151+
const results = values.filter(predicate);
152+
153+
expect(results.length).toBeLessThanOrEqual(1);
154+
155+
return results[0] ?? null;
156+
};
157+
158+
const getMetaElement = (
159+
parent: ParentNode | null,
160+
namespaceURI: MetaNamespaceURI,
161+
localName: MetaElementLocalName
162+
): Element | null => {
163+
if (parent == null) {
164+
return null;
165+
}
166+
167+
const children = Array.from(parent.children);
168+
169+
return findExclusiveMatch(children, (child) => {
170+
return child.namespaceURI === namespaceURI && child.localName === localName;
171+
});
172+
};
173+
174+
const getMetaChildValue = (
175+
metaElement: Element | null,
176+
namespaceURI: MetaNamespaceURI,
177+
localName: MetaChildLocalName
178+
): string | null => {
179+
const element = getMetaElement(metaElement, namespaceURI, localName);
180+
181+
if (element == null) {
182+
return null;
183+
}
184+
185+
expect(element.childElementCount).toBe(0);
186+
187+
const { textContent } = element;
188+
189+
assert(typeof textContent === 'string');
190+
191+
return textContent;
192+
};
193+
194+
interface MetaNamespaceOptions {
195+
readonly [key: string]: unknown;
196+
readonly metaNamespaceURI: MetaNamespaceURI;
197+
}
198+
199+
type AssertMetaNamespaceOptions = (value: unknown) => asserts value is MetaNamespaceOptions;
200+
201+
const assertMetaNamespaceOptions: AssertMetaNamespaceOptions = (value) => {
202+
assertUnknownObject(value);
203+
assertMetaNamespaceURI(value.metaNamespaceURI);
204+
};
205+
206+
const getSerializedMeta = (scenario: Scenario, namespaceURI: MetaNamespaceURI): SerializedMeta => {
207+
const serializedInstanceBody = scenario.proposed_serializeInstance();
208+
/**
209+
* Important: we intentionally omit the default namespace when serializing instance XML. We need to restore it here to reliably traverse nodes when {@link metaNamespaceURI} is {@link XFORMS_NAMESPACE_URI}.
210+
*/
211+
const instanceXML = `<instance xmlns="${XFORMS_NAMESPACE_URI}">${serializedInstanceBody}</instance>`;
212+
213+
const parser = new DOMParser();
214+
const instanceDocument = parser.parseFromString(instanceXML, 'text/xml');
215+
const instanceElement = instanceDocument.documentElement;
216+
const instanceRoot = instanceElement.firstElementChild;
217+
218+
assert(
219+
instanceRoot != null,
220+
`Failed to find instance root element.\n\nActual serialized XML: ${serializedInstanceBody}\n\nActual instance DOM state: ${instanceElement.outerHTML}`
221+
);
222+
223+
const meta = getMetaElement(instanceRoot, namespaceURI, 'meta');
224+
const instanceID = getMetaChildValue(meta, namespaceURI, 'instanceID');
225+
const deprecatedID = getMetaChildValue(meta, namespaceURI, 'deprecatedID');
226+
227+
return {
228+
meta,
229+
instanceID,
230+
deprecatedID,
231+
};
232+
};
233+
234+
const assertPreloadUIDValue = (actual: string | null) => {
235+
assert(actual != null, 'Expected preload uid value to be serialized');
236+
expect(actual, 'Expected preload uid value to match pattern').toMatch(PRELOAD_UID_PATTERN);
237+
};
238+
239+
interface EditedMetaOptions extends MetaNamespaceOptions {
240+
readonly sourceScenario: Scenario;
241+
}
242+
243+
type AssertEditedMetaOptions = (value: unknown) => asserts value is EditedMetaOptions;
244+
245+
const assertEditedMetaOptions: AssertEditedMetaOptions = (value) => {
246+
assertMetaNamespaceOptions(value);
247+
assertScenario(value.sourceScenario);
248+
};
249+
90250
export const submissionExtensions = extendExpect(expect, {
91251
toHaveSerializedSubmissionXML: new AsymmetricTypedExpectExtension(
92252
assertScenario,
@@ -152,6 +312,88 @@ export const submissionExtensions = extendExpect(expect, {
152312
return compareSubmissionXML(actualText, expected);
153313
}
154314
),
315+
316+
toHaveComputedPreloadInstanceID: new AsymmetricTypedExpectExtension(
317+
assertScenario,
318+
assertMetaNamespaceOptions,
319+
(scenario, options): SimpleAssertionResult => {
320+
try {
321+
const meta = getSerializedMeta(scenario, options.metaNamespaceURI);
322+
323+
assertPreloadUIDValue(meta.instanceID);
324+
325+
return true;
326+
} catch (error) {
327+
if (error instanceof Error) {
328+
return error;
329+
}
330+
331+
// eslint-disable-next-line no-console
332+
console.error(error);
333+
return new Error('Unknown error');
334+
}
335+
}
336+
),
337+
338+
toHaveEditedPreloadInstanceID: new AsymmetricTypedExpectExtension(
339+
assertScenario,
340+
assertEditedMetaOptions,
341+
(editedScenario, options): SimpleAssertionResult => {
342+
try {
343+
const { metaNamespaceURI, sourceScenario } = options;
344+
const sourceMeta = getSerializedMeta(sourceScenario, metaNamespaceURI);
345+
const editedMeta = getSerializedMeta(editedScenario, metaNamespaceURI);
346+
347+
assertPreloadUIDValue(sourceMeta.instanceID);
348+
assertPreloadUIDValue(editedMeta.instanceID);
349+
350+
expect(
351+
editedMeta.instanceID,
352+
'Expected preloaded instanceID metadata to be recomputed on edit'
353+
).not.toBe(sourceMeta.instanceID);
354+
355+
return true;
356+
} catch (error) {
357+
if (error instanceof Error) {
358+
return error;
359+
}
360+
361+
// eslint-disable-next-line no-console
362+
console.error(error);
363+
return new Error('Unknown error');
364+
}
365+
}
366+
),
367+
368+
toHaveDeprecatedIDFromSource: new AsymmetricTypedExpectExtension(
369+
assertScenario,
370+
assertEditedMetaOptions,
371+
(editedScenario, options): SimpleAssertionResult => {
372+
try {
373+
const { metaNamespaceURI, sourceScenario } = options;
374+
const sourceMeta = getSerializedMeta(sourceScenario, metaNamespaceURI);
375+
const editedMeta = getSerializedMeta(editedScenario, metaNamespaceURI);
376+
377+
assertPreloadUIDValue(sourceMeta.instanceID);
378+
assertPreloadUIDValue(editedMeta.deprecatedID);
379+
380+
expect(
381+
editedMeta.deprecatedID,
382+
'Expected edited deprecatedID metadata to be assigned from source instanceID'
383+
).toBe(sourceMeta.instanceID);
384+
385+
return true;
386+
} catch (error) {
387+
if (error instanceof Error) {
388+
return error;
389+
}
390+
391+
// eslint-disable-next-line no-console
392+
console.error(error);
393+
return new Error('Unknown error');
394+
}
395+
}
396+
),
155397
});
156398

157399
type SubmissionExtensions = typeof submissionExtensions;

0 commit comments

Comments
 (0)