www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

integrationTest.js (29470B)


      1 "use strict";
      2 
      3 describe("Zotero.Integration", function () {
      4 	Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
      5 	const INTEGRATION_TYPE_ITEM = 1;
      6 	const INTEGRATION_TYPE_BIBLIOGRAPHY = 2;
      7 	const INTEGRATION_TYPE_TEMP = 3;
      8 	/**
      9 	 * To be used as a reference for Zotero-Word Integration plugins
     10 	 * 
     11 	 * NOTE: Functions must return promises instead of values!
     12 	 * The functions defined for the dummy are promisified below
     13 	 */
     14 	var DocumentPluginDummy = {};
     15 
     16 	/**
     17 	 * The Application class corresponds to a word processing application.
     18 	 */
     19 	DocumentPluginDummy.Application = function() { 
     20 		this.doc = new DocumentPluginDummy.Document();
     21 		this.primaryFieldType = "Field";
     22 		this.secondaryFieldType = "Bookmark";
     23 		this.supportedNotes = ['footnotes', 'endnotes'];
     24 		this.fields = [];
     25 	};
     26 	DocumentPluginDummy.Application.prototype = {
     27 		/**
     28 		 * Gets the active document.
     29 		 * @returns {DocumentPluginDummy.Document}
     30 		 */
     31 		getActiveDocument: function() {return this.doc},
     32 		/**
     33 		 * Gets the document by some app-specific identifier.
     34 		 * @param {String|Number} docID
     35 		 */
     36 		getDocument: function(docID) {return this.doc},
     37 		QueryInterface: function() {return this},
     38 	};
     39 
     40 	/**
     41 	 * The Document class corresponds to a single word processing document.
     42 	 */
     43 	DocumentPluginDummy.Document = function() {this.fields = []};
     44 	DocumentPluginDummy.Document.prototype = {
     45 		/**
     46 		 * Displays a dialog in the word processing application
     47 		 * @param {String} dialogText
     48 		 * @param {Number} icon - one of the constants defined in integration.js for dialog icons
     49 		 * @param {Number} buttons - one of the constants defined in integration.js for dialog buttons
     50 		 * @returns {Number}
     51 		 *		 - Yes: 2, No: 1, Cancel: 0
     52 		 *		 - Yes: 1, No: 0
     53 		 *		 - Ok: 1, Cancel: 0
     54 		 *		 - Ok: 0
     55 		 */
     56 		displayAlert: (dialogText, icon, buttons) => 0,
     57 		/**
     58 		 * Brings this document to the foreground (if necessary to return after displaying a dialog)
     59 		 */
     60 		activate: () => 0,
     61 		/**
     62 		 * Determines whether a field can be inserted at the current position.
     63 		 * @param {String} fieldType
     64 		 * @returns {Boolean}
     65 		 */
     66 		canInsertField: (fieldType) => true,
     67 		/**
     68 		 * Returns the field in which the cursor resides, or NULL if none.
     69 		 * @param {String} fieldType
     70 		 * @returns {Boolean}
     71 		 */
     72 		cursorInField: (fieldType) => false,
     73 		/**
     74 		 * Get document data property from the current document
     75 		 * @returns {String}
     76 		 */
     77 		getDocumentData: function() {return this.data},
     78 		/**
     79 		 * Set document data property
     80 		 * @param {String} data
     81 		 */
     82 		setDocumentData: function(data) {this.data = data},
     83 		/**
     84 		 * Inserts a field at the given position and initializes the field object.
     85 		 * @param {String} fieldType
     86 		 * @param {Integer} noteType
     87 		 * @returns {DocumentPluginDummy.Field}
     88 		 */
     89 		insertField: function(fieldType, noteType) { 
     90 			if (typeof noteType != "number") {
     91 				throw new Error("noteType must be an integer");
     92 			}
     93 			var field = new DocumentPluginDummy.Field(this); 
     94 			this.fields.push(field);
     95 			return field;
     96 		},
     97 		/**
     98 		 * Gets all fields present in the document.
     99 		 * @param {String} fieldType
    100 		 * @returns {DocumentPluginDummy.Field[]}
    101 		 */
    102 		getFields: function(fieldType) {return Array.from(this.fields)},
    103 		/**
    104 		 * Sets the bibliography style, overwriting the current values for this document
    105 		 */
    106 		setBibliographyStyle: (firstLineIndent, bodyIndent, lineSpacing, entrySpacing,
    107 			tabStops, tabStopsCount) => 0,
    108 		/**
    109 		 * Converts all fields in a document to a different fieldType or noteType
    110 		 * @params {DocumentPluginDummy.Field[]} fields
    111 		 */
    112 		convert: (fields, toFieldType, toNoteType, count) => 0,
    113 		/**
    114 		 * Cleans up the document state and resumes processor for editing
    115 		 */
    116 		cleanup: () => 0,
    117 
    118 		/**
    119 		 * Informs the document processor that the operation is complete
    120 		 */
    121 		complete: () => 0,
    122 	};
    123 
    124 	/**
    125 	 * The Field class corresponds to a field containing an individual citation
    126 	 * or bibliography
    127 	 */
    128 	DocumentPluginDummy.Field = function(doc) {
    129 		this.doc = doc;
    130 		this.code = '';
    131 		// This is actually required and current integration code depends on text being non-empty upon insertion.
    132 		// insertBibliography will fail if there is no placeholder text.
    133 		this.text = '{Placeholder}';
    134 		this.wrappedJSObject = this;
    135 	};
    136 	DocumentPluginDummy.Field.noteIndex = 0;
    137 	DocumentPluginDummy.Field.prototype = {
    138 		/**
    139 		 * Deletes this field and its contents.
    140 		 */
    141 		delete: function() {this.doc.fields.filter((field) => field !== this)},
    142 		/**
    143 		 * Removes this field, but maintains the field's contents.
    144 		 */
    145 		removeCode: function() {this.code = ""},
    146 		/**
    147 		 * Selects this field.
    148 		 */
    149 		select: () => 0,
    150 		/**
    151 		 * Sets the text inside this field to a specified plain text string or pseudo-RTF formatted text
    152 		 * string.
    153 		 * @param {String} text
    154 		 * @param {Boolean} isRich
    155 		 */
    156 		setText: function(text, isRich) {this.text = text},
    157 		/**
    158 		 * Gets the text inside this field, preferably with formatting, but potentially without
    159 		 * @returns {String}
    160 		 */
    161 		getText: function() {return this.text},
    162 		/**
    163 		 * Sets field's code
    164 		 * @param {String} code
    165 		 */
    166 		setCode: function(code) {this.code = code},
    167 		/**
    168 		 * Gets field's code.
    169 		 * @returns {String}
    170 		 */
    171 		getCode: function() {return this.code},
    172 		/**
    173 		 * Returns true if this field and the passed field are actually references to the same field.
    174 		 * @param {DocumentPluginDummy.Field} field
    175 		 * @returns {Boolean}
    176 		 */
    177 		equals: function(field) {return this == field},
    178 		/**
    179 		 * This field's note index, if it is in a footnote or endnote; otherwise zero.
    180 		 * @returns {Number}
    181 		 */
    182 		getNoteIndex: () => 0,
    183 	};
    184 
    185 	// Processing functions for logging and promisification
    186 	for (let cls of ['Application', 'Document', 'Field']) {
    187 		for (let methodName in DocumentPluginDummy[cls].prototype) {
    188 			if (methodName !== 'QueryInterface') {
    189 				let method = DocumentPluginDummy[cls].prototype[methodName];
    190 				DocumentPluginDummy[cls].prototype[methodName] = async function() {
    191 					try {
    192 						Zotero.debug(`DocumentPluginDummy: ${cls}.${methodName} invoked with args ${JSON.stringify(arguments)}`, 2);
    193 					} catch (e) {
    194 						Zotero.debug(`DocumentPluginDummy: ${cls}.${methodName} invoked with args ${arguments}`, 2);
    195 					}
    196 					var result = method.apply(this, arguments);
    197 					try {
    198 						Zotero.debug(`Result: ${JSON.stringify(result)}`, 2);
    199 					} catch (e) {
    200 						Zotero.debug(`Result: ${result}`, 2);
    201 					}
    202 					return result;
    203 				}
    204 			}
    205 		}
    206 	}
    207 	
    208 	var testItems;
    209 	var applications = {};
    210 	var addEditCitationSpy, displayDialogStub;
    211 	var styleID = "http://www.zotero.org/styles/cell";
    212 	var stylePath = OS.Path.join(getTestDataDirectory().path, 'cell.csl');
    213 
    214 	var commandList = [
    215 		'addCitation', 'editCitation', 'addEditCitation',
    216 		'addBibliography', 'editBibliography', 'addEditBibliography',
    217 		'refresh', 'removeCodes', 'setDocPrefs'
    218 	];
    219 	
    220 	function execCommand(command, docID) {
    221 		if (! commandList.includes(command)) {
    222 			throw new Error(`${command} is not a valid document command`);
    223 		}
    224 		if (typeof docID === "undefined") {
    225 			throw new Error(`docID cannot be undefined`)
    226 		}
    227 		Zotero.debug(`execCommand '${command}': ${docID}`, 2);
    228 		return Zotero.Integration.execCommand("dummy", command, docID);
    229 	}
    230 	
    231 	var dialogResults = {
    232 		addCitationDialog: {},
    233 		quickFormat: {},
    234 		integrationDocPrefs: {},
    235 		selectItemsDialog: {},
    236 		editBibliographyDialog: {}
    237 	};
    238 	
    239 	async function initDoc(docID, options={}) {
    240 		applications[docID] = new DocumentPluginDummy.Application();
    241 		var data = new Zotero.Integration.DocumentData();
    242 		data.prefs = {
    243 			noteType: 0,
    244 			fieldType: "Field",
    245 			automaticJournalAbbreviations: true
    246 		};
    247 		data.style = {styleID, locale: 'en-US', hasBibliography: true, bibliographyStyleHasBeenSet: true};
    248 		data.sessionID = Zotero.Utilities.randomString(10);
    249 		Object.assign(data, options);
    250 		await (await applications[docID].getDocument(docID)).setDocumentData(data.serialize());
    251 	}
    252 	
    253 	function setDefaultIntegrationDocPrefs() {
    254 		dialogResults.integrationDocPrefs = {
    255 			style: "http://www.zotero.org/styles/cell",
    256 			locale: 'en-US',
    257 			fieldType: 'Field',
    258 			automaticJournalAbbreviations: false,
    259 			useEndnotes: 0
    260 		};
    261 	}
    262 	setDefaultIntegrationDocPrefs();
    263 	
    264 	function setAddEditItems(items) {
    265 		if (items.length == undefined) items = [items];
    266 		dialogResults.quickFormat = async function(dialogName, io) {
    267 			io.citation.citationItems = items.map(function(item) {
    268 				item = Zotero.Cite.getItem(item.id);
    269 				return {id: item.id, uris: item.cslURIs, itemData: item.cslItemData};
    270 			});
    271 			await io.previewFn(io.citation);
    272 			io._acceptDeferred.resolve(() => {});
    273 		};
    274 	}
    275 	
    276 	before(function* () {
    277 		yield Zotero.Styles.init();
    278 		yield Zotero.Styles.install({file: stylePath}, styleID, true);
    279 
    280 		testItems = [];
    281 		for (let i = 0; i < 5; i++) {
    282 			let testItem = yield createDataObject('item', {libraryID: Zotero.Libraries.userLibraryID});
    283 			testItem.setField('title', `title${1}`);
    284 			testItem.setCreator(0, {creatorType: 'author', name: `Author No${i}`});
    285 			testItems.push(testItem);
    286 		}
    287 		setAddEditItems(testItems[0]);
    288 		
    289 		sinon.stub(Zotero.Integration, 'getApplication').callsFake(function(agent, command, docID) {
    290 			if (!applications[docID]) {
    291 				applications[docID] = new DocumentPluginDummy.Application();
    292 			}
    293 			return applications[docID];
    294 		});
    295 		
    296 		displayDialogStub = sinon.stub(Zotero.Integration, 'displayDialog');
    297 		displayDialogStub.callsFake(async function(dialogName, prefs, io) {
    298 			Zotero.debug(`Display dialog: ${dialogName}`, 2);
    299 			var ioResult = dialogResults[dialogName.substring(dialogName.lastIndexOf('/')+1, dialogName.length-4)];
    300 			if (typeof ioResult == 'function') {
    301 				await ioResult(dialogName, io);
    302 			} else {
    303 				Object.assign(io, ioResult);
    304 			}
    305 		});
    306 		
    307 		addEditCitationSpy = sinon.spy(Zotero.Integration.Interface.prototype, 'addEditCitation');
    308 		
    309 		sinon.stub(Zotero.Integration.Progress.prototype, 'show');
    310 	});
    311 	
    312 	after(function() {
    313 		Zotero.Integration.Progress.prototype.show.restore();
    314 		Zotero.Integration.getApplication.restore();
    315 		displayDialogStub.restore();
    316 		addEditCitationSpy.restore();
    317 	});
    318 	
    319 	describe('Interface', function() {
    320 		describe('#execCommand', function() {
    321 			var setDocumentDataSpy;
    322 			var docID = this.fullTitle();
    323 			
    324 			before(function() {
    325 				setDocumentDataSpy = sinon.spy(DocumentPluginDummy.Document.prototype, 'setDocumentData');
    326 			});
    327 			
    328 			afterEach(function() {
    329 				setDocumentDataSpy.reset();
    330 			});
    331 			
    332 			after(function() {
    333 				setDocumentDataSpy.restore();
    334 			});
    335 			
    336 			it('should call doc.setDocumentData once', function* () {
    337 				yield execCommand('addEditCitation', docID);
    338 				assert.isTrue(setDocumentDataSpy.calledOnce);
    339 			});
    340 			
    341 			describe('when style used in the document does not exist', function() {
    342 				var docID = this.fullTitle();
    343 				var displayAlertStub;
    344 				var style;
    345 				before(function* () {
    346 					displayAlertStub = sinon.stub(DocumentPluginDummy.Document.prototype, 'displayAlert').resolves(0);
    347 				});
    348 				
    349 				beforeEach(async function () {
    350 					// 🦉birds?
    351 					style = {styleID: "http://www.example.com/csl/waterbirds", locale: 'en-US'};
    352 					
    353 					// Make sure style not in library
    354 					try {
    355 						Zotero.Styles.get(style.styleID).remove();
    356 					} catch (e) {}
    357 					await initDoc(docID, {style});
    358 					displayDialogStub.resetHistory();
    359 					displayAlertStub.reset();
    360 				});
    361 				
    362 				after(function* () {
    363 					displayAlertStub.restore();
    364 				});
    365 			
    366 				describe('when the style is not from a trusted source', function() {
    367 					it('should download the style and if user clicks YES', function* () {
    368 						var styleInstallStub = sinon.stub(Zotero.Styles, "install").resolves();
    369 						var style = Zotero.Styles.get(styleID);
    370 						var styleGetCalledOnce = false;
    371 						var styleGetStub = sinon.stub(Zotero.Styles, 'get').callsFake(function() {
    372 							if (!styleGetCalledOnce) {
    373 								styleGetCalledOnce = true;
    374 								return false;
    375 							}
    376 							return style;
    377 						});
    378 						displayAlertStub.resolves(1);
    379 						yield execCommand('addEditCitation', docID);
    380 						assert.isTrue(displayAlertStub.calledOnce);
    381 						assert.isFalse(displayDialogStub.calledWith(applications[docID].doc, 'chrome://zotero/content/integration/integrationDocPrefs.xul'));
    382 						assert.isTrue(styleInstallStub.calledOnce);
    383 						assert.isOk(Zotero.Styles.get(style.styleID));
    384 						styleInstallStub.restore();
    385 						styleGetStub.restore();
    386 					});
    387 					
    388 					it('should prompt with the document preferences dialog if user clicks NO', function* () {
    389 						displayAlertStub.resolves(0);
    390 						yield execCommand('addEditCitation', docID);
    391 						assert.isTrue(displayAlertStub.calledOnce);
    392 						// Prefs to select a new style and quickFormat
    393 						assert.isTrue(displayDialogStub.calledTwice);
    394 						assert.isNotOk(Zotero.Styles.get(style.styleID));
    395 					});	
    396 				});
    397 					
    398 				it('should download the style without prompting if it is from zotero.org', function* (){
    399 					yield initDoc(docID, {styleID: "http://www.zotero.org/styles/waterbirds", locale: 'en-US'});
    400 					var styleInstallStub = sinon.stub(Zotero.Styles, "install").resolves();
    401 					var style = Zotero.Styles.get(styleID);
    402 					var styleGetCalledOnce = false;
    403 					var styleGetStub = sinon.stub(Zotero.Styles, 'get').callsFake(function() {
    404 						if (!styleGetCalledOnce) {
    405 							styleGetCalledOnce = true;
    406 							return false;
    407 						}
    408 						return style;
    409 					});
    410 					displayAlertStub.resolves(1);
    411 					yield execCommand('addEditCitation', docID);
    412 					assert.isFalse(displayAlertStub.called);
    413 					assert.isFalse(displayDialogStub.calledWith(applications[docID].doc, 'chrome://zotero/content/integration/integrationDocPrefs.xul'));
    414 					assert.isTrue(styleInstallStub.calledOnce);
    415 					assert.isOk(Zotero.Styles.get(style.styleID));
    416 					styleInstallStub.restore();
    417 					styleGetStub.restore();	
    418 				});
    419 			});
    420 		});
    421 		
    422 		describe('#addEditCitation', function() {
    423 			var insertMultipleCitations = Zotero.Promise.coroutine(function *() {
    424 				var docID = this.test.fullTitle();
    425 				if (!(docID in applications)) yield initDoc(docID);
    426 				var doc = applications[docID].doc;
    427 
    428 				setAddEditItems(testItems[0]);
    429 				yield execCommand('addEditCitation', docID);
    430 				assert.equal(doc.fields.length, 1);
    431 				var citation = yield (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
    432 				assert.equal(citation.citationItems.length, 1);
    433 				assert.equal(citation.citationItems[0].id, testItems[0].id);
    434 
    435 				setAddEditItems(testItems.slice(1, 3));
    436 				yield execCommand('addEditCitation', docID);
    437 				assert.equal(doc.fields.length, 2);
    438 				citation = yield (new Zotero.Integration.CitationField(doc.fields[1], doc.fields[1].code)).unserialize();
    439 				assert.equal(citation.citationItems.length, 2);
    440 				for (let i = 1; i < 3; i++) {
    441 					assert.equal(citation.citationItems[i-1].id, testItems[i].id);
    442 				}
    443 			});
    444 			it('should insert citation if not in field', insertMultipleCitations);
    445 			
    446 			it('should edit citation if in citation field', function* () {
    447 				yield insertMultipleCitations.call(this);
    448 				var docID = this.test.fullTitle();
    449 				var doc = applications[docID].doc;
    450 
    451 				sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
    452 				sinon.stub(doc, 'canInsertField').resolves(false);
    453 
    454 				setAddEditItems(testItems.slice(3, 5));
    455 				yield execCommand('addEditCitation', docID);
    456 				assert.equal(doc.fields.length, 2);
    457 				var citation = yield (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
    458 				assert.equal(citation.citationItems.length, 2);
    459 				assert.equal(citation.citationItems[0].id, testItems[3].id);
    460 			});
    461 			
    462 			it('should update bibliography if present', function* () {
    463 				yield insertMultipleCitations.call(this);
    464 				var docID = this.test.fullTitle();
    465 				var doc = applications[docID].doc;
    466 				
    467 				let getCiteprocBibliographySpy =
    468 					sinon.spy(Zotero.Integration.Bibliography.prototype, 'getCiteprocBibliography');
    469 					
    470 				yield execCommand('addEditBibliography', docID);
    471 				assert.isTrue(getCiteprocBibliographySpy.calledOnce);
    472 				
    473 				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[0].entry_ids.length, 3);
    474 				getCiteprocBibliographySpy.reset();
    475 
    476 				setAddEditItems(testItems[3]);
    477 				yield execCommand('addEditCitation', docID);
    478 				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[0].entry_ids.length, 4);
    479 
    480 				getCiteprocBibliographySpy.restore();
    481 			});
    482 			
    483 			describe('when original citation text has been modified', function() {
    484 				var displayAlertStub;
    485 				before(function* () {
    486 					displayAlertStub = sinon.stub(DocumentPluginDummy.Document.prototype, 'displayAlert').resolves(0);
    487 				});	
    488 				beforeEach(function() {
    489 					displayAlertStub.reset();
    490 				});
    491 				after(function() {
    492 					displayAlertStub.restore();
    493 				});
    494 				it('should keep modification if "Cancel" selected in editCitation triggered alert', async function () {
    495 					await insertMultipleCitations.call(this);
    496 					var docID = this.test.fullTitle();
    497 					var doc = applications[docID].doc;
    498 
    499 					doc.fields[0].text = "modified";
    500 					sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
    501 					sinon.stub(doc, 'canInsertField').resolves(false);
    502 
    503 					await execCommand('addEditCitation', docID);
    504 					assert.equal(doc.fields.length, 2);
    505 					assert.equal(doc.fields[0].text, "modified");
    506 				});
    507 				it('should display citation dialog if "OK" selected in editCitation triggered alert', async function () {
    508 					await insertMultipleCitations.call(this);
    509 					var docID = this.test.fullTitle();
    510 					var doc = applications[docID].doc;
    511 
    512 					let origText = doc.fields[0].text;
    513 					doc.fields[0].text = "modified";
    514 					// Return OK
    515 					displayAlertStub.resolves(1);
    516 					sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
    517 					sinon.stub(doc, 'canInsertField').resolves(false);
    518 					setAddEditItems(testItems[0]);
    519 
    520 					await execCommand('addEditCitation', docID);
    521 					assert.isTrue(displayAlertStub.called);
    522 					assert.equal(doc.fields.length, 2);
    523 					assert.equal(doc.fields[0].text, origText);
    524 				});
    525 				it('should set dontUpdate: true if "yes" selected in refresh prompt', async function() {
    526 					await insertMultipleCitations.call(this);
    527 					var docID = this.test.fullTitle();
    528 					var doc = applications[docID].doc;
    529 
    530 					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
    531 					assert.isNotOk(citation.properties.dontUpdate);
    532 					doc.fields[0].text = "modified";
    533 					// Return Yes
    534 					displayAlertStub.resolves(1);
    535 
    536 					await execCommand('refresh', docID);
    537 					assert.isTrue(displayAlertStub.called);
    538 					assert.equal(doc.fields.length, 2);
    539 					assert.equal(doc.fields[0].text, "modified");
    540 					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
    541 					assert.isOk(citation.properties.dontUpdate);
    542 				});
    543 				it('should reset citation text if "no" selected in refresh prompt', async function() {
    544 					await insertMultipleCitations.call(this);
    545 					var docID = this.test.fullTitle();
    546 					var doc = applications[docID].doc;
    547 
    548 					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
    549 					assert.isNotOk(citation.properties.dontUpdate);
    550 					let origText = doc.fields[0].text;
    551 					doc.fields[0].text = "modified";
    552 					// Return No
    553 					displayAlertStub.resolves(0);
    554 
    555 					await execCommand('refresh', docID);
    556 					assert.isTrue(displayAlertStub.called);
    557 					assert.equal(doc.fields.length, 2);
    558 					assert.equal(doc.fields[0].text, origText);
    559 					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
    560 					assert.isNotOk(citation.properties.dontUpdate);
    561 				});
    562 			});
    563 			
    564 			describe('when there are copy-pasted citations', function() {
    565 				it('should resolve duplicate citationIDs and mark both as new citations', async function() {
    566 					var docID = this.test.fullTitle();
    567 					if (!(docID in applications)) initDoc(docID);
    568 					var doc = applications[docID].doc;
    569 
    570 					setAddEditItems(testItems[0]);
    571 					await execCommand('addEditCitation', docID);
    572 					assert.equal(doc.fields.length, 1);
    573 					// Add a duplicate
    574 					doc.fields.push(new DocumentPluginDummy.Field(doc));
    575 					doc.fields[1].code = doc.fields[0].code;
    576 					doc.fields[1].text = doc.fields[0].text;
    577 					
    578 					var originalUpdateDocument = Zotero.Integration.Fields.prototype.updateDocument;
    579 					var stubUpdateDocument = sinon.stub(Zotero.Integration.Fields.prototype, 'updateDocument');
    580 					try {
    581 						var indicesLength;
    582 						stubUpdateDocument.callsFake(function() {
    583 							indicesLength = Object.keys(Zotero.Integration.currentSession.newIndices).length;
    584 							return originalUpdateDocument.apply(this, arguments);
    585 						});
    586 
    587 						setAddEditItems(testItems[1]);
    588 						await execCommand('addEditCitation', docID);
    589 						assert.equal(indicesLength, 3);
    590 					} finally {
    591 						stubUpdateDocument.restore();
    592 					}
    593 				});
    594 				
    595 				it('should successfully process citations copied in from another doc', async function() {
    596 					var docID = this.test.fullTitle();
    597 					if (!(docID in applications)) initDoc(docID);
    598 					var doc = applications[docID].doc;
    599 
    600 					setAddEditItems(testItems[0]);
    601 					await execCommand('addEditCitation', docID);
    602 					assert.equal(doc.fields.length, 1);
    603 					doc.fields.push(new DocumentPluginDummy.Field(doc));
    604 					// Add a "citation copied from somewhere else"
    605 					// the content doesn't really matter, just make sure that the citationID is different
    606 					var newCitationID = Zotero.Utilities.randomString();
    607 					doc.fields[1].code = doc.fields[0].code;
    608 					doc.fields[1].code = doc.fields[1].code.replace(/"citationID":"[A-Za-z0-9^"]*"/,
    609 						`"citationID":"${newCitationID}"`);
    610 					doc.fields[1].text = doc.fields[0].text;
    611 					
    612 					var originalUpdateDocument = Zotero.Integration.Fields.prototype.updateDocument;
    613 					var stubUpdateDocument = sinon.stub(Zotero.Integration.Fields.prototype, 'updateDocument');
    614 					try {
    615 						var indices;
    616 						stubUpdateDocument.callsFake(function() {
    617 							indices = Object.keys(Zotero.Integration.currentSession.newIndices);
    618 							return originalUpdateDocument.apply(this, arguments);
    619 						});
    620 
    621 						setAddEditItems(testItems[1]);
    622 						await execCommand('addEditCitation', docID);
    623 						assert.equal(indices.length, 2);
    624 						assert.include(indices, '1');
    625 						assert.include(indices, '2');
    626 					} finally {
    627 						stubUpdateDocument.restore();
    628 					}
    629 				});
    630 			});
    631 			
    632 			describe('when delayCitationUpdates is set', function() {
    633 				it('should insert a citation with wave underlining', function* (){
    634 					yield insertMultipleCitations.call(this);
    635 					var docID = this.test.fullTitle();
    636 					var doc = applications[docID].doc;
    637 					var data = new Zotero.Integration.DocumentData(doc.data);
    638 					data.prefs.delayCitationUpdates = true;
    639 					doc.data = data.serialize();
    640 					
    641 					var setTextSpy = sinon.spy(DocumentPluginDummy.Field.prototype, 'setText');
    642 					setAddEditItems(testItems[3]);
    643 					yield execCommand('addEditCitation', docID);
    644 					assert.isTrue(setTextSpy.lastCall.args[0].includes('\\uldash'));
    645 					
    646 					setTextSpy.restore();
    647 				});
    648 				
    649 				it('should not write to any other fields besides the one being updated', function* () {
    650 					yield insertMultipleCitations.call(this);
    651 					var docID = this.test.fullTitle();
    652 					var doc = applications[docID].doc;
    653 					var data = new Zotero.Integration.DocumentData(doc.data);
    654 					data.prefs.delayCitationUpdates = true;
    655 					doc.data = data.serialize();
    656 
    657 					var setTextSpy = sinon.spy(DocumentPluginDummy.Field.prototype, 'setText');
    658 					var setCodeSpy = sinon.spy(DocumentPluginDummy.Field.prototype, 'setCode');
    659 					
    660 					setAddEditItems(testItems[3]);
    661 					yield execCommand('addEditCitation', docID);
    662 					var field = setTextSpy.firstCall.thisValue;
    663 					
    664 					for (let i = 0; i < setTextSpy.callCount; i++) {
    665 						assert.isTrue(yield field.equals(setTextSpy.getCall(i).thisValue));
    666 					}
    667 
    668 					for (let i = 0; i < setCodeSpy.callCount; i++) {
    669 						assert.isTrue(yield field.equals(setCodeSpy.getCall(i).thisValue));
    670 					}
    671 
    672 					setTextSpy.restore();
    673 					setCodeSpy.restore();
    674 				})
    675 			});
    676 		});
    677 		
    678 		describe('#addEditBibliography', function() {
    679 			var docID = this.fullTitle();
    680 			beforeEach(function* () {
    681 				yield initDoc(docID);
    682 				yield execCommand('addEditCitation', docID);
    683 			});
    684 			
    685 			it('should insert bibliography if no bibliography field present', function* () {
    686 				displayDialogStub.resetHistory();
    687 				yield execCommand('addEditBibliography', docID);
    688 				assert.isFalse(displayDialogStub.called);
    689 				var biblPresent = false;
    690 				for (let i = applications[docID].doc.fields.length-1; i >= 0; i--) {
    691 					let field = yield Zotero.Integration.Field.loadExisting(applications[docID].doc.fields[i]);
    692 					if (field.type == INTEGRATION_TYPE_BIBLIOGRAPHY) {
    693 						biblPresent = true;
    694 						break;
    695 					}
    696 				}
    697 				assert.isTrue(biblPresent);
    698 			});
    699 			
    700 			it('should display the edit bibliography dialog if bibliography present', function* () {
    701 				yield execCommand('addEditBibliography', docID);
    702 				displayDialogStub.resetHistory();
    703 				yield execCommand('addEditBibliography', docID);
    704 				assert.isTrue(displayDialogStub.calledOnce);
    705 				assert.isTrue(displayDialogStub.lastCall.args[0].includes('editBibliographyDialog'));
    706 			});
    707 		});
    708 	});
    709 	
    710 	describe("DocumentData", function() {
    711 		it('should properly unserialize old XML document data', function() {
    712 			var serializedXMLData = "<data data-version=\"3\" zotero-version=\"5.0.SOURCE\"><session id=\"F0NFmZ32\"/><style id=\"http://www.zotero.org/styles/cell\" hasBibliography=\"1\" bibliographyStyleHasBeenSet=\"1\"/><prefs><pref name=\"fieldType\" value=\"ReferenceMark\"/><pref name=\"automaticJournalAbbreviations\" value=\"true\"/><pref name=\"noteType\" value=\"0\"/></prefs></data>";
    713 			var data = new Zotero.Integration.DocumentData(serializedXMLData);
    714 			var expectedData = {
    715 				style: {
    716 					styleID: 'http://www.zotero.org/styles/cell',
    717 					locale: null,
    718 					hasBibliography: true,
    719 					bibliographyStyleHasBeenSet: true
    720 				},
    721 				prefs: {
    722 					fieldType: 'ReferenceMark',
    723 					automaticJournalAbbreviations: true,
    724 					noteType: 0
    725 				},
    726 				sessionID: 'F0NFmZ32',
    727 				zoteroVersion: '5.0.SOURCE',
    728 				dataVersion: '3'
    729 			};
    730 			// Convert to JSON to remove functions from DocumentData object
    731 			assert.equal(JSON.stringify(data), JSON.stringify(expectedData));
    732 		});
    733 		
    734 		it('should properly unserialize JSON document data', function() {
    735 			var expectedData = JSON.stringify({
    736 				style: {
    737 					styleID: 'http://www.zotero.org/styles/cell',
    738 					locale: 'en-US',
    739 					hasBibliography: true,
    740 					bibliographyStyleHasBeenSet: true
    741 				},
    742 				prefs: {
    743 					fieldType: 'ReferenceMark',
    744 					automaticJournalAbbreviations: false,
    745 					noteType: 0
    746 				},
    747 				sessionID: 'owl-sesh',
    748 				zoteroVersion: '5.0.SOURCE',
    749 				dataVersion: 4
    750 			});
    751 			var data = new Zotero.Integration.DocumentData(expectedData);
    752 			// Convert to JSON to remove functions from DocumentData object
    753 			assert.equal(JSON.stringify(data), expectedData);
    754 		});
    755 		
    756 		it('should properly serialize document data to XML (data ver 3)', function() {
    757 			sinon.spy(Zotero, 'debug');
    758 			var data = new Zotero.Integration.DocumentData();
    759 			data.sessionID = "owl-sesh";
    760 			data.zoteroVersion = Zotero.version;
    761 			data.dataVersion = 3;
    762 			data.style = {
    763 				styleID: 'http://www.zotero.org/styles/cell',
    764 				locale: 'en-US',
    765 				hasBibliography: false,
    766 				bibliographyStyleHasBeenSet: true
    767 			};
    768 			data.prefs = {
    769 				noteType: 1,
    770 				fieldType: "Field",
    771 				automaticJournalAbbreviations: true
    772 			};
    773 			
    774 			var serializedData = data.serialize();
    775 			// Make sure we serialized to XML here
    776 			assert.equal(serializedData[0], '<');
    777 			// Serialize and unserialize (above test makes sure unserialize works properly).
    778 			var processedData = new Zotero.Integration.DocumentData(serializedData);
    779 			
    780 			// This isn't ideal, but currently how it works. Better serialization which properly retains types
    781 			// coming with official 5.0 release.
    782 			data.dataVersion = "3";
    783 
    784 			// Convert to JSON to remove functions from DocumentData objects
    785 			assert.equal(JSON.stringify(processedData), JSON.stringify(data));
    786 			
    787 			// Make sure we are not triggering debug traces in Utilities.htmlSpecialChars()
    788 			assert.isFalse(Zotero.debug.calledWith(sinon.match.string, 1));
    789 			Zotero.debug.restore();
    790 		});
    791 		
    792 		it('should properly serialize document data to JSON (data ver 4)', function() {
    793 			var data = new Zotero.Integration.DocumentData();
    794 			// data version 4 triggers serialization to JSON
    795 			// (e.g. when we've retrieved data from the doc and it was ver 4 already)
    796 			data.dataVersion = 4;
    797 			data.sessionID = "owl-sesh";
    798 			data.style = {
    799 				styleID: 'http://www.zotero.org/styles/cell',
    800 				locale: 'en-US',
    801 				hasBibliography: false,
    802 				bibliographyStyleHasBeenSet: true
    803 			};
    804 			data.prefs = {
    805 				noteType: 1,
    806 				fieldType: "Field",
    807 				automaticJournalAbbreviations: true
    808 			};
    809 			
    810 			// Serialize and unserialize (above tests makes sure unserialize works properly).
    811 			var processedData = new Zotero.Integration.DocumentData(data.serialize());
    812 
    813 			// Added in serialization routine
    814 			data.zoteroVersion = Zotero.version;
    815 			
    816 			// Convert to JSON to remove functions from DocumentData objects
    817 			assert.deepEqual(JSON.parse(JSON.stringify(processedData)), JSON.parse(JSON.stringify(data)));
    818 		});
    819 	})
    820 });