"use strict";

describe("Zotero.ItemTree", function() {
	var win, zp, cv, itemsView;
	var existingItemID;
	var existingItemID2;
	
	// Load Zotero pane and select library
	before(async function () {
		win = await loadZoteroPane();
		zp = win.ZoteroPane;
		cv = zp.collectionsView;
		
		var item1 = await createDataObject('item', { setTitle: true });
		existingItemID = item1.id;
		var item2 = await createDataObject('item');
		existingItemID2 = item2.id;
	});
	beforeEach(async function () {
		await selectLibrary(win);
		itemsView = zp.itemsView;
		itemsView._columnsId = null;
	});
	after(function () {
		win.close();
	});
	
	it("shouldn't show items in trash in library root", async function () {
		var item = await createDataObject('item', { title: "foo" });
		var itemID = item.id;
		item.deleted = true;
		await item.saveTx();
		assert.isFalse(itemsView.getRowIndexByID(itemID));
	});
	
	describe("when performing a quick search", function () {
		let quicksearch;
		
		before(() => {
			quicksearch = win.document.getElementById('zotero-tb-search-textbox');
		});
		after(async () => {
			quicksearch.value = "";
			quicksearch.doCommand();
			await itemsView._refreshPromise;
		});
		
		describe("when issuing a Select All command", function () {
			let parentItem, match;
			let selectAllEvent = { key: 'a' };
			
			before(async function () {
				parentItem = await createDataObject('item');
				match = await importFileAttachment('test.png', { title: 'find-me', parentItemID: parentItem.id });
				await importFileAttachment('test.png', { title: 'not-a-result', parentItemID: parentItem.id });
				if (Zotero.isMac) {
					selectAllEvent.metaKey = true;
				}
				else {
					selectAllEvent.ctrlKey = true;
				}
			});
			
			after(async function() {
				await parentItem.erase();
			});
			
			it("should not select non-matching children", async function () {
				quicksearch.value = match.getField('title');
				quicksearch.doCommand();
				await itemsView._refreshPromise;
				itemsView.tree._onKeyDown(selectAllEvent);

				var selected = itemsView.getSelectedItems(true);
				assert.lengthOf(selected, 1);
				assert.equal(selected[0], match.id);
			});

			it("should expand collapsed parents with matching children", async function () {
				itemsView.collapseAllRows();
				var selected = itemsView.getSelectedItems(true);
				// After collapse the parent item is selected
				assert.lengthOf(selected, 1);
				assert.equal(selected[0], parentItem.id);
				
				itemsView.tree._onKeyDown(selectAllEvent);
				selected = itemsView.getSelectedItems(true);
				assert.lengthOf(selected, 1);
				assert.equal(selected[0], match.id);
			});
		});
		
		describe("when dragging attachments", function () {
			let parentItem, childItem;
			before(async () => {
				parentItem = await createDataObject('item', { title: "match-parent" });
				childItem = await importFileAttachment('test.png', { title: 'match-child', parentItemID: parentItem.id });
			});
			
			it("should display a child attachment when it is dragged into top level if it matches the search", async function () {
				childItem.parentID = parentItem.id;
				await childItem.save();
				
				quicksearch.value = "match";
				quicksearch.doCommand();
				
				await itemsView._refreshPromise;
				assert.lengthOf(itemsView._rows, 2);
				assert.equal(itemsView.getRow(0).id, parentItem.id);
				assert.equal(itemsView.getRow(1).id, childItem.id);
				assert.equal(itemsView.getRow(1).level, 1);
				
				// The drop effectively does this
				childItem.parentID = false;
				await childItem.save();
				await itemsView._refreshPromise;
				
				assert.lengthOf(itemsView._rows, 2);
				assert.equal(itemsView.getRow(0).id, childItem.id);
				assert.equal(itemsView.getRow(0).level, 0);
				assert.equal(itemsView.getRow(1).id, parentItem.id);
			});
			
			it("should display a child attachment when it is dragged onto a parent item if it matches the search", async function () {
				childItem.parentID = false;
				await childItem.save();
				
				quicksearch.value = "match";
				quicksearch.doCommand();
				
				await itemsView._refreshPromise;
				assert.lengthOf(itemsView._rows, 2);
				assert.equal(itemsView.getRow(0).id, childItem.id);
				assert.equal(itemsView.getRow(0).level, 0);
				assert.equal(itemsView.getRow(1).id, parentItem.id);
				
				// The drop effectively does this
				childItem.parentID = parentItem.id;
				await childItem.save();
				await itemsView._refreshPromise;
				
				assert.lengthOf(itemsView._rows, 2);
				assert.equal(itemsView.getRow(0).id, parentItem.id);
				assert.equal(itemsView.getRow(1).id, childItem.id);
				assert.equal(itemsView.getRow(1).level, 1);
			});
		});
	});
	
	describe("#selectItem()", function () {
		/**
		 * Don't hang if the pane's item-select handler is never triggered due to the item already
		 * being selected
		 */
		it("should return if item is already selected", async function () {
			var numSelected = await itemsView.selectItem(existingItemID);
			assert.equal(numSelected, 1);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], existingItemID);
			numSelected = await itemsView.selectItem(existingItemID);
			assert.equal(numSelected, 1);
			selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], existingItemID);
		});
	});
	
	describe("#selectItems()", function () {
		/**
		 * Don't hang if the pane's item-select handler is never triggered due to the items already
		 * being selected
		 */
		it("should return if all items are already selected", async function () {
			var itemIDs = [existingItemID, existingItemID2];
			var numSelected = await itemsView.selectItems(itemIDs);
			assert.equal(numSelected, 2);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 2);
			assert.sameMembers(selected, itemIDs);
			numSelected = await itemsView.selectItems(itemIDs);
			assert.equal(numSelected, 2);
			selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 2);
			assert.sameMembers(selected, itemIDs);
		});
		
		
		it("should expand parent items to select children", async function () {
			var item1 = await createDataObject('item');
			var item2 = await createDataObject('item');
			var item3 = await createDataObject('item');
			var note1 = await createDataObject('item', { itemType: 'note', parentID: item1.id });
			var note2 = await createDataObject('item', { itemType: 'note', parentID: item2.id });
			var note3 = await createDataObject('item', { itemType: 'note', parentID: item3.id });
			
			var toSelect = [note1.id, note2.id, note3.id];
			itemsView.collapseAllRows();

			var numSelected = await itemsView.selectItems(toSelect);
			assert.equal(numSelected, 3);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 3);
			assert.sameMembers(selected, toSelect);
			
			// Again with the ids given in reverse order
			itemsView.collapseAllRows();
			toSelect = toSelect.reverse();
			var numSelected = await itemsView.selectItems(toSelect);
			assert.equal(numSelected, 3);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 3);
			assert.sameMembers(selected, toSelect);
		});
	});
	
	describe("#getCellText()", function () {
		it("should return new value after edit", function* () {
			var str = Zotero.Utilities.randomString();
			var item = yield createDataObject('item', { title: str });
			var row = itemsView.getRowIndexByID(item.id);
			assert.equal(itemsView.getCellText(row, 'title'), str);
			yield modifyDataObject(item);
			assert.notEqual(itemsView.getCellText(row, 'title'), str);
		})
	})
	
	describe.skip("#sort()", function () {
		it("should ignore invalid secondary-sort field", async function () {
			await createDataObject('item', { title: 'A' });
			await createDataObject('item', { title: 'A' });
			
			// Set invalid field as secondary sort for title
			Zotero.Prefs.set('secondarySort.title', 'invalidField');
			
			// Sort by title
			var colIndex = itemsView.tree._getColumns().findIndex(column => column.dataKey == 'title');
			await itemsView.tree._columns.toggleSort(colIndex);
			
			var e = await getPromiseError(zp.itemsView.sort());
			assert.isFalse(e);
			assert.isUndefined(Zotero.Prefs.get('secondarySort.title'));
		});
		
		it("should ignore invalid fallback-sort field", async function () {
			Zotero.Prefs.clear('fallbackSort');
			var originalFallback = Zotero.Prefs.get('fallbackSort');
			Zotero.Prefs.set('fallbackSort', 'invalidField,' + originalFallback);
			
			// Sort by title
			var colIndex = itemsView.tree._getColumns().findIndex(column => column.dataKey == 'title');
			await itemsView.tree._columns.toggleSort(colIndex);
			
			var e = await getPromiseError(zp.itemsView.sort());
			assert.isFalse(e);
			assert.equal(Zotero.Prefs.get('fallbackSort'), originalFallback);
		});
	});
	
	describe("#notify()", function () {
		beforeEach(function () {
			sinon.spy(win.ZoteroPane, "itemSelected");
		})
		
		afterEach(function () {
			win.ZoteroPane.itemSelected.restore();
		})
		
		it("should select a new item", async function () {
			let selectPromise = itemsView.waitForSelect();
			itemsView.selection.clearSelection();
			assert.lengthOf(itemsView.getSelectedItems(), 0);

			await selectPromise;
			assert.equal(win.ZoteroPane.itemSelected.callCount, 1);
			
			// Create item
			var item = new Zotero.Item('book');
			var id = await item.saveTx();
			
			// New item should be selected
			var selected = itemsView.getSelectedItems();
			assert.lengthOf(selected, 1);
			assert.equal(selected[0].id, id);
			
			// Item should have been selected once
			assert.equal(win.ZoteroPane.itemSelected.callCount, 2);
			await assert.eventually.ok(win.ZoteroPane.itemSelected.returnValues[1]);
		});
		
		it("shouldn't select a new item if skipNotifier is passed", function* () {
			// Select existing item
			yield itemsView.selectItem(existingItemID);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], existingItemID);
			
			// Reset call count on spy
			win.ZoteroPane.itemSelected.resetHistory();
			
			// Create item with skipNotifier flag
			var item = new Zotero.Item('book');
			var id = yield item.saveTx({
				skipNotifier: true
			});
			
			// No select events should have occurred
			assert.equal(win.ZoteroPane.itemSelected.callCount, 0);
			
			// Existing item should still be selected
			selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], existingItemID);
		});
		
		it("shouldn't select a new item if skipSelect is passed", async function () {
			// Select existing item
			await itemsView.selectItem(existingItemID);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], existingItemID);
			
			// Reset call count on spy
			win.ZoteroPane.itemSelected.resetHistory();
			
			// Create item with skipSelect flag
			var item = new Zotero.Item('book');
			var id = await item.saveTx({
				skipSelect: true
			});
			
			// itemSelected should have been called once (from 'selectEventsSuppressed = false'
			// in notify()) as a no-op
			assert.equal(win.ZoteroPane.itemSelected.callCount, 1);
			assert.isFalse(win.ZoteroPane.itemSelected.returnValues[0].value());
			
			// Existing item should still be selected
			selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], existingItemID);
		});
		
		it("should clear search and select new item if non-matching quick search is active", async function () {
			await createDataObject('item');
			
			var quicksearch = win.document.getElementById('zotero-tb-search');
			quicksearch.searchTextbox.value = Zotero.randomString();
			quicksearch.doCommand();
			await itemsView._refreshPromise;
			
			assert.equal(itemsView.rowCount, 0);
			
			// Create item
			var item = await createDataObject('item');
			
			assert.isAbove(itemsView.rowCount, 0);
			assert.equal(quicksearch.value, '');
			
			// New item should be selected
			var selected = itemsView.getSelectedItems();
			assert.lengthOf(selected, 1);
			assert.equal(selected[0].id, item.id);
		});
		
		it("shouldn't clear quicksearch if skipSelect is passed", function* () {
			var searchString = Zotero.Items.get(existingItemID).getField('title');
			
			yield createDataObject('item');
			
			var quicksearch = win.document.getElementById('zotero-tb-search-textbox');
			quicksearch.value = searchString;
			quicksearch.doCommand();
			yield itemsView._refreshPromise;
			
			assert.equal(itemsView.rowCount, 1);
			
			// Create item with skipSelect flag
			var item = new Zotero.Item('book');
			var ran = Zotero.Utilities.randomString();
			item.setField('title', ran);
			var id = yield item.saveTx({
				skipSelect: true
			});
			
			assert.equal(itemsView.rowCount, 1);
			assert.equal(quicksearch.value, searchString);
			
			// Clear search
			quicksearch.value = "";
			quicksearch.doCommand();
			yield itemsView._refreshPromise;
		});
		
		it("shouldn't change selection outside of trash if new trashed item is created with skipSelect", function* () {
			yield selectLibrary(win);
			yield waitForItemsLoad(win);
			
			itemsView.selection.clearSelection();
			
			var item = createUnsavedDataObject('item');
			item.deleted = true;
			var id = yield item.saveTx({
				skipSelect: true
			});
			
			// Nothing should be selected
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 0);
		})
		
		it("shouldn't select a modified item", function* () {
			// Create item
			var item = new Zotero.Item('book');
			var id = yield item.saveTx();
			
			itemsView.selection.clearSelection();
			assert.lengthOf(itemsView.getSelectedItems(), 0);
			// Reset call count on spy
			win.ZoteroPane.itemSelected.resetHistory();
			
			// Modify item
			item.setField('title', 'no select on modify');
			yield item.saveTx();
			
			// itemSelected should have been called once (from 'selectEventsSuppressed = false'
			// in notify()) as a no-op
			assert.equal(win.ZoteroPane.itemSelected.callCount, 1);
			assert.isFalse(win.ZoteroPane.itemSelected.returnValues[0].value());
			
			// Modified item should not be selected
			assert.lengthOf(itemsView.getSelectedItems(), 0);
		});
		
		it("should maintain selection on a selected modified item", function* () {
			// Create item
			var item = new Zotero.Item('book');
			var id = yield item.saveTx();
			
			yield itemsView.selectItem(id);
			var selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], id);
			
			// Reset call count on spy
			win.ZoteroPane.itemSelected.resetHistory();
			
			// Modify item
			item.setField('title', 'maintain selection on modify');
			yield item.saveTx();
			
			// itemSelected should have been called once (from 'selectEventsSuppressed = false'
			// in notify()) as a no-op
			assert.equal(win.ZoteroPane.itemSelected.callCount, 1);
			assert.isFalse(win.ZoteroPane.itemSelected.returnValues[0].value());
			
			// Modified item should still be selected
			selected = itemsView.getSelectedItems(true);
			assert.lengthOf(selected, 1);
			assert.equal(selected[0], id);
		});
		
		it("should reselect the same row when an item is removed", function* () {
			var collection = yield createDataObject('collection');
			yield waitForItemsLoad(win);
			itemsView = zp.itemsView;
			
			var items = [];
			var num = 6;
			for (let i = 0; i < num; i++) {
				let item = createUnsavedDataObject('item', { title: "" + i });
				item.addToCollection(collection.id);
				yield item.saveTx();
				items.push(item);
			}
			assert.equal(itemsView.rowCount, num);
			
			// Select the third item in the list
			itemsView.selection.select(2);
			
			// Remove item
			var treeRow = itemsView.getRow(2);
			yield Zotero.DB.executeTransaction(async function () {
				await collection.removeItems([treeRow.ref.id]);
			}.bind(this));
			
			// Selection should stay on third row
			assert.equal(itemsView.selection.focused, 2);
			
			// Delete item
			var treeRow = itemsView.getRow(2);
			yield treeRow.ref.eraseTx();
			
			// Selection should stay on third row
			assert.equal(itemsView.selection.focused, 2);
			
			yield Zotero.Items.erase(items.map(item => item.id));
		});
		
		it("shouldn't select sibling on attachment erase if attachment wasn't selected", function* () {
			var item = yield createDataObject('item');
			var att1 = yield importFileAttachment('test.png', { title: 'A', parentItemID: item.id });
			var att2 = yield importFileAttachment('test.png', { title: 'B', parentItemID: item.id });
			yield zp.itemsView.selectItem(att2.id); // expand
			yield zp.itemsView.selectItem(item.id);
			yield att1.eraseTx();
			assert.sameMembers(zp.itemsView.getSelectedItems(true), [item.id]);
		});
		
		it("should keep first visible item in view when other items are added with skipSelect and nothing in view is selected", function* () {
			var collection = yield createDataObject('collection');
			yield waitForItemsLoad(win);
			itemsView = zp.itemsView;
			
			var treebox = itemsView._treebox;
			var numVisibleRows = treebox.getLastVisibleRow() - treebox.getFirstVisibleRow();
			
			// Get a numeric string left-padded with zeroes
			function getTitle(i, max) {
				return new String(new Array(max + 1).join(0) + i).slice(-1 * max);
			}
			
			var num = numVisibleRows + 10;
			yield Zotero.DB.executeTransaction(async function () {
				for (let i = 0; i < num; i++) {
					let title = getTitle(i, num);
					let item = createUnsavedDataObject('item', { title });
					item.addToCollection(collection.id);
					await item.save();
				}
			}.bind(this));
			
			// Scroll halfway
			treebox.scrollToRow(Math.round(num / 2) - Math.round(numVisibleRows / 2));
			
			var firstVisibleItemID = itemsView.getRow(treebox.getFirstVisibleRow()).ref.id;
			
			// Add one item at the beginning
			var item = createUnsavedDataObject(
				'item', { title: getTitle(0, num), collections: [collection.id] }
			);
			yield item.saveTx({
				skipSelect: true
			});
			// Then add a few more in a transaction
			yield Zotero.DB.executeTransaction(async function () {
				for (let i = 0; i < 3; i++) {
					var item = createUnsavedDataObject(
						'item', { title: getTitle(0, num), collections: [collection.id] }
					);
					await item.save({
						skipSelect: true
					});
				}
			}.bind(this));
			
			// Make sure the same item is still in the first visible row
			assert.equal(itemsView.getRow(treebox.getFirstVisibleRow()).ref.id, firstVisibleItemID);
		});
		
		it.skip("should keep first visible selected item in position when other items are added with skipSelect", function* () {
			var collection = yield createDataObject('collection');
			yield waitForItemsLoad(win);
			itemsView = zp.itemsView;
			
			var treebox = itemsView._treebox;
			var numVisibleRows = treebox.getLastVisibleRow() - treebox.getFirstVisibleRow();
			
			// Get a numeric string left-padded with zeroes
			function getTitle(i, max) {
				return new String(new Array(max + 1).join(0) + i).slice(-1 * max);
			}
			
			var num = numVisibleRows + 10;
			yield Zotero.DB.executeTransaction(async function () {
				for (let i = 0; i < num; i++) {
					let title = getTitle(i, num);
					let item = createUnsavedDataObject('item', { title });
					item.addToCollection(collection.id);
					await item.save();
				}
			}.bind(this));
			
			// Scroll halfway
			treebox.scrollToRow(Math.round(num / 2) - Math.round(numVisibleRows / 2));
			
			// Select an item
			itemsView.selection.select(Math.round(num / 2));
			var selectedItem = itemsView.getSelectedItems()[0];
			var offset = itemsView.getRowIndexByID(selectedItem.treeViewID) - treebox.getFirstVisibleRow();
			
			// Add one item at the beginning
			var item = createUnsavedDataObject(
				'item', { title: getTitle(0, num), collections: [collection.id] }
			);
			yield item.saveTx({
				skipSelect: true
			});
			// Then add a few more in a transaction
			yield Zotero.DB.executeTransaction(async function () {
				for (let i = 0; i < 3; i++) {
					var item = createUnsavedDataObject(
						'item', { title: getTitle(0, num), collections: [collection.id] }
					);
					await item.save({
						skipSelect: true
					});
				}
			}.bind(this));
			
			// Make sure the selected item is still at the same position
			assert.equal(itemsView.getSelectedItems()[0], selectedItem);
			var newOffset = itemsView.getRowIndexByID(selectedItem.treeViewID) - treebox.getFirstVisibleRow();
			assert.equal(newOffset, offset);
		});
		
		it("shouldn't scroll items list if at top when other items are added with skipSelect", function* () {
			var collection = yield createDataObject('collection');
			yield waitForItemsLoad(win);
			itemsView = zp.itemsView;
			
			var treebox = itemsView._treebox;
			var numVisibleRows = treebox.getLastVisibleRow() - treebox.getFirstVisibleRow();
			
			// Get a numeric string left-padded with zeroes
			function getTitle(i, max) {
				return new String(new Array(max + 1).join(0) + i).slice(-1 * max);
			}
			
			var num = numVisibleRows + 10;
			yield Zotero.DB.executeTransaction(async function () {
				// Start at "*1" so we can add items before
				for (let i = 1; i < num; i++) {
					let title = getTitle(i, num);
					let item = createUnsavedDataObject('item', { title });
					item.addToCollection(collection.id);
					await item.save();
				}
			}.bind(this));
			
			// Scroll to top
			treebox.scrollToRow(0);
			
			// Add one item at the beginning
			var item = createUnsavedDataObject(
				'item', { title: getTitle(0, num), collections: [collection.id] }
			);
			yield item.saveTx({
				skipSelect: true
			});
			// Then add a few more in a transaction
			yield Zotero.DB.executeTransaction(async function () {
				for (let i = 0; i < 3; i++) {
					var item = createUnsavedDataObject(
						'item', { title: getTitle(0, num), collections: [collection.id] }
					);
					await item.save({
						skipSelect: true
					});
				}
			}.bind(this));
			
			// Make sure the first row is still at the top
			assert.equal(treebox.getFirstVisibleRow(), 0);
		});
		
		it("should update search results when items are added", function* () {
			var search = yield createDataObject('search');
			var title = search.getConditions()[0].value;
			
			yield waitForItemsLoad(win);
			assert.equal(zp.itemsView.rowCount, 0);
			
			// Add an item matching search
			var item = yield createDataObject('item', { title });
			
			yield waitForItemsLoad(win);
			assert.equal(zp.itemsView.rowCount, 1);
			assert.equal(zp.itemsView.getRowIndexByID(item.id), 0);
		});
		
		it("should re-sort search results when an item is modified", async function () {
			var search = await createDataObject('search');
			itemsView = zp.itemsView;
			var title = search.getConditions()[0].value;
			
			var item1 = await createDataObject('item', { title: title + " 1" });
			var item2 = await createDataObject('item', { title: title + " 3" });
			var item3 = await createDataObject('item', { title: title + " 5" });
			var item4 = await createDataObject('item', { title: title + " 7" });

			// Sort by title
			var colIndex = itemsView.tree._getColumns().findIndex(column => column.dataKey == 'firstCreator');
			await itemsView.tree._columns.toggleSort(colIndex);
			await waitForItemsLoad(win);
			colIndex = itemsView.tree._getColumns().findIndex(column => column.dataKey == 'title');
			await itemsView.tree._columns.toggleSort(colIndex);
			await waitForItemsLoad(win);
			
			// Check initial sort order
			assert.equal(itemsView.getRow(0).ref.getField('title'), title + " 1");
			assert.equal(itemsView.getRow(3).ref.getField('title'), title + " 7");
			
			// Set first row to title that should be sorted in the middle
			itemsView.getRow(3).ref.setField('title', title + " 4");
			await itemsView.getRow(3).ref.saveTx();
			
			assert.equal(itemsView.getRow(0).ref.getField('title'), title + " 1");
			assert.equal(itemsView.getRow(1).ref.getField('title'), title + " 3");
			assert.equal(itemsView.getRow(2).ref.getField('title'), title + " 4");
			assert.equal(itemsView.getRow(3).ref.getField('title'), title + " 5");
		});
		
		it("should update search results when search conditions are changed", function* () {
			var search = createUnsavedDataObject('search');
			var title1 = Zotero.Utilities.randomString();
			var title2 = Zotero.Utilities.randomString();
			search.fromJSON({
				name: "Test",
				conditions: [
					{
						condition: "title",
						operator: "is",
						value: title1
					}
				]
			});
			yield search.saveTx();
			
			yield waitForItemsLoad(win);
			
			// Add an item that doesn't match search
			var item = yield createDataObject('item', { title: title2 });
			yield waitForItemsLoad(win);
			assert.equal(zp.itemsView.rowCount, 0);
			
			// Modify conditions to match item
			search.removeCondition(0);
			search.addCondition("title", "is", title2);
			yield search.saveTx();
			
			yield waitForItemsLoad(win);
			
			assert.equal(zp.itemsView.rowCount, 1);
		});
		
		it("should remove items from Unfiled Items when added to a collection", async function () {
			var userLibraryID = Zotero.Libraries.userLibraryID;
			var collection = await createDataObject('collection');
			var item = await createDataObject('item', { title: "Unfiled Item" });
			await zp.setVirtual(userLibraryID, 'unfiled', true, true);
			assert.equal(zp.getCollectionTreeRow().id, 'U' + userLibraryID);
			await waitForItemsLoad(win);
			assert.isNumber(zp.itemsView.getRowIndexByID(item.id));
			await Zotero.DB.executeTransaction(async function () {
				await collection.addItem(item.id);
			});
			assert.isFalse(zp.itemsView.getRowIndexByID(item.id));
		});
		
		describe("Trash", function () {
			it("should remove untrashed parent item when last trashed child is deleted", function* () {
				var userLibraryID = Zotero.Libraries.userLibraryID;
				var item = yield createDataObject('item');
				var note = yield createDataObject(
					'item', { itemType: 'note', parentID: item.id, deleted: true }
				);
				yield cv.selectByID("T" + userLibraryID);
				yield waitForItemsLoad(win);
				assert.isNumber(zp.itemsView.getRowIndexByID(item.id));
				var promise = waitForDialog();
				yield zp.emptyTrash();
				yield promise;
				assert.equal(zp.itemsView.rowCount, 0);
			});
		});
		
		describe("My Publications", function () {
			before(async function () {
				var libraryID = Zotero.Libraries.userLibraryID;
				
				var s = new Zotero.Search;
				s.libraryID = libraryID;
				s.addCondition('publications', 'true');
				var ids = await s.search();
				
				await Zotero.Items.erase(ids);
				
				await zp.collectionsView.selectByID("P" + libraryID);
				await waitForItemsLoad(win);
				
				// Make sure we're showing the intro text
				var messageElem = win.document.querySelector('.items-tree-message');
				assert.notEqual(messageElem.style.display, 'none');
			});
			
			it("should replace My Publications intro text with items list on item add", async function () {
				var item = await createDataObject('item');
				
				await zp.collectionsView.selectByID("P" + item.libraryID);
				await waitForItemsLoad(win);
				
				item.inPublications = true;
				await item.saveTx();

				var messageElem = win.document.querySelector('.items-tree-message');
				assert.equal(messageElem.style.display, 'none');
				
				assert.isNumber(itemsView.getRowIndexByID(item.id));
			});
			
			it("should add new item to My Publications items list", function* () {
				var item1 = createUnsavedDataObject('item');
				item1.inPublications = true;
				yield item1.saveTx();
				
				yield zp.collectionsView.selectByID("P" + item1.libraryID);
				yield waitForItemsLoad(win);

				var messageElem = win.document.querySelector('.items-tree-message');
				assert.equal(messageElem.style.display, 'none');
				
				var item2 = createUnsavedDataObject('item');
				item2.inPublications = true;
				yield item2.saveTx();
				
				assert.isNumber(itemsView.getRowIndexByID(item2.id));
			});
			
			it("should add modified item to My Publications items list", function* () {
				var item1 = createUnsavedDataObject('item');
				item1.inPublications = true;
				yield item1.saveTx();
				var item2 = yield createDataObject('item');
				
				yield zp.collectionsView.selectByID("P" + item1.libraryID);
				yield waitForItemsLoad(win);

				var messageElem = win.document.querySelector('.items-tree-message');
				assert.equal(messageElem.style.display, 'none');
				
				assert.isFalse(itemsView.getRowIndexByID(item2.id));
				
				item2.inPublications = true;
				yield item2.saveTx();
				
				assert.isNumber(itemsView.getRowIndexByID(item2.id));
			});
			
			it("should show Show/Hide button for imported file attachment", function* () {
				var item = yield createDataObject('item', { inPublications: true });
				var attachment = yield importFileAttachment('test.png', { parentItemID: item.id });
				
				yield zp.collectionsView.selectByID("P" + item.libraryID);
				yield waitForItemsLoad(win);
				
				yield itemsView.selectItem(attachment.id);
				yield Zotero.Promise.delay();
				
				var box = win.document.getElementById('zotero-item-pane-my-publications-button');
				assert.isFalse(box.hidden);
			});
			
			it("shouldn't show Show/Hide button for linked file attachment", function* () {
				var item = yield createDataObject('item', { inPublications: true });
				var attachment = yield Zotero.Attachments.linkFromFile({
					file: OS.Path.join(getTestDataDirectory().path, 'test.png'),
					parentItemID: item.id
				});
				
				yield zp.collectionsView.selectByID("P" + item.libraryID);
				yield waitForItemsLoad(win);
				
				yield itemsView.selectItem(attachment.id);
				
				var box = win.document.getElementById('zotero-item-pane-my-publications-button');
				// box is not created if it shouldn't show
				assert.isNull(box);
			});
		});
	})
	
	
	describe("#onDrop()", function () {
		var httpd;
		var port = 16213;
		var baseURL = `http://localhost:${port}/`;
		var pdfFilename = "test.pdf";
		var pdfURL = baseURL + pdfFilename;
		var pdfPath;
		
		function drop(index, orient, dataTransfer) {
			Zotero.DragDrop.currentOrientation = orient;
			var event = { dataTransfer };
			// On macOS, ItemTree checks modifier keys, not just the dropEffect
			if (Zotero.isMac
					&& dataTransfer.types.contains('application/x-moz-file')) {
				switch (dataTransfer.dropEffect) {
					case 'link':
						event.metaKey = true;
						event.altKey = true;
						break;
					
					case 'move':
						event.metaKey = true;
						event.altKey = false;
						break;
					
					default:
						event.metaKey = false;
						event.altKey = false;
				}
			}
			return itemsView.onDrop(event, index);
		}
		
		// Serve a PDF to test URL dragging
		before(function () {
			Components.utils.import("resource://zotero-unit/httpd.js");
			httpd = new HttpServer();
			httpd.start(port);
			var file = getTestDataDirectory();
			file.append(pdfFilename);
			pdfPath = file.path;
			httpd.registerFile("/" + pdfFilename, file);
		});
		
		beforeEach(() => {
			// Don't run recognize on every file
			Zotero.Prefs.set('autoRecognizeFiles', false);
			Zotero.Prefs.clear('autoRenameFiles');
			Zotero.Prefs.clear('autoRenameFiles.linked');
		});
		
		after(function* () {
			var defer = new Zotero.Promise.defer();
			httpd.stop(() => defer.resolve());
			yield defer.promise;
			
			Zotero.Prefs.clear('autoRecognizeFiles');
			Zotero.Prefs.clear('autoRenameFiles');
			Zotero.Prefs.clear('autoRenameFiles.linked');
		});
		
		it("should move a child item from one item to another", function* () {
			var collection = yield createDataObject('collection');
			yield waitForItemsLoad(win);
			var item1 = yield createDataObject('item', { title: "A", collections: [collection.id] });
			var item2 = yield createDataObject('item', { title: "B", collections: [collection.id] });
			var item3 = yield createDataObject('item', { itemType: 'note', parentID: item1.id });
			
			yield itemsView.selectItem(item3.id);
			
			var promise = itemsView.waitForSelect();
			
			drop(itemsView.getRowIndexByID(item2.id), 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'zotero/item';
					}
				},
				getData: function (type) {
					if (type == 'zotero/item') {
						return item3.id + "";
					}
				},
				mozItemCount: 1
			});
			
			yield promise;
			
			// Old parent should be empty
			assert.isFalse(itemsView.isContainerOpen(itemsView.getRowIndexByID(item1.id)));
			assert.isTrue(itemsView.isContainerEmpty(itemsView.getRowIndexByID(item1.id)));
			
			// New parent should be open
			assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(item2.id)));
			assert.isFalse(itemsView.isContainerEmpty(itemsView.getRowIndexByID(item2.id)));
		});
		
		it("should move a child item from last item in list to another", function* () {
			var collection = yield createDataObject('collection');
			yield waitForItemsLoad(win);
			var item1 = yield createDataObject('item', { title: "A", collections: [collection.id] });
			var item2 = yield createDataObject('item', { title: "B", collections: [collection.id] });
			var item3 = yield createDataObject('item', { itemType: 'note', parentID: item2.id });
			
			yield itemsView.selectItem(item3.id);
			
			var promise = itemsView.waitForSelect();
			
			drop(itemsView.getRowIndexByID(item1.id), 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'zotero/item';
					}
				},
				getData: function (type) {
					if (type == 'zotero/item') {
						return item3.id + "";
					}
				},
				mozItemCount: 1
			});
			
			yield promise;
			
			// Old parent should be empty
			assert.isFalse(itemsView.isContainerOpen(itemsView.getRowIndexByID(item2.id)));
			assert.isTrue(itemsView.isContainerEmpty(itemsView.getRowIndexByID(item2.id)));
			
			// New parent should be open
			assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(item1.id)));
			assert.isFalse(itemsView.isContainerEmpty(itemsView.getRowIndexByID(item1.id)));
		});
		
		it("should create a stored top-level attachment when a file is dragged", function* () {
			var file = getTestDataDirectory();
			file.append('test.png');
			
			var promise = itemsView.waitForSelect();
			
			drop(0, -1, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 1,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i == 0) {
						return file;
					}
				}
			})
			
			yield promise;
			// Attachment add triggers multiple notifications and multiple select events
			yield itemsView.waitForSelect();
			var items = itemsView.getSelectedItems();
			var path = yield items[0].getFilePathAsync();
			assert.equal(
				(yield Zotero.File.getBinaryContentsAsync(path)),
				(yield Zotero.File.getBinaryContentsAsync(file))
			);
		});
		
		it("should create a stored top-level attachment when a URL is dragged", function* () {
			var promise = itemsView.waitForSelect();
			
			drop(0, -1, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'text/x-moz-url';
					}
				},
				getData: function (type) {
					if (type == 'text/x-moz-url') {
						return pdfURL;
					}
				},
				mozItemCount: 1,
			})

			yield promise;
			var item = itemsView.getSelectedItems()[0];
			assert.equal(item.getField('url'), pdfURL);
			assert.equal(
				(yield Zotero.File.getBinaryContentsAsync(yield item.getFilePathAsync())),
				(yield Zotero.File.getBinaryContentsAsync(pdfPath))
			);
		});
		
		it("should create a stored child attachment when a URL is dragged", function* () {
			var view = zp.itemsView;
			var parentItem = yield createDataObject('item');
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'text/x-moz-url';
					}
				},
				getData: function (type) {
					if (type == 'text/x-moz-url') {
						return pdfURL;
					}
				},
				mozItemCount: 1,
			})
			
			var itemIDs = yield promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			assert.equal(item.getField('url'), pdfURL);
			assert.equal(
				(yield Zotero.File.getBinaryContentsAsync(yield item.getFilePathAsync())),
				(yield Zotero.File.getBinaryContentsAsync(pdfPath))
			);
		});
		
		it("should automatically retrieve metadata for top-level PDF if pref is enabled", async function () {
			Zotero.Prefs.set('autoRecognizeFiles', true);
			
			var view = zp.itemsView;
			
			var promise = waitForItemEvent('add');
			
			// Fake recognizer response
			Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
			var server = sinon.fakeServer.create();
			server.autoRespond = true;
			setHTTPResponse(
				server,
				ZOTERO_CONFIG.SERVICES_URL,
				{
					method: 'POST',
					url: 'recognizer/recognize',
					status: 200,
					headers: {
						'Content-Type': 'application/json'
					},
					json: {
						title: 'Test',
						authors: []
					}
				}
			);
			
			drop(0, -1, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'text/x-moz-url';
					}
				},
				getData: function (type) {
					if (type == 'text/x-moz-url') {
						return pdfURL;
					}
				},
				mozItemCount: 1,
			})
			
			// Wait for attachment item
			var attachmentIDs = await promise;
			// Wait for attachment item to be moved under new item
			await waitForItemEvent('add');
			await waitForItemEvent('modify');
			await waitForItemEvent('modify');
			
			assert.isFalse(Zotero.Items.get(attachmentIDs[0]).isTopLevelItem());
			
			Zotero.HTTP.mock = null;
		});
		
		it("should automatically retrieve metadata for multiple top-level PDFs if pref is enabled", async function () {
			Zotero.Prefs.set('autoRecognizeFiles', true);
			
			var view = zp.itemsView;
			
			var promise = waitForItemEvent('add');
			var recognizerPromise = waitForRecognizer();
			
			// Fake recognizer response
			Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
			var server = sinon.fakeServer.create();
			server.autoRespond = true;
			setHTTPResponse(
				server,
				ZOTERO_CONFIG.SERVICES_URL,
				{
					method: 'POST',
					url: 'recognizer/recognize',
					status: 200,
					headers: {
						'Content-Type': 'application/json'
					},
					json: {
						title: 'Test',
						authors: []
					}
				}
			);
			
			drop(0, -1, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'text/x-moz-url';
					}
				},
				getData: function (type) {
					if (type == 'text/x-moz-url') {
						return pdfURL;
					}
				},
				mozItemCount: 2,
			})
			
			var item1 = Zotero.Items.get((await promise)[0]);
			var item2 = Zotero.Items.get((await waitForItemEvent('add'))[0]);
			
			var progressWindow = await recognizerPromise;
			progressWindow.close();
			Zotero.ProgressQueues.get('recognize').cancel();
			assert.isFalse(item1.isTopLevelItem());
			assert.isFalse(item2.isTopLevelItem());
			
			Zotero.HTTP.mock = null;
		});
		
		it("should rename a stored child attachment using parent metadata if no existing file attachments and pref enabled", async function () {
			var view = zp.itemsView;
			var parentTitle = Zotero.Utilities.randomString();
			var parentItem = await createDataObject('item', { title: parentTitle });
			await Zotero.Attachments.linkFromURL({
				url: 'https://example.com',
				title: 'Example',
				parentItemID: parentItem.id
			});
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var file = getTestDataDirectory();
			file.append('empty.pdf');
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 1,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i == 0) {
						return file;
					}
				}
			})
			
			var itemIDs = await promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			var title = item.getField('title');
			var path = await item.getFilePathAsync();
			assert.equal(title, parentTitle + '.pdf');
			assert.equal(OS.Path.basename(path), parentTitle + '.pdf');
		});
		
		it("should rename a linked child attachment using parent metadata if no existing file attachments and pref enabled", async function () {
			Zotero.Prefs.set('autoRenameFiles.linked', true);
			
			var view = zp.itemsView;
			var parentTitle = Zotero.Utilities.randomString();
			var parentItem = await createDataObject('item', { title: parentTitle });
			await Zotero.Attachments.linkFromURL({
				url: 'https://example.com',
				title: 'Example',
				parentItemID: parentItem.id
			});
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var file = OS.Path.join(await getTempDirectory(), 'empty.pdf');
			await OS.File.copy(
				OS.Path.join(getTestDataDirectory().path, 'empty.pdf'),
				file
			);
			file = Zotero.File.pathToFile(file);
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'link',
				effectAllowed: 'link',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 1,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i == 0) {
						return file;
					}
				}
			})
			
			var itemIDs = await promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			var title = item.getField('title');
			var path = await item.getFilePathAsync();
			assert.equal(title, parentTitle + '.pdf');
			assert.equal(OS.Path.basename(path), parentTitle + '.pdf');
		});
		
		it("shouldn't rename a linked child attachment using parent metadata if pref disabled", async function () {
			Zotero.Prefs.set('autoRenameFiles.linked', false);
			
			var view = zp.itemsView;
			var parentTitle = Zotero.Utilities.randomString();
			var parentItem = await createDataObject('item', { title: parentTitle });
			await Zotero.Attachments.linkFromURL({
				url: 'https://example.com',
				title: 'Example',
				parentItemID: parentItem.id
			});
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var file = OS.Path.join(await getTempDirectory(), 'empty.pdf');
			await OS.File.copy(
				OS.Path.join(getTestDataDirectory().path, 'empty.pdf'),
				file
			);
			file = Zotero.File.pathToFile(file);
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'link',
				effectAllowed: 'link',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 1,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i == 0) {
						return file;
					}
				}
			})
			
			var itemIDs = await promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			var title = item.getField('title');
			var path = await item.getFilePathAsync();
			assert.equal(title, 'empty.pdf');
			assert.equal(OS.Path.basename(path), 'empty.pdf');
		});
		
		it("shouldn't rename a stored child attachment using parent metadata if pref disabled", async function () {
			Zotero.Prefs.set('autoRenameFiles', false);
			
			var view = zp.itemsView;
			var parentTitle = Zotero.Utilities.randomString();
			var parentItem = await createDataObject('item', { title: parentTitle });
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var originalFileName = 'empty.pdf';
			var file = getTestDataDirectory();
			file.append(originalFileName);
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 1,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i == 0) {
						return file;
					}
				}
			})
			
			var itemIDs = await promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			var title = item.getField('title');
			var path = await item.getFilePathAsync();
			// Should match original filename, not parent title
			assert.equal(title, originalFileName);
			assert.equal(OS.Path.basename(path), originalFileName);
		});
		
		it("shouldn't rename a stored child attachment using parent metadata if existing file attachments", async function () {
			var view = zp.itemsView;
			var parentTitle = Zotero.Utilities.randomString();
			var parentItem = await createDataObject('item', { title: parentTitle });
			await Zotero.Attachments.linkFromFile({
				file: OS.Path.join(getTestDataDirectory().path, 'test.png'),
				parentItemID: parentItem.id
			});
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var originalFileName = 'empty.pdf';
			var file = getTestDataDirectory();
			file.append(originalFileName);
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 1,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i == 0) {
						return file;
					}
				}
			})
			
			var itemIDs = await promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			var title = item.getField('title');
			var path = await item.getFilePathAsync();
			assert.equal(title, originalFileName);
			assert.equal(OS.Path.basename(path), originalFileName);
		});
		
		it("shouldn't rename a stored child attachment using parent metadata if drag includes multiple files", async function () {
			var view = zp.itemsView;
			var parentTitle = Zotero.Utilities.randomString();
			var parentItem = await createDataObject('item', { title: parentTitle });
			var parentRow = view.getRowIndexByID(parentItem.id);
			
			var originalFileName = 'empty.pdf';
			var file = getTestDataDirectory();
			file.append(originalFileName);
			
			var promise = waitForItemEvent('add');
			
			drop(parentRow, 0, {
				dropEffect: 'copy',
				effectAllowed: 'copy',
				types: {
					contains: function (type) {
						return type == 'application/x-moz-file';
					}
				},
				mozItemCount: 2,
				mozGetDataAt: function (type, i) {
					if (type == 'application/x-moz-file' && i <= 1) {
						return file;
					}
				}
			})
			
			var itemIDs = await promise;
			var item = Zotero.Items.get(itemIDs[0]);
			assert.equal(item.parentItemID, parentItem.id);
			var title = item.getField('title');
			var path = await item.getFilePathAsync();
			assert.equal(title, originalFileName);
			assert.equal(OS.Path.basename(path), originalFileName);
		});
	});
})