"use strict";

describe("Zotero.CollectionTree", function () {
	var win, zp, cv, userLibraryID;
	
	before(function* () {
		win = yield loadZoteroPane();
		zp = win.ZoteroPane;
		cv = zp.collectionsView;
		userLibraryID = Zotero.Libraries.userLibraryID;
	});
	beforeEach(function () {
		// TODO: Add a selectCollection() function and select a collection instead?
		return selectLibrary(win);
	})
	after(function () {
		win.close();
	});
	
	describe("#refresh()", function () {
		it("should show Duplicate Items and Unfiled Items by default and shouldn't show Retracted Items", async function () {
			Zotero.Prefs.clear('duplicateLibraries');
			Zotero.Prefs.clear('unfiledLibraries');
			Zotero.Prefs.clear('retractedLibraries');
			await cv.refresh();
			assert.ok(cv.getRowIndexByID("D" + userLibraryID));
			assert.ok(cv.getRowIndexByID("U" + userLibraryID));
			assert.isFalse(cv.getRowIndexByID("R" + userLibraryID));
		});
		
		it("shouldn't show virtual collections if hidden", async function () {
			Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`);
			Zotero.Prefs.set('unfiledLibraries', `{"${userLibraryID}": false}`);
			Zotero.Prefs.set('retractedLibraries', `{"${userLibraryID}": false}`);
			await cv.refresh();
			assert.isFalse(cv.getRowIndexByID("D" + userLibraryID));
			assert.isFalse(cv.getRowIndexByID("U" + userLibraryID));
			assert.isFalse(cv.getRowIndexByID("R" + userLibraryID));
		});
		
		it("should maintain open state of group", async function () {
			var group1 = await createGroup();
			var group2 = await createGroup();
			var group1Row = cv.getRowIndexByID(group1.treeViewID);
			var group2Row = cv.getRowIndexByID(group2.treeViewID);
			
			// Open group 1 and close group 2
			if (!cv.isContainerOpen(group1Row)) {
				await cv.toggleOpenState(group1Row);
			}
			if (cv.isContainerOpen(group2Row)) {
				await cv.toggleOpenState(group2Row);
			}
			
			cv._saveOpenStates();
			// #_saveOpenStates is debounced
			await Zotero.Promise.delay(500);
			
			group1Row = cv.getRowIndexByID(group1.treeViewID);
			group2Row = cv.getRowIndexByID(group2.treeViewID);
			
			await cv.refresh();
			
			// Group rows shouldn't have changed
			assert.equal(cv.getRowIndexByID(group1.treeViewID), group1Row);
			assert.equal(cv.getRowIndexByID(group2.treeViewID), group2Row);
			// Group open states shouldn't have changed
			assert.isTrue(cv.isContainerOpen(group1Row));
			assert.isFalse(cv.isContainerOpen(group2Row));
		});
		
		it("should update associated item tree view", async function () {
			var collection = await createDataObject('collection');
			var item = await createDataObject('item', { collections: [collection.id] });
			await cv.reload();
			await cv.selectCollection(collection.id);
			await cv.selectItem(item.id);
		});
	});
	
	describe("collapse/expand", function () {
		it("should close and open My Library repeatedly", async function () {
			await cv.selectLibrary(userLibraryID);
			var row = cv.selection.focused;
			
			cv.collapseLibrary(userLibraryID);
			assert.equal(cv.selection.focused, row);
			assert.isFalse(cv.isContainerOpen(row));
			
			await cv.expandLibrary(userLibraryID);
			assert.equal(cv.selection.focused, row);
			assert.ok(cv.isContainerOpen(row));
			
			cv.collapseLibrary(userLibraryID);
			assert.equal(cv.selection.focused, row);
			assert.isFalse(cv.isContainerOpen(row));
			
			await cv.expandLibrary(userLibraryID);
			assert.equal(cv.selection.focused, row);
			assert.ok(cv.isContainerOpen(row));
		})
	})
	
	describe("#expandLibrary()", function () {
		var libraryRow, col1, col2, col3;
		
		before(function* () {
			yield cv.selectLibrary(userLibraryID);
			libraryRow = cv.selection.focused;
		});
		
		beforeEach(function* () {
			// My Library
			//   - A
			//     - B
			//       - C
			col1 = yield createDataObject('collection');
			col2 = yield createDataObject('collection', { parentID: col1.id });
			col3 = yield createDataObject('collection', { parentID: col2.id });
			yield select(win, col3);
		});
		
		it("should open a library and respect stored container state", async function () {
			// Collapse B
			await cv.toggleOpenState(cv.getRowIndexByID(col2.treeViewID));
			cv._saveOpenStates();
			// #_saveOpenStates is debounced
			await Zotero.Promise.delay(500);
			
			// Close and reopen library
			await cv.toggleOpenState(libraryRow);
			await cv.expandLibrary(userLibraryID);

			assert.isTrue(cv.isContainerOpen(libraryRow));
			assert.isTrue(cv.isContainerOpen(cv.getRowIndexByID(col1.treeViewID)));
			assert.isFalse(cv.isContainerOpen(cv.getRowIndexByID(col2.treeViewID)));
		});
		
		it("should open a library and all subcollections in recursive mode", async function () {
			await cv.toggleOpenState(cv.getRowIndexByID(col2.treeViewID));
			cv._saveOpenStates();
			// #_saveOpenStates is debounced
			await Zotero.Promise.delay(500);
			
			// Close and reopen library
			await cv.toggleOpenState(libraryRow);
			await cv.expandLibrary(userLibraryID, true);

			assert.isTrue(cv.isContainerOpen(cv.getRowIndexByID(col1.treeViewID)));
			assert.isTrue(cv.isContainerOpen(cv.getRowIndexByID(col2.treeViewID)));
		});
		
		it("should open a group and show top-level collections", async function () {
			var group = await createGroup();
			var libraryID = group.libraryID;
			var col1 = await createDataObject('collection', { libraryID });
			var col2 = await createDataObject('collection', { libraryID });
			var col3 = await createDataObject('collection', { libraryID });
			var col4 = await createDataObject('collection', { libraryID, parentID: col1.id });
			var col5 = await createDataObject('collection', { libraryID, parentID: col4.id });
			
			// Close group
			cv.toggleOpenState(cv.getRowIndexByID(group.treeViewID), false);
			
			await cv.expandLibrary(libraryID);
			
			assert.isNumber(cv.getRowIndexByID(col1.treeViewID));
			assert.isNumber(cv.getRowIndexByID(col2.treeViewID));
			assert.isNumber(cv.getRowIndexByID(col3.treeViewID));
			assert.isFalse(cv.getRowIndexByID(col4.treeViewID));
			assert.isFalse(cv.getRowIndexByID(col5.treeViewID));
		});
	});
	
	describe("#expandToCollection()", function () {
		it("should expand a collection to a subcollection", async function () {
			var collection1 = await createDataObject('collection');
			var collection2 = await createDataObject('collection', { parentID: collection1.id });
			var row = cv.getRowIndexByID("C" + collection1.id);
			assert.isFalse(cv.isContainerOpen(row));
			
			await cv.expandToCollection(collection2.id);
			cv.forceUpdate();
			
			// Make sure parent row position hasn't changed
			assert.equal(cv.getRowIndexByID("C" + collection1.id), row);
			// Parent should have been opened
			assert.isTrue(cv.isContainerOpen(row));
		})
	})
	
	describe("#selectByID()", function () {
		it("should select the trash", async function () {
			await cv.selectByID("T1");
			var row = cv.selection.focused;
			var treeRow = cv.getRow(row);
			assert.ok(treeRow.isTrash());
			assert.equal(treeRow.ref.libraryID, userLibraryID);
		})
	})
	
	describe("#selectWait()", function () {
		it("shouldn't hang if row is already selected", async function () {
			var row = cv.getRowIndexByID("T" + userLibraryID);
			await cv.selectWait(row);
			await Zotero.Promise.delay(50);
			await cv.selectWait(row);
		})
	})
	
	describe("Trash for collections/searches", function () {
		for (let objectType of ['collection', 'search']) {
			it(`should remove deleted ${objectType} from collectionTree`, async function () {
				var o1 = await createDataObject(objectType);
				var o2 = await createDataObject(objectType);
				var o3 = await createDataObject(objectType);
				
				// Move them to trash
				o1.deleted = true;
				o2.deleted = true;
				o3.deleted = true;
				await o1.saveTx();
				await o2.saveTx();
				await o3.saveTx();

				// Make sure they're gone from collectionTree
				assert.isFalse(cv.getRowIndexByID(o1.treeViewID));
				assert.isFalse(cv.getRowIndexByID(o2.treeViewID));
				assert.isFalse(cv.getRowIndexByID(o3.treeViewID));
			});
			
			it(`should put restored ${objectType} back into collectionTree`, async function () {
				var o1 = await createDataObject(objectType, { deleted: true });
				var o2 = await createDataObject(objectType, { deleted: true });
				var o3 = await createDataObject(objectType, { deleted: true });
				
				await selectTrash(win);

				// Restore
				await Zotero.DB.executeTransaction(async function () {
					o1.deleted = false;
					o2.deleted = false;
					o3.deleted = false;
					await o1.save();
					await o2.save();
					await o3.save();
				});

				// Check if trash is still selected
				let trashRow = cv.getRowIndexByID("T" + userLibraryID);
				assert.equal(cv.selection.focused, trashRow);

				// Check if restored entries are back in collectionTree
				assert.isNumber(cv.getRowIndexByID(o1.treeViewID));
				assert.isNumber(cv.getRowIndexByID(o2.treeViewID));
				assert.isNumber(cv.getRowIndexByID(o3.treeViewID));
				// Make sure it's all gone from trash
				assert.isFalse(zp.itemsView.getRowIndexByID(o1.treeViewID));
				assert.isFalse(zp.itemsView.getRowIndexByID(o2.treeViewID));
				assert.isFalse(zp.itemsView.getRowIndexByID(o3.treeViewID));
			});
		}

		it("should delete subcollections when parent is deleted", async function () {
			var c1 = await createDataObject('collection');
			var c2 = await createDataObject('collection', { parentID: c1.id });
			var c3 = await createDataObject('collection', { parentID: c2.id });
			
			// Expand to bottom
			await select(win, c3);
			// Select top parent
			await select(win, c1);
			// Move parent to trash
			await cv.deleteSelection();

			// Make sure they're gone from collectionTree
			assert.isFalse(cv.getRowIndexByID(c1.treeViewID));
			assert.isFalse(cv.getRowIndexByID(c2.treeViewID));
			assert.isFalse(cv.getRowIndexByID(c3.treeViewID));
		})

		it("should restore deleted subcollections with parent", async function () {
			var c1 = await createDataObject('collection', { deleted: true });
			var c2 = await createDataObject('collection', { parentID: c1.id });
			var c3 = await createDataObject('collection', { parentID: c2.id });
			
			await selectTrash(win);
			
			// Restore items
			zp.itemsView.selectItem(c1.treeViewID);
			await zp.restoreSelectedItems();
			
			// Check if trash is still selected
			let trashRow = cv.getRowIndexByID("T" + userLibraryID);
			assert.equal(cv.selection.focused, trashRow);
			
			// Check if restored collections are back in collectionTree
			let parentRowIndex = cv.getRowIndexByID(c1.treeViewID);
			await cv.toggleOpenState(parentRowIndex);
			assert.equal(cv.getRow(parentRowIndex).level, 1);
			
			let middleRowIndex = cv.getRowIndexByID(c2.treeViewID);
			await cv.toggleOpenState(middleRowIndex);
			assert.equal(cv.getRow(middleRowIndex).level, 2);
			
			let bottomRowIndex = cv.getRowIndexByID(c3.treeViewID);
			assert.equal(cv.getRow(bottomRowIndex).level, 3);
			
			// Make sure they're gone from trash
			assert.isFalse(zp.itemsView.getRowIndexByID(c1.treeViewID));
		});
	});
	
	describe("#notify()", function () {
		it("should select a new collection", async function () {
			// Create collection
			var collection = new Zotero.Collection;
			collection.name = "Select new collection";
			var id = await collection.saveTx();
			
			// New collection should be selected
			var selected = cv.getSelectedCollection(true);
			assert.equal(selected, id);
		});
		
		it("shouldn't select a new collection if skipNotifier is passed", async function () {
			// Create collection with skipNotifier flag
			var collection = new Zotero.Collection;
			collection.name = "No select on skipNotifier";
			var id = await collection.saveTx({
				skipNotifier: true
			});
			
			// Library should still be selected
			assert.equal(cv.getSelectedLibraryID(), userLibraryID);
		});
		
		it("shouldn't select a new collection if skipSelect is passed", async function () {
			// Create collection with skipSelect flag
			var collection = new Zotero.Collection;
			collection.name = "No select on skipSelect";
			var id = await collection.saveTx({
				skipSelect: true
			});
			
			// Library should still be selected
			assert.equal(cv.getSelectedLibraryID(), userLibraryID);
		});
		
		it("shouldn't select a modified collection", async function () {
			// Create collection
			var collection = new Zotero.Collection;
			collection.name = "No select on modify";
			var id = await collection.saveTx();
			
			await selectLibrary(win);
			
			collection.name = "No select on modify 2";
			await collection.saveTx();
			
			// Modified collection should not be selected
			assert.equal(cv.getSelectedLibraryID(), userLibraryID);
		});
		
		it("should maintain selection on a selected modified collection", async function () {
			// Create collection
			var collection = new Zotero.Collection;
			collection.name = "Reselect on modify";
			var id = await collection.saveTx();
			
			var selected = cv.getSelectedCollection(true);
			assert.equal(selected, id);
			
			collection.name = "Reselect on modify 2";
			await collection.saveTx();
			
			// Modified collection should still be selected
			selected = cv.getSelectedCollection(true);
			assert.equal(selected, id);
		});
		
		describe(".deleted selection", function () {
			for (let objectType of ['collection', 'search']) {
				it(`should select next row when ${objectType} is moved to trash`, async function () {
					var ran = Zotero.Utilities.randomString();
					var o1 = await createDataObject(objectType, { name: ran + "AAA" });
					var o2 = await createDataObject(objectType, { name: ran + "BBB" });
					var o3 = await createDataObject(objectType, { name: ran + "CCC" });
					
					await cv.selectByID(o2.treeViewID);
					
					o2.deleted = true;
					await o2.saveTx();
					
					assert.equal(zp.getCollectionTreeRow().ref.id, o3.id);
				});
				
				it(`should maintain selection on ${objectType} when row above is moved to trash`, async function () {
					var ran = Zotero.Utilities.randomString();
					var o1 = await createDataObject(objectType, { name: ran + "AAA" });
					var o2 = await createDataObject(objectType, { name: ran + "BBB" });
					var o3 = await createDataObject(objectType, { name: ran + "CCC" });
					
					await cv.selectByID(o3.treeViewID);
					assert.equal(zp.getCollectionTreeRow().ref.id, o3.id);
					
					o1.deleted = true;
					await o1.saveTx();
					
					assert.equal(zp.getCollectionTreeRow().ref.id, o3.id);
				});
				
				it(`should maintain selection on trash when ${objectType} is restored`, async function () {
					var o = await createDataObject(objectType, { deleted: true });
					
					await cv.selectByID("T1");
					
					o.deleted = false;
					await o.saveTx();
					
					assert.isTrue(zp.getCollectionTreeRow().isTrash());
					
					// Row should have been added back
					assert.isAbove(cv.getRowIndexByID(o.treeViewID), 0);
				});
			}
		});
		
		for (let objectType of ['collection', 'search']) {
			it(`should select next row when ${objectType} is erased`, async function () {
				var ran = Zotero.Utilities.randomString();
				var o1 = await createDataObject(objectType, { name: ran + "AAA" });
				var o2 = await createDataObject(objectType, { name: ran + "BBB" });
				var o3 = await createDataObject(objectType, { name: ran + "CCC" });
				
				await cv.selectByID(o2.treeViewID);
				
				await o2.eraseTx();
				
				assert.equal(zp.getCollectionTreeRow().ref.id, o3.id);
			});
		}
		
		it("should update the editability of the current view", async function () {
			var group = await createGroup({
				editable: false,
				filesEditable: false
			});
			await cv.selectLibrary(group.libraryID);
			await waitForItemsLoad(win);
			
			assert.isFalse(zp.getCollectionTreeRow().editable);
			var cmd = win.document.getElementById('cmd_zotero_newStandaloneNote');
			assert.isTrue(cmd.getAttribute('disabled') == 'true');
			
			group.editable = true;
			await group.saveTx();
			
			assert.isTrue(zp.getCollectionTreeRow().editable);
			assert.isFalse(cmd.getAttribute('disabled') == 'true');
		});
		
		it("should re-sort a modified collection", async function () {
			var prefix = Zotero.Utilities.randomString() + " ";
			var collectionA = await createDataObject('collection', { name: prefix + "A" });
			var collectionB = await createDataObject('collection', { name: prefix + "B" });
			
			var aRow = cv.getRowIndexByID("C" + collectionA.id);
			var aRowOriginal = aRow;
			var bRow = cv.getRowIndexByID("C" + collectionB.id);
			assert.equal(bRow, aRow + 1);
			
			collectionA.name = prefix + "C";
			await collectionA.saveTx();
			
			var aRow = cv.getRowIndexByID("C" + collectionA.id);
			var bRow = cv.getRowIndexByID("C" + collectionB.id);
			assert.equal(bRow, aRowOriginal);
			assert.equal(aRow, bRow + 1);
		})
		
		it("should re-sort a modified search", async function () {
			var prefix = Zotero.Utilities.randomString() + " ";
			var searchA = await createDataObject('search', { name: prefix + "A" });
			var searchB = await createDataObject('search', { name: prefix + "B" });
			
			var aRow = cv.getRowIndexByID("S" + searchA.id);
			var aRowOriginal = aRow;
			var bRow = cv.getRowIndexByID("S" + searchB.id);
			assert.equal(bRow, aRow + 1);
			
			searchA.name = prefix + "C";
			await searchA.saveTx();
			
			var aRow = cv.getRowIndexByID("S" + searchA.id);
			var bRow = cv.getRowIndexByID("S" + searchB.id);
			assert.equal(bRow, aRowOriginal);
			assert.equal(aRow, bRow + 1);
		})
		
		
		it("should add collection after parent's subcollection and before non-sibling", async function () {
			var c0 = await createDataObject('collection', { name: "Test" });
			var rootRow = cv.getRowIndexByID(c0.treeViewID);
			
			var c1 = await createDataObject('collection', { name: "1", parentID: c0.id });
			var c2 = await createDataObject('collection', { name: "2", parentID: c0.id });
			var c3 = await createDataObject('collection', { name: "3", parentID: c1.id });
			var c4 = await createDataObject('collection', { name: "4", parentID: c3.id });
			var c5 = await createDataObject('collection', { name: "5", parentID: c1.id });
			await select(win, c4);
			
			assert.equal(cv.getRowIndexByID(c1.treeViewID), rootRow + 1);
			
			assert.isAbove(cv.getRowIndexByID(c1.treeViewID), cv.getRowIndexByID(c0.treeViewID));
			assert.isAbove(cv.getRowIndexByID(c2.treeViewID), cv.getRowIndexByID(c0.treeViewID));
			
			assert.isAbove(cv.getRowIndexByID(c3.treeViewID), cv.getRowIndexByID(c1.treeViewID));
			assert.isAbove(cv.getRowIndexByID(c5.treeViewID), cv.getRowIndexByID(c1.treeViewID));
			assert.isBelow(cv.getRowIndexByID(c5.treeViewID), cv.getRowIndexByID(c2.treeViewID));
			
			assert.equal(cv.getRowIndexByID(c4.treeViewID), cv.getRowIndexByID(c3.treeViewID) + 1);
		});
		
		
		it("should add multiple collections", async function () {
			var col1, col2;
			await Zotero.DB.executeTransaction(async function () {
				col1 = createUnsavedDataObject('collection');
				col2 = createUnsavedDataObject('collection');
				await col1.save();
				await col2.save();
			});
			
			var aRow = cv.getRowIndexByID("C" + col1.id);
			var bRow = cv.getRowIndexByID("C" + col2.id);
			assert.isAbove(aRow, 0);
			assert.isAbove(bRow, 0);
			// skipSelect is implied for multiple collections, so library should still be selected
			assert.equal(cv.selection.focused, 0);
		});
		
		
		it("shouldn't refresh the items list when a collection is modified", async function () {
			var collection = await createDataObject('collection');
			await waitForItemsLoad(win);
			var itemsView = zp.itemsView;
			
			collection.name = "New Name";
			await collection.saveTx();
			
			await waitForItemsLoad(win);
			assert.equal(zp.itemsView, itemsView);
		})
		
		it("should add a saved search after collections", async function () {
			var collection = new Zotero.Collection;
			collection.name = "Test";
			var collectionID = await collection.saveTx();
			
			var search = new Zotero.Search;
			search.name = "A Test Search";
			search.addCondition('title', 'contains', 'test');
			var searchID = await search.saveTx();
			
			var collectionRow = cv._rowMap["C" + collectionID];
			var searchRow = cv._rowMap["S" + searchID];
			var duplicatesRow = cv._rowMap["D" + userLibraryID];
			var unfiledRow = cv._rowMap["U" + userLibraryID];
			
			assert.isAbove(searchRow, collectionRow);
			// If there's a duplicates row or an unfiled row, add before those.
			// Otherwise, add before the trash
			if (duplicatesRow !== undefined) {
				assert.isBelow(searchRow, duplicatesRow);
			}
			else if (unfiledRow !== undefined) {
				assert.isBelow(searchRow, unfiledRow);
			}
			else {
				var trashRow = cv._rowMap["T" + userLibraryID];
				assert.isBelow(searchRow, trashRow);
			}
		})
		
		it("shouldn't select a new group", async function () {
			var group = await createGroup();
			// Library should still be selected
			assert.equal(cv.getSelectedLibraryID(), userLibraryID);
		})
		
		it("should remove a group and all children", async function () {
			// Make sure Group Libraries separator and header exist already,
			// since otherwise they'll interfere with the count
			await getGroup();
			
			var originalRowCount = cv._rows.length;
			
			var group = await createGroup();
			await createDataObject('collection', { libraryID: group.libraryID });
			var c = await createDataObject('collection', { libraryID: group.libraryID });
			var c2 = await createDataObject('collection', { libraryID: group.libraryID, parentID: c.id });
			await createDataObject('collection', { libraryID: group.libraryID });
			await createDataObject('collection', { libraryID: group.libraryID });
			await select(win, c2);
			
			// Group, collections, Recently Read, Duplicates, Unfiled, and trash
			assert.equal(cv._rows.length, originalRowCount + 10);
			
			// Select group
			await cv.selectLibrary(group.libraryID);
			await waitForItemsLoad(win);
			
			var spy = sinon.spy(cv, "refresh");
			try {
				await group.eraseTx();
				
				assert.equal(cv._rows.length, originalRowCount);
				// Make sure the tree wasn't refreshed
				sinon.assert.notCalled(spy);
			}
			finally {
				spy.restore();
			}
		})
		
		it("should select a new feed", async function () {
			var feed = await createFeed();
			// Feed should be selected
			assert.equal(cv.getSelectedLibraryID(), feed.id);
		});
		
		it("shouldn't select a new feed with skipSelect: true", async function () {
			var feed = await createFeed({
				saveOptions: {
					skipSelect: true
				}
			});
			// Library should still be selected
			assert.equal(cv.getSelectedLibraryID(), userLibraryID);
		});
		
		it("should remove deleted feed", async function () {
			var feed = await createFeed();
			await cv.selectLibrary(feed.libraryID);
			waitForDialog();
			var id = feed.treeViewID;
			let promise = waitForCollectionTree(win);
			await win.ZoteroPane.deleteSelectedCollection();
			await promise;
			assert.isFalse(cv.getRowIndexByID(id))
		})
		
		it("should not reload tree upon feed update", async function () {
			var feed = await createFeed();
			await cv.selectLibrary(Zotero.Libraries.userLibraryID);
			try {
				var reloadSpy = sinon.spy(cv, 'reload');
				// A set of notifier calls  when a feed update is running
				Zotero.debug(feed.id, 2);
				await Zotero.Notifier.trigger('statusChanged', 'feed', feed.id);
				await Zotero.Notifier.trigger('modify', 'feed', feed.id);
				await Zotero.Notifier.trigger('unreadCountUpdated', 'feed', feed.id);
				await Zotero.Notifier.trigger('statusChanged', 'feed', feed.id);
				assert.isFalse(reloadSpy.called);
			} finally {
				reloadSpy.restore();
			}
		});
	});
	
	describe("#selectItem()", function () {
		it("should switch to library root if item isn't in collection", async function () {
			var item = await createDataObject('item');
			var collection = await createDataObject('collection');
			Zotero.debug(zp.itemsView._rows);
			await cv.selectItem(item.id);
			await waitForItemsLoad(win);
			assert.equal(cv.selection.focused, 0);
			assert.sameMembers(zp.itemsView.getSelectedItems(), [item]);
		});
	});
	
	describe("#selectItems()", function () {
		it("should switch to library root if at least one item isn't in the current collection", async function () {
			var collection = await createDataObject('collection');
			var item1 = await createDataObject('item', { collections: [collection.id] });
			var item2 = await createDataObject('item');
			await cv.selectItems([item1.id, item2.id]);
			await waitForItemsLoad(win);
			assert.equal(cv.selection.focused, 0);
			assert.sameMembers(zp.itemsView.getSelectedItems(true), [item1.id, item2.id]);
		});
		
		it("should switch to trash if item is in trash", async function () {
			var item = await createDataObject('item', { deleted: true });
			await cv.selectItems([item.id]);
			await waitForItemsLoad(win);
			assert.isTrue(zp.getCollectionTreeRow().isTrash());
			assert.sameMembers(zp.itemsView.getSelectedItems(true), [item.id]);
		});
		
		it("should switch to trash if parent of child note is in trash", async function () {
			var item = await createDataObject('item', { deleted: true });
			var note = await createDataObject('item', { itemType: 'note', parentItemID: item.id });
			await cv.selectItems([note.id]);
			await waitForItemsLoad(win);
			assert.isTrue(zp.getCollectionTreeRow().isTrash());
			assert.sameMembers(zp.itemsView.getSelectedItems(true), [note.id]);
		});
	});
	
	describe("#onDrop()", function () {
		/**
		 * Simulate a drag and drop
		 *
		 * @param {String} type - 'item' or 'collection'
		 * @param {String|Object} targetRow - Tree row id (e.g., "L123"), or { row, orient }
		 * @param {Integer[]} collectionIDs
		 * @param {Promise} [promise] - If a promise is provided, it will be waited for and its
		 *     value returned after the drag. Otherwise, an 'add' event will be waited for, and
		 *     an object with 'ids' and 'extraData' will be returned.
		 */
		var onDrop = async function (objectType, targetRow, ids, promise, action = 'copy') {
			if (typeof targetRow == 'string') {
				var row = cv.getRowIndexByID(targetRow);
				var orient = 0;
			}
			else {
				var { row, orient } = targetRow;
			}
			
			Zotero.DragDrop.currentDragSource = objectType == "item"
				? zp.itemsView.collectionTreeRow
				: null;
			
			if (!promise) {
				promise = waitForNotifierEvent("add", objectType);
			}
			await cv.onDrop({
				persist: () => 0,
				target: {ownerDocument: {defaultView: win}},
				dataTransfer: {
					dropEffect: action,
					effectAllowed: action,
					types: [`zotero/${objectType}`],
					getData: function (type) {
						if (type == `zotero/${objectType}`) {
							return ids.join(",");
						}
					}
				}
			}, row);
			
			// Add observer to wait for add
			var result = await promise;
			Zotero.DragDrop.currentDragSource = null;
			return result;
		};
		
		
		var canDrop = async function (objectType, targetRowID, ids) {
			var row = cv.getRowIndexByID(targetRowID);
			
			Zotero.DragDrop.currentDragSource = objectType == "item"
				? zp.itemsView.collectionTreeRow
				: null;
			var dt = {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: [`zotero/${objectType}`],
				getData: function (type) {
					if (type == `zotero/${objectType}`) {
						return ids.join(",");
					}
				}
			};
			var canDrop = cv.canDropCheck(row, 0, dt);
			if (canDrop) {
				canDrop = await cv.canDropCheckAsync(row, 0, dt);
			}
			Zotero.DragDrop.currentDragSource = null;
			return canDrop;
		};
		
		describe("with items", function () {
			it("should add an item to a collection", async function () {
				var collection = await createDataObject('collection');
				var item = await createDataObject('item', false, { skipSelect: true });
				
				// Add observer to wait for collection add
				var deferred = Zotero.Promise.defer();
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids, extraData) {
						if (type == 'collection-item' && event == 'add'
								&& ids[0] == collection.id + "-" + item.id) {
							setTimeout(function () {
								deferred.resolve();
							});
						}
					}
				}, 'collection-item', 'test');
				
				await onDrop('item', 'C' + collection.id, [item.id], deferred.promise);
				
				Zotero.Notifier.unregisterObserver(observerID);
				
				await select(win, collection);
				
				var itemsView = win.ZoteroPane.itemsView;
				assert.equal(itemsView.rowCount, 1);
				var treeRow = itemsView.getRow(0);
				assert.equal(treeRow.ref.id, item.id);
			})
			
			it("should move an item from one collection to another", async function () {
				var collection1 = await createDataObject('collection');
				await select(win, collection1);
				var collection2 = await createDataObject('collection');
				var item = await createDataObject('item', { collections: [collection1.id] });
				
				// Add observer to wait for collection add
				var deferred = Zotero.Promise.defer();
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids, extraData) {
						if (type == 'collection-item' && event == 'add'
								&& ids[0] == collection2.id + "-" + item.id) {
							setTimeout(function () {
								deferred.resolve();
							});
						}
					}
				}, 'collection-item', 'test');
				
				let promise = zp.itemsView.waitForSelect();
				await onDrop('item', 'C' + collection2.id, [item.id], deferred.promise, 'move');
				await promise;
				
				Zotero.Notifier.unregisterObserver(observerID);
				
				// Source collection should be empty
				assert.equal(zp.itemsView.rowCount, 0);
				
				await select(win, collection2);
				
				// Target collection should have item
				assert.equal(zp.itemsView.rowCount, 1);
				var treeRow = zp.itemsView.getRow(0);
				assert.equal(treeRow.ref.id, item.id);
			});
			
			describe("My Publications", function () {
				function getItemModifyPromise(item) {
					// Add observer to wait for item modification
					return new Zotero.Promise((resolve) => {
						var observerID = Zotero.Notifier.registerObserver({
							notify: function (event, type, ids, extraData) {
								if (type == 'item' && event == 'modify' && ids[0] == item.id) {
									setTimeout(resolve);
									Zotero.Notifier.unregisterObserver(observerID);
								}
							}
						}, 'item', 'test');
					});
				}
				
				function acceptItemsWithoutFiles(win) {
					var doc = win.document;
					doc.getElementById('confirm-authorship-checkbox').checked = true;
					var wizard = doc.getElementById('publications-dialog-wizard');
					if (!doc.getElementById('include-files').disabled) {
						throw new Error("Include Files checkbox isn't disabled");
					}
					wizard.getButton('next').click();
				}
				
				function acceptItemsWithFiles(win) {
					var doc = win.document;
					doc.getElementById('include-files').checked = true;
					doc.getElementById('confirm-authorship-checkbox').checked = true;
					var wizard = doc.getElementById('publications-dialog-wizard');
					if (doc.getElementById('include-files').disabled) {
						throw new Error("Include Files checkbox shouldn't be disabled");
					}
					wizard.getButton('next').click();
					wizard.getButton('next').click();
				}
				
				it("should add an item to My Publications", async function () {
					// TEMP: Disabled due to spurious errors
					// https://github.com/zotero/zotero/issues/5749
					if (Zotero.automatedTest) {
						this.skip();
						return;
					}
					// Remove other items in My Publications
					var s = new Zotero.Search();
					s.addCondition('libraryID', 'is', Zotero.Libraries.userLibraryID);
					s.addCondition('publications', 'true');
					var ids = await s.search();
					await Zotero.Items.erase(ids);
					
					var item = await createDataObject('item', false, { skipSelect: true });
					var libraryID = item.libraryID;
					
					var itemModifyPromise = getItemModifyPromise(item);
					var winPromise = waitForWindow('chrome://zotero/content/publicationsDialog.xhtml')
					var dropPromise = onDrop('item', 'P' + libraryID, [item.id], itemModifyPromise);
					acceptItemsWithoutFiles(await winPromise);
					await dropPromise;
					
					// Select publications and check for item
					await cv.selectByID("P" + libraryID);
					await waitForItemsLoad(win);
					var itemsView = win.ZoteroPane.itemsView
					assert.equal(itemsView.rowCount, 1);
					var treeRow = itemsView.getRow(0);
					assert.equal(treeRow.ref.id, item.id);
				});
				
				it("should add an item with a file attachment to My Publications", async function () {
					var item = await createDataObject('item', false, { skipSelect: true });
					var attachment = await importFileAttachment('test.png', { parentItemID: item.id });
					var libraryID = item.libraryID;
					
					var itemModifyPromise = getItemModifyPromise(item);
					var winPromise = waitForWindow('chrome://zotero/content/publicationsDialog.xhtml')
					var dropPromise = onDrop('item', 'P' + libraryID, [item.id], itemModifyPromise);
					acceptItemsWithFiles(await winPromise);
					await dropPromise;
					
					assert.isTrue(item.inPublications);
					// File attachment should be in My Publications
					assert.isTrue(attachment.inPublications);
				});
				
				it("should add an item with a linked URL attachment to My Publications", async function () {
					var item = await createDataObject('item', false, { skipSelect: true });
					var attachment = await Zotero.Attachments.linkFromURL({
						parentItemID: item.id,
						title: 'Test',
						url: 'http://127.0.0.1/',
						contentType: 'text/html'
					});
					var libraryID = item.libraryID;
					
					var itemModifyPromise = getItemModifyPromise(item);
					var winPromise = waitForWindow('chrome://zotero/content/publicationsDialog.xhtml')
					var dropPromise = onDrop('item', 'P' + libraryID, [item.id], itemModifyPromise);
					acceptItemsWithoutFiles(await winPromise);
					await dropPromise;
					
					assert.isTrue(item.inPublications);
					// Link attachment should be in My Publications
					assert.isTrue(attachment.inPublications);
				});
				
				it("shouldn't add linked file attachment to My Publications", async function () {
					var item = await createDataObject('item', false, { skipSelect: true });
					var attachment = await Zotero.Attachments.linkFromFile({
						parentItemID: item.id,
						title: 'Test',
						file: OS.Path.join(getTestDataDirectory().path, 'test.png'),
						contentType: 'image/png'
					});
					var libraryID = item.libraryID;
					
					var itemModifyPromise = getItemModifyPromise(item);
					var winPromise = waitForWindow('chrome://zotero/content/publicationsDialog.xhtml')
					var dropPromise = onDrop('item', 'P' + libraryID, [item.id], itemModifyPromise);
					acceptItemsWithoutFiles(await winPromise);
					await dropPromise;
					
					assert.isTrue(item.inPublications);
					// Linked URL attachment shouldn't be in My Publications
					assert.isFalse(attachment.inPublications);
				});
			});
			
			it("should copy an item with a PDF attachment containing annotations to a group", async function () {
				await Zotero.Users.setCurrentUserID(1);
				await Zotero.Users.setName(1, 'Name');
				
				var group = await createGroup();
				
				var item = await createDataObject('item', false, { skipSelect: true });
				var file = getTestDataDirectory();
				file.append('test.pdf');
				var attachment = await Zotero.Attachments.importFromFile({
					file,
					parentItemID: item.id
				});
				var annotation = await createAnnotation('highlight', attachment);
				
				var ids = (await onDrop('item', 'L' + group.libraryID, [item.id])).ids;
				
				await cv.selectLibrary(group.libraryID);
				await waitForItemsLoad(win);
				
				// Check parent
				var itemsView = win.ZoteroPane.itemsView;
				assert.equal(itemsView.rowCount, 1);
				var treeRow = itemsView.getRow(0);
				assert.equal(treeRow.ref.libraryID, group.libraryID);
				assert.equal(treeRow.ref.id, ids[0]);
				// New item should link back to original
				var linked = await item.getLinkedItem(group.libraryID);
				assert.equal(linked.id, treeRow.ref.id);
				
				// Check attachment
				assert.isTrue(itemsView.isContainer(0));
				itemsView.toggleOpenState(0);
				assert.equal(itemsView.rowCount, 2);
				treeRow = itemsView.getRow(1);
				assert.equal(treeRow.ref.id, ids[1]);
				// New attachment should link back to original
				linked = await attachment.getLinkedItem(group.libraryID);
				assert.equal(linked.id, treeRow.ref.id);
				
				// Check annotation
				var groupAttachment = Zotero.Items.get(treeRow.ref.id);
				var annotations = groupAttachment.getAnnotations();
				assert.lengthOf(annotations, 1);
				
				return group.eraseTx();
			});
			
			it("should copy a group item with a PDF attachment containing annotations to the personal library", async function () {
				await Zotero.Users.setCurrentUserID(1);
				await Zotero.Users.setName(1, 'Name 1');
				await Zotero.Users.setName(12345, 'Name 2');
				
				var group = await createGroup();
				await cv.selectLibrary(group.libraryID);
				
				var groupItem = await createDataObject('item', { libraryID: group.libraryID });
				var file = getTestDataDirectory();
				file.append('test.pdf');
				var attachment = await Zotero.Attachments.importFromFile({
					file,
					parentItemID: groupItem.id
				});
				var annotation = await createAnnotation('highlight', attachment);
				await annotation.saveTx();
				
				var ids = (await onDrop('item', 'L1', [groupItem.id])).ids;
				var newItem = Zotero.Items.get(ids[0]);
				
				// Check annotation
				var newAttachment = Zotero.Items.get(newItem.getAttachments())[0];
				var annotations = newAttachment.getAnnotations();
				assert.lengthOf(annotations, 1);
				
				return group.eraseTx();
			});
			
			it("should copy a standalone attachment to a group", async function () {
				await Zotero.Users.setCurrentUserID(1);
				await Zotero.Users.setName(1, 'Name 1');
				await Zotero.Users.setName(12345, 'Name 2');
				
				var group = await createGroup();
				
				var item = await importPDFAttachment();
				
				var ids = (await onDrop('item', 'L' + group.libraryID, [item.id])).ids;
				var newItem = Zotero.Items.get(ids[0]);
				
				assert.equal(newItem.libraryID, group.libraryID);
				assert.isTrue(newItem.isPDFAttachment());
				
				return group.eraseTx();
			});
			
			it("should not copy an item or its attachment to a group twice", async function () {
				var group = await getGroup();
				
				var itemTitle = Zotero.Utilities.randomString();
				var item = await createDataObject('item', false, { skipSelect: true });
				var file = getTestDataDirectory();
				file.append('test.png');
				var attachment = await Zotero.Attachments.importFromFile({
					file: file,
					parentItemID: item.id
				});
				var attachmentTitle = Zotero.Utilities.randomString();
				attachment.setField('title', attachmentTitle);
				await attachment.saveTx();
				
				await onDrop('item', 'L' + group.libraryID, [item.id]);
				assert.isFalse(await canDrop('item', 'L' + group.libraryID, [item.id]));
			})
			
			it("should copy an item from a read-only group to an editable group", async function () {
				var group1 = await createGroup();
				var item = await createDataObject('item', { libraryID: group1.libraryID });
				group1.editable = false;
				await group1.saveTx();
				var group2 = await createGroup();
				
				await cv.selectLibrary(group1.libraryID);
				await waitForItemsLoad(win);
				
				await onDrop('item', 'L' + group2.libraryID, [item.id]);
				
				assert.isFalse(await item.getLinkedItem(group2.libraryID));
				// New collection should link back to original
				assert.ok(await item.getLinkedItem(group2.libraryID, true));
				
				await group1.eraseTx();
				await group2.eraseTx();
			});
			
			it("should ignore a linked, trashed item when re-dragging an item to a group", async function () {
				var group = await getGroup();
				var collection = await createDataObject('collection', { libraryID: group.libraryID });
				
				var item = await createDataObject('item', false, { skipSelect: true });
				await onDrop('item', 'L' + group.libraryID, [item.id]);
				
				var droppedItem = await item.getLinkedItem(group.libraryID);
				droppedItem.setCollections([collection.id]);
				droppedItem.deleted = true;
				await droppedItem.saveTx();
				
				await onDrop('item', 'L' + group.libraryID, [item.id]);
				
				var linkedItem = await item.getLinkedItem(group.libraryID);
				assert.notEqual(linkedItem, droppedItem);
				
				assert.isTrue(droppedItem.deleted);
			})

			it("can delete an item by dropping it into the trash", async function () {
				var item = await createDataObject('item', false, { skipSelect: true });
				var deferred = Zotero.Promise.defer();
				deferred.resolve();
				await onDrop('item', 'T' + userLibraryID, [item.id], deferred.promise);
				assert.isTrue(item.deleted);
			});
		})
		
		
		describe("with collections", function () {
			it("should make a subcollection top-level", async function () {
				var collection1 = await createDataObject('collection', { name: "A" }, { skipSelect: true });
				var collection2 = await createDataObject('collection', { name: "C" }, { skipSelect: true });
				var collection3 = await createDataObject('collection', { name: "D" }, { skipSelect: true });
				var collection4 = await createDataObject('collection', { name: "B", parentKey: collection2.key });
				
				var colIndex1 = cv.getRowIndexByID('C' + collection1.id);
				var colIndex2 = cv.getRowIndexByID('C' + collection2.id);
				var colIndex3 = cv.getRowIndexByID('C' + collection3.id);
				var colIndex4 = cv.getRowIndexByID('C' + collection4.id);
				
				// Add observer to wait for collection add
				var deferred = Zotero.Promise.defer();
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids, extraData) {
						if (type == 'collection' && event == 'modify' && ids[0] == collection4.id) {
							setTimeout(function () {
								deferred.resolve();
							}, 50);
						}
					}
				}, 'collection', 'test');
				
				await onDrop(
					'collection',
					{
						row: 0,
						orient: 1
					},
					[collection4.id],
					deferred.promise
				);
				
				Zotero.Notifier.unregisterObserver(observerID);
				
				var newColIndex1 = cv.getRowIndexByID('C' + collection1.id);
				var newColIndex2 = cv.getRowIndexByID('C' + collection2.id);
				var newColIndex3 = cv.getRowIndexByID('C' + collection3.id);
				var newColIndex4 = cv.getRowIndexByID('C' + collection4.id);
				
				assert.equal(newColIndex1, colIndex1);
				assert.isBelow(newColIndex4, newColIndex2);
				assert.isBelow(newColIndex2, newColIndex3);
				assert.equal(cv.getRow(newColIndex4).level, cv.getRow(newColIndex1).level);
			})
			                                                                                         
			it("should move a subcollection and its subcollection down under another collection", async function () {
				var collectionA = await createDataObject('collection', { name: "A" });
				var collectionB = await createDataObject('collection', { name: "B", parentKey: collectionA.key });
				var collectionC = await createDataObject('collection', { name: "C", parentKey: collectionB.key });
				var collectionD = await createDataObject('collection', { name: "D" });
				var collectionE = await createDataObject('collection', { name: "E" });
				var collectionF = await createDataObject('collection', { name: "F" });
				var collectionG = await createDataObject('collection', { name: "G", parentKey: collectionD.key });
				var collectionH = await createDataObject('collection', { name: "H", parentKey: collectionG.key });
				
				await cv.expandToCollection(collectionC.id);
				await cv.expandToCollection(collectionH.id);
				
				var colIndexA = cv.getRowIndexByID('C' + collectionA.id);
				var colIndexB = cv.getRowIndexByID('C' + collectionB.id);
				var colIndexC = cv.getRowIndexByID('C' + collectionC.id);
				var colIndexD = cv.getRowIndexByID('C' + collectionD.id);
				var colIndexE = cv.getRowIndexByID('C' + collectionE.id);
				var colIndexF = cv.getRowIndexByID('C' + collectionF.id);
				var colIndexG = cv.getRowIndexByID('C' + collectionG.id);
				var colIndexH = cv.getRowIndexByID('C' + collectionH.id);
				
				await select(win, collectionG);
				
				// Add observer to wait for collection add
				var deferred = Zotero.Promise.defer();                                                                          
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids, extraData) {
						if (type == 'collection' && event == 'modify' && ids[0] == collectionG.id) {
							setTimeout(function () {
								deferred.resolve();
							}, 50);
						}
					}
				}, 'collection', 'test');
				
				await onDrop(
					'collection',
					{
						row: colIndexE,
						orient: 0
					},
					[collectionG.id],
					deferred.promise
				);
				
				Zotero.Notifier.unregisterObserver(observerID);
				
				var newColIndexA = cv.getRowIndexByID('C' + collectionA.id);
				var newColIndexB = cv.getRowIndexByID('C' + collectionB.id);
				var newColIndexC = cv.getRowIndexByID('C' + collectionC.id);
				var newColIndexD = cv.getRowIndexByID('C' + collectionD.id);
				var newColIndexE = cv.getRowIndexByID('C' + collectionE.id);
				var newColIndexF = cv.getRowIndexByID('C' + collectionF.id);
				var newColIndexG = cv.getRowIndexByID('C' + collectionG.id);
				var newColIndexH = cv.getRowIndexByID('C' + collectionH.id);
				
				assert.isFalse(cv.isContainerOpen(newColIndexD));
				assert.isTrue(cv.isContainerEmpty(newColIndexD));
				assert.isTrue(cv.isContainerOpen(newColIndexE));
				assert.isFalse(cv.isContainerEmpty(newColIndexE));
				assert.equal(newColIndexE, newColIndexG - 1);
				assert.equal(newColIndexG, newColIndexH - 1);
				
				// TODO: Check deeper subcollection open states
			});
			
			it("should move a subcollection and its subcollection up under another collection", async function () {
				var collectionA = await createDataObject('collection', { name: "A" });
				var collectionB = await createDataObject('collection', { name: "B", parentKey: collectionA.key });
				var collectionC = await createDataObject('collection', { name: "C", parentKey: collectionB.key });
				var collectionD = await createDataObject('collection', { name: "D" });
				var collectionE = await createDataObject('collection', { name: "E" });
				var collectionF = await createDataObject('collection', { name: "F" });
				var collectionG = await createDataObject('collection', { name: "G", parentKey: collectionE.key });
				var collectionH = await createDataObject('collection', { name: "H", parentKey: collectionG.key });
				
				await cv.expandToCollection(collectionC.id);
				await cv.expandToCollection(collectionH.id);
				
				var colIndexA = cv.getRowIndexByID('C' + collectionA.id);
				var colIndexB = cv.getRowIndexByID('C' + collectionB.id);
				var colIndexC = cv.getRowIndexByID('C' + collectionC.id);
				var colIndexD = cv.getRowIndexByID('C' + collectionD.id);
				var colIndexE = cv.getRowIndexByID('C' + collectionE.id);
				var colIndexF = cv.getRowIndexByID('C' + collectionF.id);
				var colIndexG = cv.getRowIndexByID('C' + collectionG.id);
				var colIndexH = cv.getRowIndexByID('C' + collectionH.id);
				
				await cv.selectCollection(collectionG.id);
				
				// Add observer to wait for collection add
				var deferred = Zotero.Promise.defer();
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids, extraData) {
						if (type == 'collection' && event == 'modify' && ids[0] == collectionG.id) {
							setTimeout(function () {
								deferred.resolve();
							}, 50);
						}
					}
				}, 'collection', 'test');
				
				await onDrop(
					'collection',
					{
						row: colIndexD,
						orient: 0
					},
					[collectionG.id],
					deferred.promise
				);
				
				Zotero.Notifier.unregisterObserver(observerID);
				
				var newColIndexA = cv.getRowIndexByID('C' + collectionA.id);
				var newColIndexB = cv.getRowIndexByID('C' + collectionB.id);
				var newColIndexC = cv.getRowIndexByID('C' + collectionC.id);
				var newColIndexD = cv.getRowIndexByID('C' + collectionD.id);
				var newColIndexE = cv.getRowIndexByID('C' + collectionE.id);
				var newColIndexF = cv.getRowIndexByID('C' + collectionF.id);
				var newColIndexG = cv.getRowIndexByID('C' + collectionG.id);
				var newColIndexH = cv.getRowIndexByID('C' + collectionH.id);
				
				assert.isFalse(cv.isContainerOpen(newColIndexE));
				assert.isTrue(cv.isContainerEmpty(newColIndexE));
				assert.isTrue(cv.isContainerOpen(newColIndexD));
				assert.isFalse(cv.isContainerEmpty(newColIndexD));
				assert.equal(newColIndexD, newColIndexG - 1);
				assert.equal(newColIndexG, newColIndexH - 1);
				
				// TODO: Check deeper subcollection open states
			});
			
			it("should copy a collection and its subcollection to another library", async function () {
				var group = await createGroup();
				
				var collectionA = await createDataObject('collection', { name: "A" });
				var collectionB = await createDataObject('collection', { name: "B", parentKey: collectionA.key });
				var itemA = await createDataObject('item', { collections: [collectionA.key] }, { skipSelect: true });
				var itemB = await createDataObject('item', { collections: [collectionB.key] }, { skipSelect: true });
				
				await cv.selectCollection(collectionA.id);
				
				// Add observer to wait for collection add
				var deferred = Zotero.Promise.defer();
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids, extraData) {
						if (type == 'collection' && event == 'modify' && ids.includes(collectionB.id)) {
							setTimeout(function () {
								deferred.resolve();
							}, 50);
						}
					}
				}, 'collection', 'test');
				
				await onDrop(
					'collection',
					'L' + group.libraryID,
					[collectionA.id],
					deferred.promise
				);
				
				Zotero.Notifier.unregisterObserver(observerID);
				
				var pred = Zotero.Relations.linkedObjectPredicate;
				var newCollectionA = await Zotero.URI.getURICollection(collectionA.getRelations()[pred][0]);
				var newCollectionB = await Zotero.URI.getURICollection(collectionB.getRelations()[pred][0]);
				var newItemA = await Zotero.URI.getURIItem(itemA.getRelations()[pred][0]);
				var newItemB = await Zotero.URI.getURIItem(itemB.getRelations()[pred][0]);
				assert.equal(newCollectionA.libraryID, group.libraryID);
				assert.equal(newCollectionB.libraryID, group.libraryID);
				assert.equal(newCollectionB.parentID, newCollectionA.id);
				assert.equal(newItemA.libraryID, group.libraryID);
				assert.equal(newItemB.libraryID, group.libraryID);
				assert.isTrue(newCollectionA.hasItem(newItemA));
				assert.isTrue(newCollectionB.hasItem(newItemB));
				assert.isFalse(newCollectionA.hasItem(newItemB));
				assert.isFalse(newCollectionB.hasItem(newItemA));
			})
			
			it("should copy a collection from a read-only group to another group", async function () {
				var group1 = await createGroup();
				var collection = await createDataObject('collection', { libraryID: group1.libraryID });
				var item = await createDataObject('item', { libraryID: group1.libraryID, collections: [collection.id] });
				group1.editable = false;
				await group1.saveTx();
				
				var group2 = await createGroup();
				
				await select(win, collection);
				
				await onDrop('collection', 'L' + group2.libraryID, [collection.id]);
				
				assert.isFalse(await collection.getLinkedCollection(group2.libraryID));
				// New collection should link back to original
				assert.ok(await collection.getLinkedCollection(group2.libraryID, true));
				
				assert.isFalse(await item.getLinkedItem(group2.libraryID));
				// New item should link back to original
				assert.ok(await item.getLinkedItem(group2.libraryID, true));
				
				await group1.eraseTx();
				await group2.eraseTx();
			});

			it("can delete a collection by dropping it into the trash", async function () {
				var collection = await createDataObject('collection');
				var deferred = Zotero.Promise.defer();
				deferred.resolve();
				await onDrop('collection', 'T' + userLibraryID, [collection.id], deferred.promise);
				assert.isTrue(collection.deleted);
			})
		})


		describe("with feed items", function () {
			var { HttpServer } = ChromeUtils.importESModule("chrome://remote/content/server/httpd.sys.mjs");;
			
			const httpdPort = 16214;
			var httpd;
			
			before(async function () {
				httpd = new HttpServer();
				httpd.start(httpdPort);
			});
			
			after(async function () {
				await new Promise(resolve => httpd.stop(resolve));
				Zotero.Prefs.clear('downloadAssociatedFiles');
				Zotero.Prefs.clear('automaticSnapshots');
			});

			it("should add a translated feed item retrieved from a URL", async function () {
				// Disable file/snapshot saving to avoid external network requests and async
				// attachment processing
				Zotero.Prefs.set('downloadAssociatedFiles', false);
				Zotero.Prefs.set('automaticSnapshots', false);

				// Serve the feed entry webpage via localhost
				const urlPath = "/journalArticle-single.html";
				const url = `http://localhost:${httpdPort}` + urlPath;
				httpd.registerFile(
					urlPath,
					Zotero.File.pathToFile(OS.Path.join(
						getTestDataDirectory().path, 'metadata', 'journalArticle-single.html'
					))
				);
				
				var feed = await createFeed();
				var collection = await createDataObject('collection', false, { skipSelect: true });
				var feedItem = await createDataObject('feedItem', {libraryID: feed.libraryID}, { skipSelect: true });
				feedItem.setField('url', url);
				await feedItem.saveTx();
				var translateFn = sinon.spy(feedItem, 'translate');

				// Add observer to wait for collection-item add, using setTimeout
				// to ensure all synchronous notifier processing completes first
				var deferred = Zotero.Promise.defer();
				var observerID = Zotero.Notifier.registerObserver({
					notify: function (event, type, ids) {
						if (type == 'collection-item' && event == 'add'
								&& ids.some(id => id.startsWith(collection.id + "-"))) {
							setTimeout(function () {
								deferred.resolve();
							});
						}
					}
				}, 'collection-item', 'test');

				await onDrop('item', 'C' + collection.id, [feedItem.id], deferred.promise);

				Zotero.Notifier.unregisterObserver(observerID);

				// Check that the translated item was the one that was created after drag
				var item = await translateFn.returnValues[0];
				assert.ok(item, 'Translation should return an item');

				await select(win, collection);

				var itemsView = win.ZoteroPane.itemsView;
				assert.equal(itemsView.rowCount, 1);
				var treeRow = itemsView.getRow(0);
				assert.equal(treeRow.ref.id, item.id);
			})
		})

		describe("with searches", function () {
			it("can delete a saved search by dropping it into the trash", async function () {
				var search = await createDataObject('search');
				var deferred = Zotero.Promise.defer();
				deferred.resolve();
				await onDrop('search', 'T' + userLibraryID, [search.id], deferred.promise);
				assert.isTrue(search.deleted);
			})
		});
	})

	describe("Feeds pseudo-library", function () {
		beforeEach(async function () {
			for (let feed of Zotero.Feeds.getAll()) {
				await feed.eraseTx();
			}
		});
		
		it("should contain feed items from all feeds", async function () {
			let feed1 = await createFeed();
			let feed2 = await createFeed();
			let feedItem1 = await createDataObject('feedItem', { libraryID: feed1.libraryID }, { skipSelect: true });
			let feedItem2 = await createDataObject('feedItem', { libraryID: feed2.libraryID }, { skipSelect: true });
			await cv.selectFeeds();
			await waitForItemsLoad(win);
			
			let itemsView = zp.itemsView;
			assert.equal(itemsView.rowCount, 2);
			assert.equal(itemsView.getRow(0).ref.id, feedItem2.id);
			assert.equal(itemsView.getRow(1).ref.id, feedItem1.id);
		});

		it("should be filterable", async function () {
			let feed1 = await createFeed();
			let feed2 = await createFeed();
			let feedItem1 = await createDataObject('feedItem', { libraryID: feed1.libraryID, setTitle: true }, { skipSelect: true });
			let feedItem2 = await createDataObject('feedItem', { libraryID: feed2.libraryID, setTitle: true }, { skipSelect: true });
			await cv.selectFeeds();
			await waitForItemsLoad(win);

			var quickSearch = win.document.getElementById('zotero-tb-search-textbox');
			quickSearch.value = feedItem1.getField('title');
			quickSearch.doCommand();

			let itemsView = zp.itemsView;
			await itemsView._refreshPromise;
			assert.equal(itemsView.rowCount, 1);
			assert.equal(itemsView.getRow(0).ref.id, feedItem1.id);
		});

		it("should be bold if any feed items are unread", async function () {
			let feed1 = await createFeed();
			let feed2 = await createFeed();
			let feedItem1 = await createDataObject('feedItem', { libraryID: feed1.libraryID, setTitle: true }, { skipSelect: true });
			let feedItem2 = await createDataObject('feedItem', { libraryID: feed2.libraryID, setTitle: true }, { skipSelect: true });
			
			await feedItem1.toggleRead(true);
			
			// Unread count is automatically updated on feed refresh, but we need to do it manually here
			await feed1.updateUnreadCount();
			await feed2.updateUnreadCount();
			
			assert.equal(cv.getRow(cv.getRowIndexByID('F1')).ref.unreadCount, 1);
			assert.lengthOf(win.document.querySelectorAll('#zotero-collections-tree .row.unread'), 2);
		});
	});

	describe("#setFilter()", function () {
		var collection1, collection2, collection3, collection4, collection5, collection6, collection7, collection8;
		var search1, search2, feed1, feed2;
		var allRows = [];
		let keyboardClick = (key) => {
			return new KeyboardEvent('keydown', {
				key: key,
				code: key,
				bubbles: true,
			});
		};
		before(async function () {
			// Delete all previously added collections, feeds, searches
			for (let col of Zotero.Collections.getByLibrary(userLibraryID)) {
				await col.eraseTx();
			}
			await clearFeeds();
			for (let s of Zotero.Searches.getByLibrary(userLibraryID)) {
				await s.eraseTx();
			}
			// Display the collection search bar
			win.document.getElementById("zotero-tb-collections-search").click();
			// Do not hide the search panel on blur
			win.document.getElementById("zotero-collections-search").removeEventListener('blur', zp.hideCollectionSearch);

			feed1 = await createFeed({ name: "feed_1 " });
			feed2 = await createFeed({ name: "feed_2" });
			
			collection1 = await createDataObject('collection', { name: "collection_level_one", libraryID: userLibraryID });
			collection2 = await createDataObject('collection', { name: "collection_level_two_1", parentID: collection1.id, libraryID: userLibraryID });
			collection3 = await createDataObject('collection', { name: "collection_level_two_2", parentID: collection1.id, libraryID: userLibraryID });
			collection4 = await createDataObject('collection', { name: "collection_level_three_1", parentID: collection2.id, libraryID: userLibraryID });
			collection5 = await createDataObject('collection', { name: "collection_level_three_11", parentID: collection2.id, libraryID: userLibraryID });
			collection6 = await createDataObject('collection', { name: "collection_level_one_1", libraryID: userLibraryID });
			collection7 = await createDataObject('collection', { name: "collection_level_two_21", parentID: collection6.id, libraryID: userLibraryID });
			collection8 = await createDataObject('collection', { name: "collection_level_two_22", parentID: collection6.id, libraryID: userLibraryID });
			search1 = await createDataObject('search', { name: "search_1", libraryID: userLibraryID });
			search2 = await createDataObject('search', { name: "search_2", libraryID: userLibraryID });
			allRows = [feed1, feed2, collection1, collection2, collection3, collection4, collection5, collection6, collection7, collection8, search1, search2];
		});

		beforeEach(async function () {
			// Empty filter and let it settle
			await cv.setFilter("");
		});

		after(async function () {
			await cv.setFilter("");
		});

		for (let type of ['collection', 'search', 'feed']) {
			 
			it(`should show only ${type} matching the filter`, async function () {
				await cv.setFilter(type);
				let displayedRowNames = cv._rows.filter(row => row.type == type).map(row => row.getName());
				let expectedRowNames = allRows.filter(row => row.name.includes(type)).map(row => row.name);
				assert.sameMembers(displayedRowNames, expectedRowNames);
			});
		}

		it('should show non-passing entries whose children pass the filter', async function () {
			await cv.setFilter("three");
			let displayedRowNames = cv._rows.filter(row => row.type == "collection").map(row => row.ref.name);
			let expectedNames = [
				"collection_level_one",
				"collection_level_two_1",
				"collection_level_three_1",
				"collection_level_three_11"
			];
			assert.sameMembers(displayedRowNames, expectedNames);
		});

		it('should not move focus from selected collection during filtering', async function () {
			await cv.selectByID("C" + collection5.id);
			await cv.setFilter("three");
			let focusedRow = cv.getRow(cv.selection.focused);
			assert.equal(focusedRow.id, "C" + collection5.id);
			await cv.setFilter("two");
			focusedRow = cv.getRow(cv.selection.focused);
			assert.equal(focusedRow.id, "C" + collection5.id);
		});

		it('should collapse collections collapsed before filtering', async function () {
			// Collapse top level collections 1 and 6
			for (let c of [collection1, collection6]) {
				let index = cv.getRowIndexByID("C" + c.id);
				let row = cv.getRow(index);
				if (row.isOpen) {
					await cv.toggleOpenState(index);
				}
			}
			
			await cv.setFilter(collection5.name);

			// Collection 1 and 2 have a matching child, so they are opened
			let colOneRow = cv.getRow(cv.getRowIndexByID("C" + collection1.id));
			assert.isTrue(colOneRow.isOpen);
			let colTwoRow = cv.getRow(cv.getRowIndexByID("C" + collection2.id));
			assert.isTrue(colTwoRow.isOpen);
			// Collection 6 has no matches, it is filtered out
			let colSixRowIndex = cv.getRowIndexByID("C" + collection6.id);
			assert.isFalse(colSixRowIndex);

			// Empty the filter
			await cv.setFilter("");

			// Collection 1 and 6 should remain collapsed as before filtering
			colOneRow = cv.getRow(cv.getRowIndexByID("C" + collection1.id));
			assert.isFalse(colOneRow.isOpen);
			let colSixRow = cv.getRow(cv.getRowIndexByID("C" + collection6.id));
			assert.isFalse(colSixRow.isOpen);
		});

		for (let type of ['collection', 'search']) {
			 
			it(`should only hide ${type} if it's renamed to not match the filter`, async function () {
				await cv.setFilter(type);
				let objectToSelect = type == 'collection' ? collection5 : search2;
				objectToSelect.name += "_updated";
				await objectToSelect.saveTx();
				let displayedRowNames = cv._rows.map(row => row.getName());
				assert.include(displayedRowNames, objectToSelect.name);

				objectToSelect.name = "not_matching_filter";
				await objectToSelect.saveTx();
				displayedRowNames = cv._rows.map(row => row.getName());
				assert.notInclude(displayedRowNames, objectToSelect.name);
			});
		}

		for (let type of ['collection', 'search']) {
			 
			it(`should only add ${type} if its name matches the filter`, async function () {
				await cv.setFilter(type);
				let newCollection = await createDataObject(type, { name: `new_${type}`, libraryID: userLibraryID });

				let displayedRowNames = cv._rows.map(row => row.ref.name);
				assert.include(displayedRowNames, newCollection.name);

				newCollection = await createDataObject(type, { name: `not_passing_${type.substring(1)}`, libraryID: userLibraryID });

				displayedRowNames = cv._rows.map(row => row.ref.name);
				assert.notInclude(displayedRowNames, newCollection.name);
			});
		}

		it(`should focus selected collection on Enter if it matches filter`, async function () {
			await cv.selectByID(`C${collection3.id}`);
			win.document.getElementById("zotero-collections-search").value = "_2";
			await cv.setFilter("_2");
			win.document.getElementById("zotero-collections-search").dispatchEvent(keyboardClick("Enter"));
			assert.equal(cv.getSelectedCollection(true), collection3.id);
			assert.equal(win.document.activeElement.id, 'collection-tree');
		});

		it(`should focus first matching collection on Enter if selected collection does not match filter`, async function () {
			await cv.selectByID(`C${collection2.id}`);
			win.document.getElementById("zotero-collections-search").focus();
			win.document.getElementById("zotero-collections-search").value = "_2";
			await cv.setFilter("_2");
			win.document.getElementById("zotero-collections-search").dispatchEvent(keyboardClick("Enter"));
			// Wait for the selection to go through
			await Zotero.Promise.delay(100);
			assert.equal(cv.getSelectedCollection(true), collection3.id);
			assert.equal(win.document.activeElement.id, 'collection-tree');
		});

		it(`should not move focus from collection filter on Enter if no rows pass the filter`, async function () {
			await cv.selectByID(`C${collection3.id}`);
			win.document.getElementById("zotero-collections-search").focus();
			win.document.getElementById("zotero-collections-search").value = "Not matching anything";
			await cv.setFilter("Not matching anything");
			win.document.getElementById("zotero-collections-search").dispatchEvent(keyboardClick("Enter"));
			assert.equal(win.document.activeElement.id, 'zotero-collections-search');
		});

		it(`should skip context rows on arrow up/down`, async function () {
			await cv.selectByID(`C${collection2.id}`);
			await cv.setFilter("_2");
			await cv.focusFirstMatchingRow();
			// Skip collection6 that does not match on the way up and down
			for (let col of [collection3, collection7, collection8]) {
				assert.equal(cv.getSelectedCollection(true), col.id);
				await cv.focusNextMatchingRow(cv.selection.focused);
			}
			await cv.selectByID(`C${collection8.id}`);
			for (let col of [collection8, collection7, collection3]) {
				assert.equal(cv.getSelectedCollection(true), col.id);
				await cv.focusNextMatchingRow(cv.selection.focused, true);
			}
		});

		it(`should clear filter on Escape from collectionTree`, async function () {
			await cv.selectByID(`C${collection2.id}`);
			let colTree = win.document.getElementById('collection-tree');
			await cv.setFilter("_2");
			cv.focusFirstMatchingRow();
			colTree.dispatchEvent(keyboardClick("Escape"));
			assert.equal(cv._filter, "");
			assert.equal(cv.getSelectedCollection(true), collection2.id);
		});
	});

	describe("#setHighlightedRows", function () {
		it("should not invalidate collectionTree if nothing was changed", async function () {
			let collection = await createDataObject('collection');
			let invalidateSpy = sinon.spy(cv.tree, 'invalidate');
			await cv.setHighlightedRows(['C' + collection.id]);
			await cv.setHighlightedRows(['C' + collection.id]);
			// Only the first call should invalidate the tree
			assert.isTrue(invalidateSpy.calledOnce);
			invalidateSpy.restore();
		});
	})
})
