Skip to content
This repository has been archived by the owner on Oct 17, 2019. It is now read-only.

Add FHIR Type to fromFHIR function signature #46

Merged
merged 6 commits into from
May 28, 2019
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions lib/generateClass.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,14 @@ function generateClassBody(def, specs, fhir, fhirProfile, fhirExtension, cw) {
cw.ln(`Deserializes FHIR JSON data to an instance of the ${clazzName} class.`)
.ln(`The FHIR must be valid against the ${clazzName} FHIR profile, although this is not validated by the function.`)
.ln(`@param {object} fhir - the FHIR JSON data to deserialize`)
.ln(`@param {string} fhirType - the type of the FHIR object that was passed in, in case not otherwise identifiable from the object itself`)
.ln(`@param {string} shrId - a unique, persistent, permanent identifier for the overall health record belonging to the Patient; will be auto-generated if not provided`)
.ln(`@param {Array} allEntries - the list of all entries that references in 'fhir' refer to`)
.ln(`@param {object} mappedResources - any resources that have already been mapped to SHR objects. Format is { fhir_key: {shr_obj} }`)
.ln(`@param {Array} referencesOut - list of all SHR ref() targets that were instantiated during this function call`)
.ln(`@param {boolean} asExtension - Whether the provided instance is an extension`)
.ln(`@returns {${clazzName}} An instance of ${clazzName} populated with the FHIR data`);
}).bl('static fromFHIR(fhir, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)', () => writeFromFhir(def, specs, fhir, fhirProfile, fhirExtension, cw))
}).bl('static fromFHIR(fhir, fhirType, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)', () => writeFromFhir(def, specs, fhir, fhirProfile, fhirExtension, cw))
.ln();
}

