5734 synchronization for new tags on notebook entries (#5763)

* trying this again

* wip

* wip

* wip

* one annotation per tag

* fixed too many events firing

* syncing works mostly

* syncing properly across existing annotations

* search with multiple tags

* resolve conflicts between different tag editors

* resolve conflicts

* fix annotation tests

* combine search results

* modify tests

* prevent infinite loop creating annotation

* add modified and deleted

* revert index checkin

* change to standard couch deleted flag

* revert throwing of error

* resolve conflict issues

* work in progress, but load annotations once from notebook

* works to add

* attempt 1

* wip

* last changes

* listening works, though still getting conflicts

* rename to annotationLastCreated

* use local mutable again

* works with new tags syncing

* listeners wont fire if modification is null

* clean up code

* fixed local search

* cleaned up log messages

* remove on more log

* add e2e test for network traffic

* lint

* change to use good old for each

* add some local variables for clarity

* Update src/api/objects/ObjectAPI.js

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* Update src/api/objects/ObjectAPI.js

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* Update src/plugins/notebook/components/Notebook.vue

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* press enter for last entry

* add test explanation of numbers

* fix spread typo

* add some nice jsdoc

* throw some errors

* use really small integer instead

* remove unneeded binding

* make method public and jsdoc it

* use mutables

* clean up tests

* clean up tests

* use aria labels for tests

* add some proper tsdoc to annotation api

* add undelete test

Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
This commit is contained in:
Scott Bell
2022-09-30 19:32:11 +02:00
committed by GitHub
parent 27c30132d2
commit ce463babff
22 changed files with 568 additions and 296 deletions

View File

@ -22,6 +22,7 @@
import { v4 as uuid } from 'uuid';
import EventEmitter from 'EventEmitter';
import _ from 'lodash';
/**
* @readonly
@ -42,19 +43,28 @@ const ANNOTATION_TYPES = Object.freeze({
const ANNOTATION_TYPE = 'annotation';
const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
/**
* @typedef {Object} Tag
* @property {String} key a unique identifier for the tag
* @property {String} backgroundColor eg. "#cc0000"
* @property {String} foregroundColor eg. "#ffffff"
*/
export default class AnnotationAPI extends EventEmitter {
/**
* @param {OpenMCT} openmct
*/
constructor(openmct) {
super();
this.openmct = openmct;
this.availableTags = {};
this.ANNOTATION_TYPES = ANNOTATION_TYPES;
this.ANNOTATION_TYPE = ANNOTATION_TYPE;
this.ANNOTATION_LAST_CREATED = ANNOTATION_LAST_CREATED;
this.openmct.types.addType(ANNOTATION_TYPE, {
name: 'Annotation',
@ -63,6 +73,7 @@ export default class AnnotationAPI extends EventEmitter {
cssClass: 'icon-notebook',
initialize: function (domainObject) {
domainObject.targets = domainObject.targets || {};
domainObject._deleted = domainObject._deleted || false;
domainObject.originalContextPath = domainObject.originalContextPath || '';
domainObject.tags = domainObject.tags || [];
domainObject.contentText = domainObject.contentText || '';
@ -112,6 +123,7 @@ export default class AnnotationAPI extends EventEmitter {
namespace
},
tags,
_deleted: false,
annotationType,
contentText,
originalContextPath
@ -127,6 +139,7 @@ export default class AnnotationAPI extends EventEmitter {
const success = await this.openmct.objects.save(createdObject);
if (success) {
this.emit('annotationCreated', createdObject);
this.#updateAnnotationModified(domainObject);
return createdObject;
} else {
@ -134,14 +147,32 @@ export default class AnnotationAPI extends EventEmitter {
}
}
#updateAnnotationModified(domainObject) {
this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now());
}
/**
* @method defineTag
* @param {String} key a unique identifier for the tag
* @param {Tag} tagsDefinition the definition of the tag to add
*/
defineTag(tagKey, tagsDefinition) {
this.availableTags[tagKey] = tagsDefinition;
}
/**
* @method isAnnotation
* @param {import('../objects/ObjectAPI').DomainObject} domainObject domainObject the domainObject in question
* @returns {Boolean} Returns true if the domain object is an annotation
*/
isAnnotation(domainObject) {
return domainObject && (domainObject.type === ANNOTATION_TYPE);
}
/**
* @method getAvailableTags
* @returns {Tag[]} Returns an array of the available tags that have been loaded
*/
getAvailableTags() {
if (this.availableTags) {
const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => {
@ -157,18 +188,26 @@ export default class AnnotationAPI extends EventEmitter {
}
}
async getAnnotation(query, searchType) {
let foundAnnotation = null;
/**
* @method getAnnotations
* @param {String} query - The keystring of the domain object to search for annotations for
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query
*/
async getAnnotations(query) {
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, searchType))).flat();
if (searchResults) {
foundAnnotation = searchResults[0];
}
return foundAnnotation;
return searchResults;
}
async addAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
/**
* @method addSingleAnnotationTag
* @param {import('../objects/ObjectAPI').DomainObject=} existingAnnotation - An optional annotation to add the tag to. If not specified, we will create an annotation.
* @param {import('../objects/ObjectAPI').DomainObject} targetDomainObject - The domain object the annotation will point to.
* @param {Object=} targetSpecificDetails - Optional object to add to the target object. E.g., for notebooks this would be an entryID
* @param {AnnotationType} annotationType - The type of annotation this is for.
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns the annotation that was either created or passed as an existingAnnotation
*/
async addSingleAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
if (!existingAnnotation) {
const targets = {};
const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier);
@ -186,27 +225,44 @@ export default class AnnotationAPI extends EventEmitter {
return newAnnotation;
} else {
const tagArray = [tag, ...existingAnnotation.tags];
this.openmct.objects.mutate(existingAnnotation, 'tags', tagArray);
if (!existingAnnotation.tags.includes(tag)) {
throw new Error(`Existing annotation did not contain tag ${tag}`);
}
if (existingAnnotation._deleted) {
this.unDeleteAnnotation(existingAnnotation);
}
return existingAnnotation;
}
}
removeAnnotationTag(existingAnnotation, tagToRemove) {
if (existingAnnotation && existingAnnotation.tags.includes(tagToRemove)) {
const cleanedArray = existingAnnotation.tags.filter(extantTag => extantTag !== tagToRemove);
this.openmct.objects.mutate(existingAnnotation, 'tags', cleanedArray);
} else {
throw new Error(`Asked to remove tag (${tagToRemove}) that doesn't exist`, existingAnnotation);
/**
* @method deleteAnnotations
* @param {import('../objects/ObjectAPI').DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
*/
deleteAnnotations(annotations) {
if (!annotations) {
throw new Error('Asked to delete null annotations! 🙅‍♂️');
}
annotations.forEach(annotation => {
if (!annotation._deleted) {
this.openmct.objects.mutate(annotation, '_deleted', true);
}
});
}
removeAnnotationTags(existingAnnotation) {
// just removes tags on the annotation as we can't really delete objects
if (existingAnnotation && existingAnnotation.tags) {
this.openmct.objects.mutate(existingAnnotation, 'tags', []);
/**
* @method deleteAnnotations
* @param {import('../objects/ObjectAPI').DomainObject} existingAnnotation - An annotations to undelete (set _deleted to false)
*/
unDeleteAnnotation(annotation) {
if (!annotation) {
throw new Error('Asked to undelete null annotation! 🙅‍♂️');
}
this.openmct.objects.mutate(annotation, '_deleted', false);
}
#getMatchingTags(query) {
@ -266,16 +322,36 @@ export default class AnnotationAPI extends EventEmitter {
return modelAddedToResults;
}
#combineSameTargets(results) {
const combinedResults = [];
results.forEach(currentAnnotation => {
const existingAnnotation = combinedResults.find((annotationToFind) => {
return _.isEqual(currentAnnotation.targets, annotationToFind.targets);
});
if (!existingAnnotation) {
combinedResults.push(currentAnnotation);
} else {
existingAnnotation.tags.push(...currentAnnotation.tags);
}
});
return combinedResults;
}
/**
* @method searchForTags
* @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving"
* @param {Object} abortController An optional abort method to stop the query
* @param {Object} [abortController] An optional abort method to stop the query
* @returns {Promise} returns a model of matching tags with their target domain objects attached
*/
async searchForTags(query, abortController) {
const matchingTagKeys = this.#getMatchingTags(query);
const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat();
const appliedTagSearchResults = this.#addTagMetaInformationToResults(searchResults, matchingTagKeys);
const filteredDeletedResults = searchResults.filter((result) => {
return !(result._deleted);
});
const combinedSameTargets = this.#combineSameTargets(filteredDeletedResults);
const appliedTagSearchResults = this.#addTagMetaInformationToResults(combinedSameTargets, matchingTagKeys);
const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults);
const resultsWithValidPath = appliedTargetsModels.filter(result => {
return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath);

View File

@ -126,34 +126,44 @@ describe("The Annotation API", () => {
describe("Tagging", () => {
it("can create a tag", async () => {
const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
});
it("can delete a tag", async () => {
const originalAnnotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
const annotationObject = await openmct.annotation.addAnnotationTag(originalAnnotationObject, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'anotherTagToRemove');
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
openmct.annotation.removeAnnotationTag(annotationObject, 'anotherTagToRemove');
expect(annotationObject.tags).toEqual(['aWonderfulTag']);
openmct.annotation.removeAnnotationTag(annotationObject, 'aWonderfulTag');
expect(annotationObject.tags).toEqual([]);
openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue();
});
it("throws an error if deleting non-existent tag", async () => {
const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(() => {
openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist');
}).toThrow();
});
it("can remove all tags", async () => {
const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(() => {
openmct.annotation.removeAnnotationTags(annotationObject);
openmct.annotation.deleteAnnotations([annotationObject]);
}).not.toThrow();
expect(annotationObject.tags).toEqual([]);
expect(annotationObject._deleted).toBeTrue();
});
it("can add/delete/add a tag", async () => {
let annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue();
annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
expect(annotationObject._deleted).toBeFalse();
});
});
@ -175,16 +185,5 @@ describe("The Annotation API", () => {
expect(results).toBeDefined();
expect(results.length).toEqual(1);
});
it("can get notebook annotations", async () => {
const targetKeyString = openmct.objects.makeKeyString(mockDomainObject.identifier);
const query = {
targetKeyString,
entryId: 'fooBarEntry'
};
const results = await openmct.annotation.getAnnotation(query, openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS);
expect(results).toBeDefined();
expect(results.tags.length).toEqual(2);
});
});
});

View File

@ -199,7 +199,7 @@ define([
if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) {
let keyString = this.publicAPI.objects.makeKeyString(child.identifier);
child = this.publicAPI.objects._toMutable(child);
child = this.publicAPI.objects.toMutable(child);
this.mutables[keyString] = child;
}

View File

@ -42,7 +42,6 @@ class InMemorySearchProvider {
this.openmct = openmct;
this.indexedIds = {};
this.indexedCompositions = {};
this.indexedTags = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
@ -61,7 +60,6 @@ class InMemorySearchProvider {
this.localSearchForObjects = this.localSearchForObjects.bind(this);
this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this);
this.localSearchForTags = this.localSearchForTags.bind(this);
this.localSearchForNotebookAnnotations = this.localSearchForNotebookAnnotations.bind(this);
this.onAnnotationCreation = this.onAnnotationCreation.bind(this);
this.onCompositionAdded = this.onCompositionAdded.bind(this);
this.onCompositionRemoved = this.onCompositionRemoved.bind(this);
@ -93,7 +91,7 @@ class InMemorySearchProvider {
this.searchTypes = this.openmct.objects.SEARCH_TYPES;
this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS];
this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS];
this.scheduleForIndexing(rootObject.identifier);
@ -163,8 +161,6 @@ class InMemorySearchProvider {
return this.localSearchForObjects(queryId, query, maxResults);
} else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.localSearchForAnnotations(queryId, query, maxResults);
} else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) {
return this.localSearchForNotebookAnnotations(queryId, query, maxResults);
} else if (searchType === this.searchTypes.TAGS) {
return this.localSearchForTags(queryId, query, maxResults);
} else {
@ -281,13 +277,6 @@ class InMemorySearchProvider {
provider.index(domainObject);
}
onTagMutation(domainObject, newTags) {
domainObject.tags = newTags;
const provider = this;
provider.index(domainObject);
}
onCompositionAdded(newDomainObjectToIndex) {
const provider = this;
// The object comes in as a mutable domain object, which has functions,
@ -342,14 +331,6 @@ class InMemorySearchProvider {
composition.on('remove', this.onCompositionRemoved);
this.indexedCompositions[keyString] = composition;
}
if (domainObject.type === 'annotation') {
this.indexedTags[keyString] = this.openmct.objects.observe(
domainObject,
'tags',
this.onTagMutation.bind(this, domainObject)
);
}
}
if ((keyString !== 'ROOT')) {
@ -581,43 +562,6 @@ class InMemorySearchProvider {
this.onWorkerMessage(eventToReturn);
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localSearchForNotebookAnnotations(queryId, {entryId, targetKeyString}, maxResults) {
// This results dictionary will have domain object ID keys which
// point to the value the domain object's score.
let results = [];
const message = {
request: 'searchForNotebookAnnotations',
results: [],
total: 0,
queryId
};
const matchingAnnotations = this.localIndexedAnnotationsByDomainObject[targetKeyString];
if (matchingAnnotations) {
results = matchingAnnotations.filter(matchingAnnotation => {
if (!matchingAnnotation.targets) {
return false;
}
const target = matchingAnnotation.targets[targetKeyString];
return (target && target.entryId && (target.entryId === entryId));
});
}
message.total = results.length;
message.results = results
.slice(0, maxResults);
const eventToReturn = {
data: message
};
this.onWorkerMessage(eventToReturn);
}
destroyObservers(observers) {
Object.entries(observers).forEach(([keyString, unobserve]) => {
if (typeof unobserve === 'function') {

View File

@ -43,8 +43,6 @@
port.postMessage(searchForAnnotations(event.data));
} else if (requestType === 'TAGS') {
port.postMessage(searchForTags(event.data));
} else if (requestType === 'NOTEBOOK_ANNOTATIONS') {
port.postMessage(searchForNotebookAnnotations(event.data));
} else {
throw new Error(`Unknown request ${event.data.request}`);
}
@ -204,33 +202,4 @@
return message;
}
function searchForNotebookAnnotations(data) {
let results = [];
const message = {
request: 'searchForNotebookAnnotations',
results: {},
total: 0,
queryId: data.queryId
};
const matchingAnnotations = indexedAnnotationsByDomainObject[data.input.targetKeyString];
if (matchingAnnotations) {
results = matchingAnnotations.filter(matchingAnnotation => {
if (!matchingAnnotation.targets) {
return false;
}
const target = matchingAnnotation.targets[data.input.targetKeyString];
return (target && target.entryId && (target.entryId === data.input.entryId));
});
}
message.total = results.length;
message.results = results
.slice(0, data.maxResults);
return message;
}
}());

View File

@ -64,6 +64,15 @@ import InMemorySearchProvider from './InMemorySearchProvider';
* to load domain objects
* @memberof module:openmct
*/
/**
* @readonly
* @enum {String} SEARCH_TYPES
* @property {String} OBJECTS Search for objects
* @property {String} ANNOTATIONS Search for annotations
* @property {String} TAGS Search for tags
*/
/**
* Utilities for loading, saving, and manipulating domain objects.
* @interface ObjectAPI
@ -76,7 +85,6 @@ export default class ObjectAPI {
this.SEARCH_TYPES = Object.freeze({
OBJECTS: 'OBJECTS',
ANNOTATIONS: 'ANNOTATIONS',
NOTEBOOK_ANNOTATIONS: 'NOTEBOOK_ANNOTATIONS',
TAGS: 'TAGS'
});
this.eventEmitter = new EventEmitter();
@ -188,7 +196,6 @@ export default class ObjectAPI {
* @returns {Promise} a promise which will resolve when the domain object
* has been saved, or be rejected if it cannot be saved
*/
get(identifier, abortSignal) {
let keystring = this.makeKeyString(identifier);
@ -223,7 +230,7 @@ export default class ObjectAPI {
if (result.isMutable) {
result.$refresh(result);
} else {
let mutableDomainObject = this._toMutable(result);
let mutableDomainObject = this.toMutable(result);
mutableDomainObject.$refresh(result);
}
@ -300,7 +307,7 @@ export default class ObjectAPI {
}
return this.get(identifier).then((object) => {
return this._toMutable(object);
return this.toMutable(object);
});
}
@ -490,7 +497,7 @@ export default class ObjectAPI {
} else {
//Creating a temporary mutable domain object allows other mutable instances of the
//object to be kept in sync.
let mutableDomainObject = this._toMutable(domainObject);
let mutableDomainObject = this.toMutable(domainObject);
//Mutate original object
MutableDomainObject.mutateObject(domainObject, path, value);
@ -510,15 +517,19 @@ export default class ObjectAPI {
}
/**
* @private
* Create a mutable domain object from an existing domain object
* @param {module:openmct.DomainObject} domainObject the object to make mutable
* @returns {MutableDomainObject} a mutable domain object that will automatically sync
* @method toMutable
* @memberof module:openmct.ObjectAPI#
*/
_toMutable(object) {
toMutable(domainObject) {
let mutableObject;
if (object.isMutable) {
mutableObject = object;
if (domainObject.isMutable) {
mutableObject = domainObject;
} else {
mutableObject = MutableDomainObject.createMutable(object, this.eventEmitter);
mutableObject = MutableDomainObject.createMutable(domainObject, this.eventEmitter);
// Check if provider supports realtime updates
let identifier = utils.parseKeyString(mutableObject.identifier);
@ -526,9 +537,11 @@ export default class ObjectAPI {
if (provider !== undefined
&& provider.observe !== undefined
&& this.SYNCHRONIZED_OBJECT_TYPES.includes(object.type)) {
&& this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) {
let unobserve = provider.observe(identifier, (updatedModel) => {
if (updatedModel.persisted > mutableObject.modified) {
// modified can sometimes be undefined, so make it 0 in this case
const mutableObjectModification = mutableObject.modified ?? Number.MIN_SAFE_INTEGER;
if (updatedModel.persisted > mutableObjectModification) {
//Don't replace with a stale model. This can happen on slow connections when multiple mutations happen
//in rapid succession and intermediate persistence states are returned by the observe function.
updatedModel = this.applyGetInterceptors(identifier, updatedModel);
@ -582,7 +595,7 @@ export default class ObjectAPI {
if (domainObject.isMutable) {
return domainObject.$observe(path, callback);
} else {
let mutable = this._toMutable(domainObject);
let mutable = this.toMutable(domainObject);
mutable.$observe(path, callback);
return () => mutable.$destroy();
@ -675,8 +688,10 @@ export default class ObjectAPI {
}
#hasAlreadyBeenPersisted(domainObject) {
// modified can sometimes be undefined, so make it 0 in this case
const modified = domainObject.modified ?? Number.MIN_SAFE_INTEGER;
const result = domainObject.persisted !== undefined
&& domainObject.persisted >= domainObject.modified;
&& domainObject.persisted >= modified;
return result;
}

View File

@ -320,7 +320,7 @@ describe("The Object API", () => {
beforeEach(function () {
// Duplicate object to guarantee we are not sharing object instance, which would invalidate test
testObjectDuplicate = JSON.parse(JSON.stringify(testObject));
mutableSecondInstance = objectAPI._toMutable(testObjectDuplicate);
mutableSecondInstance = objectAPI.toMutable(testObjectDuplicate);
});
afterEach(() => {