Expand Down Expand Up @@ -474,13 +475,13 @@ function writeFromFhir(def, specs, fhir, fhirProfile, fhirExtension, cw) {
cw.ln(`const inst = new ${className(def.identifier.name)}();`);

if (def.isEntry) {
cw.ln(`inst.entryInfo = FHIRHelper.createInstanceFromFHIR('shr.base.Entry', {});`); // do it this way so we don't have to import Entry
cw.ln(`inst.entryInfo.shrId = FHIRHelper.createInstanceFromFHIR('shr.base.ShrId', shrId);`);
cw.ln(`inst.entryInfo.entryId = FHIRHelper.createInstanceFromFHIR('shr.base.EntryId', fhir['id'] || uuid());`); // re-use the FHIR id if it exists, otherwise generate a new uuid
cw.ln(`inst.entryInfo = FHIRHelper.createInstanceFromFHIR('shr.base.Entry', {}, null);`); // do it this way so we don't have to import Entry
cw.ln(`inst.entryInfo.shrId = FHIRHelper.createInstanceFromFHIR('shr.base.ShrId', shrId, 'string');`);
cw.ln(`inst.entryInfo.entryId = FHIRHelper.createInstanceFromFHIR('shr.base.EntryId', fhir['id'] || uuid(), 'string');`); // re-use the FHIR id if it exists, otherwise generate a new uuid

// copied from writeToJson above --- should this URL be configurable?
const url = `http://standardhealthrecord.org/spec/${def.identifier.namespace.replace('.', '/')}/${className(def.identifier.name)}`;
cw.ln(`inst.entryInfo.entryType = FHIRHelper.createInstanceFromFHIR('shr.base.EntryType', '${url}');`);
cw.ln(`inst.entryInfo.entryType = FHIRHelper.createInstanceFromFHIR('shr.base.EntryType', '${url}', 'uri');`);
}

if(fhirProfile){
Expand Down Expand Up @@ -655,7 +656,7 @@ function writeFromFhirProfile(def, specs, fhir, fhirProfile, cw) {
if(mapping.map === '<Value>'){
// Mapping to the value of this es6 instance
if (def.value instanceof IdentifiableValue && def.value.identifier.isPrimitive) {
generateFromFHIRAssignment(def.value, element, fhirElementPath, [], 'value', fhirProfile, null, cw);
generateFromFHIRAssignment(def.value, element, fhirElementPath, [], 'value', fhirProfile, null, cw, fhir);
} else {
logger.error('Value referenced in mapping but none exist on this element.');
}
Expand Down Expand Up @@ -697,13 +698,13 @@ function writeFromFhirProfile(def, specs, fhir, fhirProfile, cw) {
}

if (i == mapping.fieldChain.length - 1) { // if it's the last field in the chain
generateFromFHIRAssignment(field, element, fhirElementPath, mapping.fieldMapPath, shrElementPath, fhirProfile, slicing, cw);
generateFromFHIRAssignment(field, element, fhirElementPath, mapping.fieldMapPath, shrElementPath, fhirProfile, slicing, cw, fhir);
} else {
// if it's not the last element in the field chain, it's an intermediate one so we just want to initialize the value so it's not null
const dec = field.card.isList ? 'const ' : ''; // if in a list, we need to declare the new variable, so do `const x = new()`
const nullCheck = field.card.isList ? '' : `${shrElementPath} || `; // if not in a list, consider that the field was already init'ed, so do `x = x || new()`

let rhs = `FHIRHelper.createInstanceFromFHIR('${field.effectiveIdentifier.fqn}', {}, shrId)`;
let rhs = `FHIRHelper.createInstanceFromFHIR('${field.effectiveIdentifier.fqn}', {}, null, shrId)`;
if (field instanceof RefValue) {
rhs = `FHIRHelper.createReference( ${rhs}, referencesOut)`;
}
Expand Down Expand Up @@ -805,7 +806,7 @@ function writeFromFhirExtension(def, specs, fhir, fhirExtension, cw) {
const varName = `match_${i}`; // ensure a unique variable name here
cw.ln(`const ${varName} = fhir['extension'].find(e => e.url == '${profileUrl}');`);
cw.bl(`if (${varName} != null)`, () => {
cw.ln(`inst.${methodName} = FHIRHelper.createInstanceFromFHIR('${instance}', ${varName}, shrId, allEntries, mappedResources, referencesOut, true);`); // asExtension = true here, false(default value) everywhere else
cw.ln(`inst.${methodName} = FHIRHelper.createInstanceFromFHIR('${instance}', ${varName}, 'Extension', shrId, allEntries, mappedResources, referencesOut, true);`); // asExtension = true here, false(default value) everywhere else
});
}
});
Expand All @@ -827,12 +828,12 @@ function writeFromFhirValue(def, specs, cw) {
cw.ln(`inst.value = fhir;`);
} else {
const shrType = def.value.effectiveIdentifier.fqn;
cw.ln(`inst.value = FHIRHelper.createInstanceFromFHIR('${shrType}', fhir, shrId, allEntries, mappedResources, referencesOut);`);
cw.ln(`inst.value = FHIRHelper.createInstanceFromFHIR('${shrType}', fhir, fhirType, shrId, allEntries, mappedResources, referencesOut);`);
}
} else {
// it could be any of the options, and we can't necessarily tell which one here
// so just call createInstance to leverage the logic that looks up profiles
cw.ln(`inst.value = FHIRHelper.createInstanceFromFHIR(null, fhir, shrId, allEntries, mappedResources, referencesOut);`);
cw.ln(`inst.value = FHIRHelper.createInstanceFromFHIR(null, fhir, fhirType, shrId, allEntries, mappedResources, referencesOut);`);
}
});
}
Expand Down Expand Up @@ -1683,8 +1684,9 @@ function generateToFHIRAssignment(cardIsList, baseCardIsList, constraintsLength,
* @param {StructureDefinition} fhirProfile - the FHIR profile the element comes from
* @param {Object} slicing - information on this element related to slicing, if any
* @param {CodeWriter} cw - the CodeWriter that is writing the file for this element
* @param {object} fhir - All exported FHIR profiles and extensions.
*/
function generateFromFHIRAssignment(field, fhirElement, fhirElementPath, shrElementMapping, shrElementPath, fhirProfile, slicing, cw) {
function generateFromFHIRAssignment(field, fhirElement, fhirElementPath, shrElementMapping, shrElementPath, fhirProfile, slicing, cw, fhir) {
const cardIsList = field.card.isList;
const isRef = field instanceof RefValue;
const fhirPathString = bracketNotation(fhirElementPath);
Expand All @@ -1702,9 +1704,14 @@ function generateFromFHIRAssignment(field, fhirElement, fhirElementPath, shrElem
cw.bl('if (referencedEntry)', () => {
const profileUrl = getTargetProfile(fhirElement, fhirProfile.fhirVersion);
const parts = profileUrl.split('/');
shrType = parts[parts.length - 1].replace(/-/g, '.');
shrType = parts[parts.length - 1];

const allFHIRProfiles = [...fhir.profiles, ...fhir._noDiffProfiles];
const matchingProfile = allFHIRProfiles.find(p => p.id === shrType);
const fhirType = matchingProfile ? `'${matchingProfile.type}'` : null;

cw.ln(`mappedResources[entryId] = FHIRHelper.createInstanceFromFHIR('${shrType}', referencedEntry['resource'], shrId, allEntries, mappedResources, referencesOut);`);
shrType = shrType.replace(/-/g, '.');
cw.ln(`mappedResources[entryId] = FHIRHelper.createInstanceFromFHIR('${shrType}', referencedEntry['resource'], ${fhirType}, shrId, allEntries, mappedResources, referencesOut);`);
});
});

Expand Down Expand Up @@ -1740,7 +1747,8 @@ function generateFromFHIRAssignment(field, fhirElement, fhirElementPath, shrElem
if (shrType.isPrimitive && !isExtension) {
rhs = fhirPathString;
} else {
rhs = `FHIRHelper.createInstanceFromFHIR('${shrType.fqn}', ${fhirPathString}, shrId, allEntries, mappedResources, referencesOut, ${isExtension})`;
const fhirType = fhirElement.type[0].code;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it safe to assume that the type at index 0 is the right type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Off the top of my head I'm not sure of all the reasons that a FHIR element might have multiple types, but if this was originally a choice type like value[x] then it's been pre-processed so that it appears to be a single choice with a single type, and that's the use case where this is most relevant.
Not conclusive proof, but running through the current spec there are no instances where there is a second type available to choose here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. Yes, if we've already handled [x] types before this then I think that should be fine.

rhs = `FHIRHelper.createInstanceFromFHIR('${shrType.fqn}', ${fhirPathString}, '${fhirType}', shrId, allEntries, mappedResources, referencesOut, ${isExtension})`;
}
if (isRef) {
rhs = `FHIRHelper.createReference( ${rhs}, referencesOut)`;
Expand Down
9 changes: 5 additions & 4 deletions lib/generateFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,16 @@ function generateFactory(namespaces) {
cw.blComment(() => {
cw.ln('Create an instance of a class from its FHIR representation.')
.ln('@param {Object} fhir - The element data in FHIR format (use `{}` and provide `type` for a blank instance)')
.ln(`@param {string} [type] - The (optional) type of the element (e.g., 'http://standardhealthrecord.org/spec/shr/demographics/PersonOfRecord'). This is only used if the type cannot be extracted from the JSON.`)
.ln(`@param {string} fhirType - the type of the FHIR object that was passed in, in case not otherwise identifiable from the object itself`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is fhirType optional? I noticed you pass in null for it in some of the cases above. So it seems it must be optional? The shrType below explicitly states that it is optional (in the description).

Also note that in jsdoc, you can indicate a parameter is optional by surrounding it in [ ]. See: https://jsdoc.app/tags-param.html#optional-parameters-and-default-values

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I see you did use the [ ] jsdoc syntax in the namespace factory... so I guess you knew. Maybe you missed it here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think I first learned I could do that between here and the namespace factory. For consistency I'll change the namespace factory comment to remove the brackets. Technically whether it's optional or not will depend on the individual class, since most classes won't use it but some will require it.

.ln(`@param {string} shrType - The (optional) type of the element (e.g., 'http://standardhealthrecord.org/spec/shr/demographics/PersonOfRecord'). This is only used if the type cannot be extracted from the JSON.`)
.ln('@returns {Object} An instance of the requested class populated with the provided data');
})
.bl('static createInstanceFromFHIR(fhir, type, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)', () => {
cw.ln('const { namespace } = getNamespaceAndNameFromFHIR(fhir, type);')
.bl('static createInstanceFromFHIR(fhir, fhirType, shrType, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)', () => {
cw.ln('const { namespace } = getNamespaceAndNameFromFHIR(fhir, shrType);')
.ln('switch (namespace) {');
for (const ns of namespaces) {
const factory = factoryName(ns.namespace);
cw.ln(`case '${ns.namespace}': return ${factory}.createInstanceFromFHIR(fhir, type, shrId, allEntries, mappedResources, referencesOut, asExtension);`);
cw.ln(`case '${ns.namespace}': return ${factory}.createInstanceFromFHIR(fhir, fhirType, shrType, shrId, allEntries, mappedResources, referencesOut, asExtension);`);
}
cw.ln(`case 'primitive': return fhir;`);
cw.ln(`default: throw new Error(\`Unsupported namespace: \${namespace}\`);`)
Expand Down
15 changes: 8 additions & 7 deletions lib/generateNamespaceFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,22 @@ function generateNamespaceFactory(ns, defs) {

cw.blComment(() => {
cw.ln('Convert an instance of a class from its FHIR representation.')
.ln('@param {Object} fhir - The element data in JSON format (use `{}` and provide `type` for a blank instance)')
.ln(`@param {string} [type] - The (optional) type of the element (e.g., 'http://standardhealthrecord.org/spec/shr/demographics/PersonOfRecord'). This is only used if the type cannot be extracted from the JSON.`)
.ln('@param {Object} fhir - The element data in JSON format (use `{}` and provide `shrType` for a blank instance)')
.ln(`@param {string} fhirType - the type of the FHIR object that was passed in, in case not otherwise identifiable from the object itself`)
.ln(`@param {string} [shrType] - The (optional) type of the element (e.g., 'http://standardhealthrecord.org/spec/shr/demographics/PersonOfRecord'). This is only used if the type cannot be extracted from the JSON.`)
.ln('@returns {Object} An instance of the requested class populated with the provided data');
})
.bl('static createInstanceFromFHIR(fhir, type, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)', () => {
cw.ln('const { namespace, elementName } = getNamespaceAndNameFromFHIR(fhir, type);')
.bl('static createInstanceFromFHIR(fhir, fhirType, shrType, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)', () => {
cw.ln('const { namespace, elementName } = getNamespaceAndNameFromFHIR(fhir, shrType);')
.bl(`if (namespace !== '${ns.namespace}')`, () => {
cw.ln(`throw new Error(\`Unsupported type in ${factory}: \${type}\`);`);
cw.ln(`throw new Error(\`Unsupported type in ${factory}: \${shrType}\`);`);
})
.ln('switch (elementName) {');
for (const def of defs) {
const elName = className(def.identifier.name);
cw.ln(`case '${elName}': return ${elName}.fromFHIR(fhir, shrId, allEntries, mappedResources, referencesOut, asExtension);`);
cw.ln(`case '${elName}': return ${elName}.fromFHIR(fhir, fhirType, shrId, allEntries, mappedResources, referencesOut, asExtension);`);
}
cw.ln(`default: throw new Error(\`Unsupported type in ${factory}: \${type}\`);`)
cw.ln(`default: throw new Error(\`Unsupported type in ${factory}: \${shrType}\`);`)
.ln('}');
});
});
Expand Down
14 changes: 7 additions & 7 deletions lib/includes/json-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,20 +194,20 @@ export const FHIRHelper = {

/**
* Creates an ES6 class instance based on a value extracted from the JSON.
* @param {string} key - the original key under which the value was stored. This is used as a backup in case the value
* does not declare its type.
* @param {object} value - the FHIR data to create an ES6 class instance for
* @param {string} shrType - the fqn of the class to be instantiated
* @param {object} fhir - the FHIR data to create an ES6 class instance for
* @param {string} fhirType - the type of the FHIR object passed in, just in case it's not otherwise available by inspecting the object
* @returns {object} an instance of an ES6 class representing the data
* @private
*/
createInstanceFromFHIR: function(key, value, shrId, allEntries=[], mappedResources={}, referencesOut=[], asExtension=false) {
if (Array.isArray(value)) {
return value.map(v => FHIRHelper.createInstanceFromFHIR(key, v, shrId, allEntries, mappedResources, referencesOut, asExtension));
createInstanceFromFHIR: function(shrType, fhir, fhirType, shrId, allEntries=[], mappedResources={}, referencesOut=[], asExtension=false) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code has three basic implementations of functions called createInstanceFromFHIR: this one, a high-level one generated on ObjectFactory, and one on each NamespaceFactory.

The latter two have signature:

function(fhir, fhirType, shrType, shrId=uuid(), allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)

But this one has signature:

function(shrType, fhir, fhirType, shrId, allEntries=[], mappedResources={}, referencesOut=[], asExtension=false)

Note that they are almost exactly the same except for the order of the first three arguments. Ideally, there should be consistency between all of them in order to avoid stupid and unintentional mistakes (probably to be made by me).

So... can we either change this signature to match the others or change the others to match this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the other locations to match this one, I think it's fewer code changes and in simpler places, plus the ordering feels more consistent with how createInstanceFromFHIR('ns.Class', fhir, ...) turns into Class.fromFHIR(fhir, ....) .
Also I updated the comments for shrType similarly to the above, originally they said "optional" but it can be required depending on other parameters.

if (Array.isArray(fhir)) {
return fhir.map(v => FHIRHelper.createInstanceFromFHIR(shrType, v, fhirType, shrId, allEntries, mappedResources, referencesOut, asExtension));
}
if (OBJECT_FACTORY == null) {
throw new Error(`SHR ES6 module is not initialized. Import 'init' before using the ES6 factories and classes`);
}
return OBJECT_FACTORY.createInstanceFromFHIR(value, key, shrId, allEntries, mappedResources, referencesOut, asExtension);
return OBJECT_FACTORY.createInstanceFromFHIR(fhir, fhirType, shrType, shrId, allEntries, mappedResources, referencesOut, asExtension);
},

/**
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lint:fix": "./node_modules/.bin/eslint . --fix"
},
"dependencies": {
"chai-spies": "^1.0.0",
"reserved-words": "^0.1.2"
},
"devDependencies": {
Expand Down
38 changes: 33 additions & 5 deletions test/es6FromFHIRJSONTest.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const { expect } = require('chai');
const chai = require('chai');
const expect = chai.expect;
const spies = require('chai-spies');
chai.use(spies);
const setup = require('./setup');
require('babel-register')({
presets: [ 'es2015' ]
Expand Down Expand Up @@ -370,7 +373,7 @@ describe('#FromFHIR_STU3', () => {
const allEntries = [json, memberA, memberB].map(j => {
return { fullUrl: `http://example.org/fhir/Observation/${j.id}`, resource: j };
});
const entry = PanelSliceByProfile.fromFHIR(json, '12345', allEntries);
const entry = PanelSliceByProfile.fromFHIR(json, 'Observation', '12345', allEntries);
expect(entry).instanceOf(PanelSliceByProfile);

const expected = new PanelSliceByProfile()
Expand Down Expand Up @@ -409,18 +412,19 @@ describe('#FromFHIR_STU3', () => {

describe('#Observation()', () => {

let Observation, Reference, ShrId, EntryId, EntryType;
let Observation, Reference, ShrId, EntryId, EntryType, DataValue;
before(() => {
Observation = context.importResult('shr/slicing/Observation');
Reference = context.importResult('Reference');
ShrId = context.importResult('shr/base/ShrId');
EntryId = context.importResult('shr/base/EntryId');
EntryType = context.importResult('shr/base/EntryType');
DataValue = context.importResult('shr/slicing/DataValue');
});

it('should deserialize a FHIR JSON instance', () => {
const json = context.getFHIR('Observation');
const entry = Observation.fromFHIR(json, '1-1');
const entry = Observation.fromFHIR(json, 'Observation', '1-1');
expect(entry).instanceOf(Observation);

const expected = new Observation()
Expand All @@ -435,6 +439,30 @@ describe('#FromFHIR_STU3', () => {

expect(entry).to.eql(expected);
});

it('should correctly pass the FHIR type to nested object fromFHIR', () => {

const spy = chai.spy.on(DataValue, 'fromFHIR');
const json1 = context.getFHIR('Observation_valueString');
try {
// TODO: fix the bug that causes this to throw
// this will error out because DataValue.fromFHIR doesn't know what to do with the given fhir (yet)
// but at this point all we care about is that it's passed the right parameter
Observation.fromFHIR(json1, 'Observation', '1-1');
} catch (e) {}

expect(spy).to.have.been.called.with('string');

const json2 = context.getFHIR('Observation_valueCodeableConcept');
try {
// TODO: fix the bug that causes this to throw
// this will error out because DataValue.fromFHIR doesn't know what to do with the given fhir (yet)
// but at this point all we care about is that it's passed the right parameter
Observation.fromFHIR(json2, 'Observation', '1-1');
} catch (e) {}

expect(spy).to.have.been.called.with('CodeableConcept');
});
});
});

Expand Down Expand Up @@ -462,7 +490,7 @@ describe('#FromFHIR_DSTU2', () => {

it('should deserialize a FHIR JSON instance', () => {
const json = context.getFHIR('Observation');
const entry = Observation.fromFHIR(json, '1-1');
const entry = Observation.fromFHIR(json, 'Observation', '1-1');
expect(entry).instanceOf(Observation);

const expected = new Observation()
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/fhir/Observation_valueCodeableConcept.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"resourceType": "Observation",
"id": "0000-0000",
"subject": { "reference": "abcd-1234" },
"valueCodeableConcept": {
"coding": [{
"system": "SNOMED-CT",
"code": "1234567890",
"display": "Dummy SNOMED Code"
}]
}
}
6 changes: 6 additions & 0 deletions test/fixtures/fhir/Observation_valueString.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"resourceType": "Observation",
"id": "0000-0000",
"subject": { "reference": "abcd-1234" },
"valueString": "something or other"
}
4 changes: 4 additions & 0 deletions test/fixtures/spec/shr_slicing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Value: CodeableConcept

EntryElement: Observation
0..1 ref(PatientEntry)
0..1 DataValue

Element: DataValue
Value: CodeableConcept or Quantity or string or time or dateTime

EntryElement: MemberA
Based on: Observation
Expand Down
Loading