describe("Citation Dialog", function () { let io = { accept() {}, cancel() {}, sort() {}, sortable: false, citation: { citationItems: [], properties: { unsorted: false, } }, getItems() { return []; }, preview: () => {}, allCitedDataLoadedPromise: Promise.resolve(), }; let dialog, win, doc, IOManager, CitationDataManager, SearchHandler; before(async function () { // Zotero.Cite.getLocatorString() requires styles to be initialized await Zotero.Styles.init(); // one of helper functions of searchHandler uses zotero pane win = await loadZoteroPane(); let dialogPromise = waitForWindow("chrome://zotero/content/integration/citationDialog.xhtml"); Services.ww.openWindow(null, "chrome://zotero/content/integration/citationDialog.xhtml", "", "", io); dialog = await dialogPromise; doc = dialog.document; IOManager = dialog.IOManager; CitationDataManager = dialog.CitationDataManager; SearchHandler = dialog.SearchHandler; // wait for everything (e.g. itemTree/collectionTree) inside of the dialog to be loaded. while (!dialog.DIALOG_STATE.loaded) { await Zotero.Promise.delay(10); } }); beforeEach(async function () { // Many operations (e.g. IOManager.addItemsToCitation) are disabled // when search runs. Search can be triggered by a variety of events // (notably the window "focus" listener, which kicks off a search after // a 100ms delay). Before each test, wait out that delay, then wait for // any searches — including ones that only just started during the // delay — to finish running. await Zotero.Promise.delay(150); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } }); after(function () { dialog.close(); win.close(); }); describe("Manage entries in the citation", function () { let citedItemNotInLibrary = { id: "o7HzMbH6/6iMXHx6s", itemData: { id: "o7HzMbH6/6iMXHx6s", type: "book", title: "cited_not_in_library_test_title", author: [ { family: "Last", given: "First" } ] }, uris: [ "http://zotero.org/users/11573780/items/K22KNVZL" ], item: { id: "o7HzMbH6/6iMXHx6s", type: "book", title: "cited_not_in_library_test_title", author: [ { family: "Last", given: "First" } ], "title-main": "cited_not_in_library_test_title", "title-sub": "", "title-subjoin": "" }, label: undefined, locator: undefined, prefix: undefined, suffix: undefined, "suppress-author": undefined }; let citedItemOne = { id: null, item: { id: null, type: "book", title: "cited_in_library_test_title", author: [ { family: "Last", given: "First" } ], "title-main": "cited_in_library_test_title", "title-sub": "", "title-subjoin": "" }, label: undefined, locator: undefined, prefix: undefined, suffix: undefined, "suppress-author": undefined }; let itemOne, itemTwo, bubbleInput, ZoteroCiteGetItemStub, surrogateCitedItem; before(async function () { bubbleInput = dialog.document.querySelector("bubble-input"); // Virtual Zotero.Item for a cited item that does not exist in the library. // Same logic as in Zotero.Integration.Citation.loadItemData. surrogateCitedItem = new Zotero.Item(); Zotero.Utilities.itemFromCSLJSON(surrogateCitedItem, citedItemNotInLibrary.itemData); surrogateCitedItem.cslItemID = citedItemNotInLibrary.id; surrogateCitedItem.cslURIs = citedItemNotInLibrary.uris; surrogateCitedItem.cslItemData = citedItemNotInLibrary.itemData; // Zotero.Cite.getItem called with citedItemNotInLibrary returns virtual Zotero.Item from above ZoteroCiteGetItemStub = sinon.stub(Zotero.Cite, 'getItem').callsFake(function (id) { if (id === citedItemNotInLibrary.id) { return surrogateCitedItem; } return Zotero.Items.get(id); }); itemOne = await createDataObject('item', { title: "one" }); itemOne.setCreators([ { firstName: "First_One", lastName: "Last_One", creatorType: "author" } ]); await itemOne.saveTx(); // citedItemOne is an earlier cited itemOne citedItemOne.id = itemOne.id; citedItemOne.item.id = itemOne.id; itemTwo = await createDataObject('item', { title: "two" }); itemTwo.setCreators([ { firstName: "First_Two", lastName: "Last_Two", creatorType: "author" } ]); await itemTwo.saveTx(); }); after(function () { ZoteroCiteGetItemStub.restore(); }); beforeEach(function () { io.citation.citationItems = []; io.citation.sortable = false; dialog.document.getElementById("keepSorted").checked = false; io.sort = () => {}; CitationDataManager.items = []; IOManager.updateBubbleInput(); }); it("should add an item to citation", async function () { await IOManager.addItemsToCitation([itemOne]); let bubbles = bubbleInput.getAllBubbles(); assert.equal(CitationDataManager.items.length, 1); assert.equal(bubbles.length, 1); assert.equal(bubbles[0].textContent, itemOne.getCreator(0).lastName); }); it("should remove an item from citation", async function () { await IOManager.addItemsToCitation([itemOne, itemTwo]); let bubbles = bubbleInput.getAllBubbles(); assert.equal(bubbles.length, 2); let firstBubbleItem = CitationDataManager.items[0]; IOManager._deleteItem(firstBubbleItem.dialogReferenceID); bubbles = bubbleInput.getAllBubbles(); assert.equal(CitationDataManager.items.length, 1); assert.equal(CitationDataManager.items[0].id, itemTwo.id); assert.equal(bubbles.length, 1); assert.equal(bubbles[0].textContent, itemTwo.getCreator(0).lastName); }); it("should build citation with a cited item in library", async function () { io.citation.citationItems = [citedItemOne]; await CitationDataManager.buildCitation(); IOManager.updateBubbleInput(); let bubbles = bubbleInput.getAllBubbles(); assert.equal(CitationDataManager.items.length, 1); assert.equal(bubbles.length, 1); assert.equal(bubbles[0].textContent, itemOne.getCreator(0).lastName); }); it("should build citation with a cited item not in library", async function () { io.citation.citationItems = [citedItemNotInLibrary]; await CitationDataManager.buildCitation(); IOManager.updateBubbleInput(); let bubbles = bubbleInput.getAllBubbles(); assert.equal(CitationDataManager.items.length, 1); assert.equal(bubbles.length, 1); assert.equal(bubbles[0].textContent, surrogateCitedItem.getCreator(0).lastName); }); it("should add a locator/suffix/prefix to a bubble", async function () { // add two bubbles for the same item await IOManager.addItemsToCitation([itemOne, itemOne]); assert.equal(CitationDataManager.items.length, 2); // open popup let firstBubble = CitationDataManager.items[0]; let popup = dialog.document.getElementById("itemDetails"); let popupOpenPromise = popup.state == "open" ? Promise.resolve() : waitForDOMEvent(popup, "popupshown"); IOManager._openItemDetailsPopup(firstBubble.dialogReferenceID); await popupOpenPromise; assert.equal(popup.state, "open"); // set locator/suffix/prefix values popup.querySelector("#locator").value = "10"; popup.querySelector("#suffix").value = "suffix"; popup.querySelector("#prefix").value = "prefix"; popup.querySelector("#prefix").dispatchEvent(new Event('input', { bubbles: true })); // make sure they are set on the bubbleItem assert.equal(firstBubble.locator, "10"); assert.equal(firstBubble.suffix, "suffix"); assert.equal(firstBubble.prefix, "prefix"); let bubble = dialog.document.querySelector(`.bubble[dialogReferenceID="${firstBubble.dialogReferenceID}"]`); assert.equal(bubble.textContent, "prefix Last_One, p. 10 suffix"); // make sure the other bubbleItem is not affected let secondBubble = CitationDataManager.items[1]; assert.notOk(secondBubble.locator); assert.notOk(secondBubble.suffix); assert.notOk(secondBubble.prefix); }); it("should change the order of bubbles", async function () { // add two items await IOManager.addItemsToCitation([itemOne, itemTwo]); let bubbleItemOne = CitationDataManager.items[0]; let bubbleItemTwo = CitationDataManager.items[1]; // check initial order let bubbles = bubbleInput.getAllBubbles(); assert.equal(bubbles[0].getAttribute("dialogReferenceID"), bubbleItemOne.dialogReferenceID); assert.equal(bubbles[1].getAttribute("dialogReferenceID"), bubbleItemTwo.dialogReferenceID); // move the second item to the first position IOManager._moveItem(bubbleItemTwo.dialogReferenceID, 0); // ensure the order is correct assert.equal(CitationDataManager.items[0].dialogReferenceID, bubbleItemTwo.dialogReferenceID); assert.equal(CitationDataManager.items[1].dialogReferenceID, bubbleItemOne.dialogReferenceID); bubbles = dialog.document.querySelector("bubble-input").getAllBubbles(); assert.equal(bubbles[0].getAttribute("dialogReferenceID"), bubbleItemTwo.dialogReferenceID); assert.equal(bubbles[1].getAttribute("dialogReferenceID"), bubbleItemOne.dialogReferenceID); }); it("should sort the citation", async function () { // Make dialog sortable io.citation.sortable = true; dialog.document.getElementById("keepSorted").checked = true; // Mock sort.io implementation that sorts itemOne to the first position io.sort = () => { let items = io.citation.citationItems; items.sort((a, b) => { if (a.id === itemOne.id) return -1; if (b.id === itemOne.id) return 1; return 0; }); io.citation.sortedItems = [ [null, items[0]], [null, items[1]] ]; }; // Add items to citation in wrong order await IOManager.addItemsToCitation([itemTwo, itemOne]); // Make sure the bubbleItems are sorted with itemOne being first let firstBubbleItem = CitationDataManager.items[0]; let secondBubbleItem = CitationDataManager.items[1]; assert.equal(firstBubbleItem.id, itemOne.id); assert.equal(secondBubbleItem.id, itemTwo.id); let bubbles = bubbleInput.getAllBubbles(); assert.equal(bubbles[0].getAttribute("dialogReferenceID"), firstBubbleItem.dialogReferenceID); assert.equal(bubbles[1].getAttribute("dialogReferenceID"), secondBubbleItem.dialogReferenceID); }); it("should update io.citation.items from bubbles", async function () { let bubbleItems = CitationDataManager.items; // Build citation with several cited items io.citation.citationItems = [citedItemOne, citedItemNotInLibrary]; await CitationDataManager.buildCitation(); // Add another item await IOManager.addItemsToCitation([itemTwo], { index: 2 }); // Add modifications bubbleItems[0].label = "page"; bubbleItems[0].locator = "10"; bubbleItems[1].prefix = "prefix"; bubbleItems[1].suffix = "suffix"; bubbleItems[2].suppressAuthor = true; // Update io.citation.items and make sure it looks right CitationDataManager.updateCitationObject(true); let expected = [ { id: itemOne.id, locator: "10", label: "page" }, { id: citedItemNotInLibrary.id, suffix: "suffix", prefix: "prefix", itemData: citedItemNotInLibrary.itemData, uris: citedItemNotInLibrary.uris, }, { id: itemTwo.id, "suppress-author": true } ]; assert.deepEqual(io.citation.citationItems, expected); }); it("should add a locator to a just added bubble", async function () { let itemOne = await createDataObject('item'); await IOManager.addItemsToCitation([itemOne]); let itemTwo = await createDataObject('item'); await IOManager.addItemsToCitation([itemTwo], { index: 0 }); // Make sure item two is marked as just-added assert.sameMembers([CitationDataManager.items[0].dialogReferenceID], IOManager._justAddedBubbles.map(b => b.dialogReferenceID)); // Type a locator and press Enter let currentInput = dialog.document.getElementById("bubble-input").getCurrentInput(); currentInput.value = "p. 10-15"; currentInput.dispatchEvent(new KeyboardEvent('keydown', { key: "Enter", bubbles: true })); // Locator added to just-added itemTwo (at the start) assert.equal(CitationDataManager.items[0].locator, "10-15"); assert.equal(CitationDataManager.items[0].label, "page"); // Locator not added to the item right before the input assert.notOk(CitationDataManager.items[1].locator); assert.notOk(CitationDataManager.items[1].label); }); it("should add a locator to a bubble before the input", async function () { let itemOne = await createDataObject('item'); let itemTwo = await createDataObject('item'); await IOManager.addItemsToCitation([itemOne, itemTwo]); // A multi-item add does not record just-added bubbles assert.notOk(IOManager._justAddedBubbles); // Type a locator and press Enter let currentInput = dialog.document.getElementById("bubble-input").getCurrentInput(); currentInput.value = "p. 10-15"; currentInput.dispatchEvent(new KeyboardEvent('keydown', { key: "Enter", bubbles: true })); // Locator added to the last bubble (to the left of the input) assert.equal(CitationDataManager.items[1].locator, "10-15"); assert.equal(CitationDataManager.items[1].label, "page"); // Locator not added to the first item assert.notOk(CitationDataManager.items[0].locator); assert.notOk(CitationDataManager.items[0].label); }); it("should count numeric value as a locator only after a bubble is added", async function () { let itemOne = await createDataObject('item'); let itemTwo = await createDataObject('item', { title: "60" }); await IOManager.addItemsToCitation([itemOne]); // Type a locator and make sure it didn't trigger search let currentInput = dialog.document.getElementById("bubble-input").getCurrentInput(); currentInput.value = "15-30"; currentInput.dispatchEvent(new Event('input', { bubbles: true })); assert.equal(SearchHandler.searchValue, ""); // Wait for the locator to be added after debounce await Zotero.Promise.delay(dialog.NUMERIC_LOCATOR_TIMEOUT); // Make sure it is added as a locator (without pressing Enter); assert.equal(currentInput.value, ""); assert.equal(CitationDataManager.items[0].locator, "15-30"); assert.equal(CitationDataManager.items[0].label, "page"); // Clear just-added bubbles and repeat IOManager._clearJustAddedBubbles(); // Now, numeric entry should be part of the query currentInput = dialog.document.getElementById("bubble-input").getCurrentInput(); currentInput.value = "60"; currentInput.dispatchEvent(new Event('input', { bubbles: true })); assert.equal(SearchHandler.searchValue, "60"); // Wait for search to finish while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // An new item matching the query should be added to citation on Enter currentInput.dispatchEvent(new KeyboardEvent('keydown', { key: "Enter", bubbles: true })); assert.equal(CitationDataManager.items[1].id, itemTwo.id); // Cleanup SearchHandler.searchValue = ""; }); }); describe("UI", function () { beforeEach(function () { CitationDataManager.items = []; IOManager.updateBubbleInput(); }); it("should switch dialog mode", async function () { await IOManager.toggleDialogMode("list"); assert.isFalse(dialog.document.getElementById("list-layout").hidden); assert.isTrue(dialog.document.getElementById("library-layout").hidden); await IOManager.toggleDialogMode("library"); assert.isFalse(dialog.document.getElementById("library-layout").hidden); assert.isTrue(dialog.document.getElementById("list-layout").hidden); }); it("should show the citation preview only when the citation has items", async function () { let prefWas = Zotero.Prefs.get("integration.citationPreviewShown"); Zotero.Prefs.set("integration.citationPreviewShown", true); try { IOManager.updateBubbleInput(); assert.isTrue(dialog.document.getElementById("citation-preview").hidden); assert.isTrue(dialog.document.getElementById("display-preview-button").hidden); let item = await createDataObject('item'); await IOManager.addItemsToCitation([item]); assert.isFalse(dialog.document.getElementById("citation-preview").hidden); assert.isFalse(dialog.document.getElementById("display-preview-button").hidden); } finally { Zotero.Prefs.set("integration.citationPreviewShown", prefWas); } }); it("should highlight bubbles whose items are selected", async function () { let itemOne = await createDataObject('item'); let itemTwo = await createDataObject('item'); await IOManager.toggleDialogMode("library"); await IOManager.addItemsToCitation([itemOne, itemTwo]); // Select row of the first bubble var promise = dialog.libraryLayout.itemsView.waitForSelect(); dialog.libraryLayout.itemsView.selectItem(itemOne.id); await promise; // Check that the bubble is highlighted let bubbleOne = dialog.document.querySelector(`.bubble[dialogReferenceID="${CitationDataManager.items[0].dialogReferenceID}"]`); assert.isTrue(bubbleOne.classList.contains("has-item-selected")); // Check the other bubble is unaffected let bubbleTwo = dialog.document.querySelector(`.bubble[dialogReferenceID="${CitationDataManager.items[1].dialogReferenceID}"]`); assert.isFalse(bubbleTwo.classList.contains("has-item-selected")); }); it("should highlight rows of items in the citation", async function () { let itemOne = await createDataObject('item'); await IOManager.toggleDialogMode("library"); // Add the item to citation await IOManager.addItemsToCitation([itemOne]); // Select the row in itemTree, so it is visible await dialog.libraryLayout.itemsView.selectItem(itemOne.id); // Make sure the row node is highlighted let rowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(itemOne.id); let rowID = `${dialog.libraryLayout.itemsView.id}-row-${rowIndex}`; let rowNode = dialog.document.getElementById(rowID); assert.isTrue(rowNode.classList.contains("highlighted")); }); it("should add all selected items when clicking + on a selected row", async function () { let collection = await createDataObject('collection'); let itemOne = await createDataObject('item', { collections: [collection.id] }); let itemTwo = await createDataObject('item', { collections: [collection.id] }); await IOManager.toggleDialogMode("library"); let cv = dialog.libraryLayout.collectionsView; let itemsView = dialog.libraryLayout.itemsView; await cv.selectByID("C" + collection.id); await itemsView.waitForLoad(); await itemsView.selectItems([itemOne.id, itemTwo.id]); IOManager.focusItemTree(); dialog.libraryLayout._handleItemsViewIconClick(itemsView.getRowIndexByID(itemOne.id)); // the add is done once focus returns to the input while (!dialog.document.activeElement.closest("bubble-input")) { await Zotero.Promise.delay(10); } assert.sameMembers(CitationDataManager.items.map(item => item.id), [itemOne.id, itemTwo.id]); // a number typed after a multi-item add is not applied as a page locator let input = dialog.document.querySelector("bubble-input").refocusInput(); input.value = "123"; IOManager._handleInputEnter(input); assert.isTrue(CitationDataManager.items.every(item => !item.locator)); input.value = ""; await cv.selectByID("L" + Zotero.Libraries.userLibraryID); await itemsView.waitForLoad(); }); it("should add only the clicked item when clicking + on an unselected row", async function () { let collection = await createDataObject('collection'); let itemOne = await createDataObject('item', { collections: [collection.id] }); let itemTwo = await createDataObject('item', { collections: [collection.id] }); await IOManager.toggleDialogMode("library"); let cv = dialog.libraryLayout.collectionsView; let itemsView = dialog.libraryLayout.itemsView; await cv.selectByID("C" + collection.id); await itemsView.waitForLoad(); await itemsView.selectItem(itemTwo.id); IOManager.focusItemTree(); dialog.libraryLayout._handleItemsViewIconClick(itemsView.getRowIndexByID(itemOne.id)); // the add is done once focus returns to the input while (!dialog.document.activeElement.closest("bubble-input")) { await Zotero.Promise.delay(10); } assert.sameMembers(CitationDataManager.items.map(item => item.id), [itemOne.id]); await cv.selectByID("L" + Zotero.Libraries.userLibraryID); await itemsView.waitForLoad(); }); it("should focus the input after adding an item with Enter in the items list", async function () { let collection = await createDataObject('collection'); let item = await createDataObject('item', { collections: [collection.id] }); await IOManager.toggleDialogMode("library"); let cv = dialog.libraryLayout.collectionsView; let itemsView = dialog.libraryLayout.itemsView; await cv.selectByID("C" + collection.id); await itemsView.waitForLoad(); await itemsView.selectItem(item.id); IOManager.focusItemTree(); let treeElem = dialog.document.querySelector("#zotero-items-tree [tabindex]"); treeElem.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); // the post-add handling returns focus to the input while (!dialog.document.activeElement.closest("bubble-input")) { await Zotero.Promise.delay(10); } await cv.selectByID("L" + Zotero.Libraries.userLibraryID); await itemsView.waitForLoad(); }); it("should show the union of items from multiple selected collections", async function () { let collectionOne = await createDataObject('collection'); let collectionTwo = await createDataObject('collection'); let itemOne = await createDataObject('item', { collections: [collectionOne.id] }); let itemTwo = await createDataObject('item', { collections: [collectionTwo.id] }); await IOManager.toggleDialogMode("library"); let cv = dialog.libraryLayout.collectionsView; let itemsView = dialog.libraryLayout.itemsView; // Select both collections await cv.selectByID("C" + collectionOne.id); cv.selection.toggleSelect(cv.getRowIndexByID("C" + collectionTwo.id)); await dialog.libraryLayout._onCollectionSelection(); await itemsView.waitForLoad(); assert.isNumber(itemsView.getRowIndexByID(itemOne.id), "Item from first selected collection should be shown"); assert.isNumber(itemsView.getRowIndexByID(itemTwo.id), "Item from second selected collection should be shown"); // Restore the default single-row selection so later tests that rely on the // library root being selected aren't affected by the leftover multi-selection await cv.selectByID("L" + Zotero.Libraries.userLibraryID); await itemsView.waitForLoad(); }); }); describe("Search", function () { let selectedOne, selectedTwo, openOne, openTwo, selectedAndOpenOne, citedOne, citedAndOpenOne, libraryOne, libraryTwo; before(async function () { selectedOne = await createDataObject('item', { title: "one_selected" }); selectedTwo = await createDataObject('item', { title: "two_selected" }); openOne = await createDataObject('item', { title: "one_open" }); openTwo = await createDataObject('item', { title: "two_open" }); selectedAndOpenOne = await createDataObject('item', { title: "one_selected_open" }); libraryOne = await createDataObject('item', { title: "one_library" }); libraryTwo = await createDataObject('item', { title: "two_library" }); citedOne = await createDataObject('item', { title: "one_cited" }); citedAndOpenOne = await createDataObject('item', { title: "one_open_cited" }); // Present these items are selected/open/cited SearchHandler.selectedItems = [selectedOne, selectedTwo, selectedAndOpenOne]; SearchHandler.openItems = [openOne, openTwo, selectedAndOpenOne, citedAndOpenOne]; SearchHandler.citedItems = [citedOne, citedAndOpenOne]; }); after(function () { SearchHandler.openItems = []; SearchHandler.selectedItems = []; }); it("should perform search in list mode", async function () { await IOManager.toggleDialogMode("list"); // Wait for search triggered after switching dialog modes to finish while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Re-set cached items right before search to guard against // the window focus handler clearing them SearchHandler.selectedItems = [selectedOne, selectedTwo, selectedAndOpenOne]; SearchHandler.openItems = [openOne, openTwo, selectedAndOpenOne, citedAndOpenOne]; SearchHandler.citedItems = [citedOne, citedAndOpenOne]; // Search for "one" await dialog.currentLayout.search("one", { skipDebounce: true }); // Selected items should have "one_selected" but not "one_selected_open", since it is open let selectedIDs = SearchHandler.results.selected.map(item => item.id); assert.sameMembers(selectedIDs, [selectedOne.id]); // Open items should have "one_open", "one_selected_open", and "one_open_cited" let openIDs = SearchHandler.results.open.map(item => item.id); assert.sameMembers(openIDs, [openOne.id, selectedAndOpenOne.id, citedAndOpenOne.id]); // Cited items should have "one_cited" let citedIDs = SearchHandler.results.cited.map(item => item.id); assert.sameMembers(citedIDs, [citedOne.id]); // Library items should have "one_library" but not "two_library", "one_cited", or "one_open_cited" let libraryIDs = SearchHandler.results.found.map(item => item.id); assert.include(libraryIDs, libraryOne.id); assert.notInclude(libraryIDs, libraryTwo.id); assert.notInclude(libraryIDs, citedOne.id); // Make sure actual nodes for search matches are rendered let expectedItemCardIDs = [...selectedIDs, ...openIDs, ...citedIDs, ...libraryIDs]; for (let itemID of expectedItemCardIDs) { let node = dialog.document.querySelector(`.item[id="${itemID}"]`); assert.isOk(node); } }); it("should perform search in library mode", async function () { await IOManager.toggleDialogMode("library"); // Wait for search triggered after switching dialog modes to finish while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Re-set cached items right before search to guard against // the window focus handler clearing them SearchHandler.selectedItems = [selectedOne, selectedTwo, selectedAndOpenOne]; SearchHandler.openItems = [openOne, openTwo, selectedAndOpenOne, citedAndOpenOne]; SearchHandler.citedItems = [citedOne, citedAndOpenOne]; // Search for "one" await dialog.currentLayout.search("one", { skipDebounce: true }); // Selected items should have "one_selected" but not "one_selected_open", since it is open let selectedIDs = SearchHandler.results.selected.map(item => item.id); assert.sameMembers(selectedIDs, [selectedOne.id]); // Open items should have "one_open", "one_selected_open", and "one_open_cited" let openIDs = SearchHandler.results.open.map(item => item.id); assert.sameMembers(openIDs, [openOne.id, selectedAndOpenOne.id, citedAndOpenOne.id]); // Cited items should have "one_cited" let citedIDs = SearchHandler.results.cited.map(item => item.id); assert.sameMembers(citedIDs, [citedOne.id]); // In library mode, library is searched via itemTree, so this should be empty assert.equal(SearchHandler.results.found.length, 0); // Make sure actual nodes for search matches are rendered let expectedItemCardIDs = [...selectedIDs, ...openIDs, ...citedIDs]; for (let itemID of expectedItemCardIDs) { let node = dialog.document.querySelector(`.item[id="${itemID}"]`); assert.isOk(node); } }); it("should sort libraries in list mode by cited count, with user library first and others alphabetical on tie", async function () { let gammaGroup = await createGroup({ name: "gamma_group" }); let betaGroup = await createGroup({ name: "beta_group" }); let alphaGroup = await createGroup({ name: "alpha_group" }); // One search-matching item per library let userItem = await createDataObject('item', { title: "libsort_user" }); let gammaItem = await createDataObject('item', { title: "libsort_gamma", libraryID: gammaGroup.libraryID }); let betaItem = await createDataObject('item', { title: "libsort_beta", libraryID: betaGroup.libraryID }); let alphaItem = await createDataObject('item', { title: "libsort_alpha", libraryID: alphaGroup.libraryID }); // Cited item with a title that doesn't match the search query, so it // isn't removed from results.found by deduplication. Goes in gamma_group // so that group has a higher cited count than the rest. let citedItem = await createDataObject('item', { title: "cited_item", libraryID: gammaGroup.libraryID }); io.getItems = async () => [citedItem]; io.isAllCitedDataLoaded = true; await IOManager.toggleDialogMode("list"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } SearchHandler.selectedItems = []; SearchHandler.openItems = []; // Reset so refreshCitedItems re-fetches via io.getItems and // populates citedItemCountsByLibrary SearchHandler.citedItems = null; SearchHandler.citedItemCountsByLibrary = {}; await SearchHandler.refreshCitedItems(); await dialog.currentLayout.search("libsort", { skipDebounce: true }); let libraryGroupKeys = SearchHandler.getOrderedSearchResultGroups() .filter(g => g.isLibrary) .map(g => g.key); // Expected order: // 1. gamma_group -- only library with a cited item (cited count wins) // 2. user library -- userLibraryID wins ties over other libraries // 3. alpha_group -- alphabetical fallback among remaining libraries // 4. beta_group assert.deepEqual(libraryGroupKeys, [ gammaGroup.libraryID, userItem.libraryID, alphaGroup.libraryID, betaGroup.libraryID ]); // Reset cited state so subsequent tests aren't affected SearchHandler.citedItems = []; SearchHandler.citedItemCountsByLibrary = {}; io.getItems = () => []; delete io.isAllCitedDataLoaded; }); }); describe("Dialog loading", function () { let newDialog; after(() => { newDialog.close(); }); it("the dialog should be interactable even if io functions are not loaded", async function () { let io = { accept() {}, cancel() {}, sortable: true, citation: { citationItems: [], properties: { unsorted: false, } }, // allCitedDataLoadedPromise is what citation dialog checks // but make all functions unresolved promises just to be sure sort() { return new Zotero.Promise(() => {}); }, getItems() { return new Zotero.Promise(() => {}); }, preview: () => {}, allCitedDataLoadedPromise: new Zotero.Promise(() => {}), }; let newDialogPromise = waitForWindow("chrome://zotero/content/integration/citationDialog.xhtml"); Services.ww.openWindow(null, "chrome://zotero/content/integration/citationDialog.xhtml", "", "", io); newDialog = await newDialogPromise; while (!newDialog.DIALOG_STATE.loaded || newDialog.SearchHandler.searching) { await Zotero.Promise.delay(10); } let item = await createDataObject('item', { title: "test" }); await newDialog.IOManager.addItemsToCitation([item]); // verify that the new bubbles was added let addedBubble = newDialog.document.querySelector(".bubble"); assert.isOk(addedBubble); }); }); describe("Add Note", function () { let item, note; before(async function () { item = await createDataObject('item', { title: "result" }); note = await createDataObject('item', { itemType: 'note' }); note.setNote("result"); await note.saveTx(); await IOManager.toggleDialogMode("list"); // Wait for search triggered after switching dialog modes to finish while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Search for "result" await dialog.currentLayout.search("result", { skipDebounce: true }); }); it("should switch dialog from add/edit citation to add note", async function () { // Start from Add/Edit Citation type await dialog.setDialogType("citation"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Before switching to add-note, note should not appear in search results let results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group; assert.notIncludeMembers(results, [note]); // Switch to add-note type await dialog.setDialogType("add-note"); assert.equal(dialog.DIALOG_STATE.type, "add-note"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // After switching to add-note, the note must appear in search results results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group; assert.includeMembers(results, [note]); // And it's node is rendered let noteNode = dialog.document.querySelector(`.item[id="${note.id}"]`); assert.isOk(noteNode); }); it("should switch dialog from add note to add/edit citation", async function () { // Start from Add Note type await dialog.setDialogType("citation"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Before switching to add/edit citation, item should not appear in search results assert.equal(dialog.DIALOG_STATE.type, "citation"); let results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group; // Switch to add/edit citation await dialog.setDialogType("citation"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Item must now appear in search results results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group; assert.includeMembers(results, [item]); // Item node must be rendered let itemNode = dialog.document.querySelector(`.item[id="${item.id}"]`); assert.isOk(itemNode); }); it("should not display empty note child rows", async function () { await dialog.setDialogType("add-note"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } await IOManager.toggleDialogMode("library"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } let parentItem = await createDataObject('item', { title: "parent_with_notes" }); let noteWithContent = await createDataObject('item', { itemType: 'note', parentID: parentItem.id }); noteWithContent.setNote('

Some note content

'); await noteWithContent.saveTx(); let emptyNote = await createDataObject('item', { itemType: 'note', parentID: parentItem.id }); await emptyNote.saveTx(); // Refresh itemTree await dialog.libraryLayout.search("", { skipDebounce: true }); // The empty note should not be among the itemTree rows let emptyNoteRowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(emptyNote.id); assert.isFalse(emptyNoteRowIndex); // The note with content should be present let noteRowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(noteWithContent.id); assert.isOk(noteRowIndex !== false); await parentItem.eraseTx(); }); it("should include notes open in tabs in open items", async function () { await dialog.setDialogType("add-note"); while (SearchHandler.searching) { await Zotero.Promise.delay(10); } // Open the note in a tab in the main window let win = Zotero.getMainWindow(); await Zotero.Notes.open(note.id, undefined, { openInBackground: true }); let tabID = win.Zotero_Tabs.getTabIDByItemID(note.id); // Re-fetch open items SearchHandler.setSearchValue(""); SearchHandler.clearNonLibraryItemsCache(); await SearchHandler.refreshSelectedAndOpenItems(); win.Zotero_Tabs.close(tabID); assert.includeMembers(SearchHandler.results.open, [note]); }); }); describe("Add annotations dialog", function () { let parentItem, attachment, highlightAnnotation, underlineAnnotation; before(async function () { // Create items and annotations parentItem = await createDataObject('item', { title: "parent_item_with_annotations" }); parentItem.setCreators([ { firstName: "First_One", lastName: "Last_One", creatorType: "author" } ]); await parentItem.saveTx(); attachment = await importFileAttachment('test.pdf', { parentID: parentItem.id }); highlightAnnotation = await createAnnotation('highlight', attachment); highlightAnnotation.annotationText = 'highlighted text'; highlightAnnotation.annotationComment = 'highlight comment'; await highlightAnnotation.saveTx(); underlineAnnotation = await createAnnotation('underline', attachment); underlineAnnotation.annotationText = 'underlined text'; underlineAnnotation.annotationComment = 'underline'; await underlineAnnotation.saveTx(); }); beforeEach(async function () { await dialog.setDialogType("annotations"); dialog.CitationDataManager.items = []; dialog.IOManager.updateBubbleInput(); // Reset search await dialog.currentLayout.search("", { skipDebounce: true }); // Wait for any ongoing search to complete while (SearchHandler.searching) { await Zotero.Promise.delay(10); } dialog.IOManager._lastClickTime = null; }); after(function () { dialog.close(); win.close(); }); it("should bubbleize selected annotations", async function () { // Pretend that highlight annotation is selected SearchHandler.results.selectedAnnotations = [highlightAnnotation]; await dialog.currentLayout.refreshItemsList(); // Click on selected annotation dialog.document.querySelector(`.item[id="${highlightAnnotation.id}"]`).click(); await Zotero.Promise.delay(); // Expect that it becomes a bubble let bubbles = dialog.document.querySelector("bubble-input").getAllBubbles(); assert.equal(bubbles.length, 1); assert.equal(bubbles[0].textContent, `Last_One “highlighted text”`); }); it("should select itemTree row on click of selected non-annotation item", async function () { // Pretend that highlight annotation is selected SearchHandler.results.selectedItems = [parentItem]; await dialog.currentLayout.refreshItemsList(); // Due to some kind of race condition with itemTree loading in tests, // sometimes _itemTreeLoadingDeferred won't be resolved. // This ensures collectionTree.selectItems doesn't get stuck. dialog.libraryLayout.itemsView._itemTreeLoadingDeferred.resolve(); // Click on selected item dialog.document.querySelector(`.item[id="${parentItem.id}"]`).click(); await Zotero.Promise.delay(); // Expect that itemTree row is selected and itemTree is focused assert.equal(dialog.libraryLayout.itemsView.getSelectedItems(true)[0], parentItem.id); assert.equal(dialog.document.activeElement.id, "item-tree-citationDialog-default"); }); it("should display selected items and selected annotations in separate decks", async function () { SearchHandler.results.selectedAnnotations = [highlightAnnotation]; SearchHandler.results.selectedItems = [parentItem]; await dialog.currentLayout.refreshItemsList(); let selectedAnnotationsDeck = dialog.document.getElementById("library-selectedAnnotations-items"); let selectedItemsDeck = dialog.document.getElementById("library-selectedItems-items"); assert.equal(selectedAnnotationsDeck.querySelectorAll(".item").length, 1); assert.equal(selectedAnnotationsDeck.querySelector(".item").id, highlightAnnotation.id); assert.equal(selectedItemsDeck.querySelectorAll(".item").length, 1); assert.equal(selectedItemsDeck.querySelector(".item").id, parentItem.id); }); it("should only include selected/open items that have annotations", async function () { // 2 top-level items, one attachment per each item, no annotations let itemNoAnnotations1 = await createDataObject('item', { title: "item_no_annotations_1" }); let itemNoAnnotations2 = await createDataObject('item', { title: "item_no_annotations_2" }); await importFileAttachment('test.pdf', { parentID: itemNoAnnotations1.id }); await importFileAttachment('test.pdf', { parentID: itemNoAnnotations2.id }); // 2 more top-level items, one attachment per each item, one annotation per attachment let itemWithAnnotations1 = await createDataObject('item', { title: "item_with_annotations_1" }); let itemWithAnnotations2 = await createDataObject('item', { title: "item_with_annotations_2" }); let attachmentWithAnnotations1 = await importFileAttachment('test.pdf', { parentID: itemWithAnnotations1.id }); let attachmentWithAnnotations2 = await importFileAttachment('test.pdf', { parentID: itemWithAnnotations2.id }); await createAnnotation('highlight', attachmentWithAnnotations1); await createAnnotation('highlight', attachmentWithAnnotations2); // Pretend that these are selected/opened items let selectedStub = sinon.stub(SearchHandler, "_getSelectedLibraryItems"); let selectedWithAnnotations = SearchHandler.keepItemsWithAnnotations([itemNoAnnotations1, itemWithAnnotations1]); selectedStub.returns(selectedWithAnnotations); let openStub = sinon.stub(SearchHandler, "_getOpenTabItems"); let openWithAnnotations = SearchHandler.keepItemsWithAnnotations([itemNoAnnotations2, itemWithAnnotations2]); openStub.resolves(openWithAnnotations); // Clear the cache so that selected/open items are re-freshed // using the stubs below SearchHandler.selectedItems = null; SearchHandler.openItems = null; // Refresh all items await SearchHandler.refreshSelectedAndOpenItems(); // Restore all stubs selectedStub.restore(); openStub.restore(); // Only 2 items with annotations should be included in the results assert.sameMembers(SearchHandler.results.selected, [itemWithAnnotations1]); assert.sameMembers(SearchHandler.results.open, [itemWithAnnotations2]); }); it("should display all selected annotations in side pane", async function () { // Actual annotations are selected await dialog.currentLayout.itemsView.selectItems([highlightAnnotation.id, underlineAnnotation.id]); let annotationPreviews = dialog.document.querySelectorAll("#annotations-list annotation-row"); assert.equal(annotationPreviews.length, 2); let annotationRowIDs = [annotationPreviews[0]._annotation.id, annotationPreviews[1]._annotation.id]; assert.sameMembers(annotationRowIDs, [highlightAnnotation.id, underlineAnnotation.id]); // A top-level item is selected await dialog.currentLayout.itemsView.selectItems([parentItem.id]); annotationPreviews = dialog.document.querySelectorAll("#annotations-list annotation-row"); assert.equal(annotationPreviews.length, 2); annotationRowIDs = [annotationPreviews[0]._annotation.id, annotationPreviews[1]._annotation.id]; assert.sameMembers(annotationRowIDs, [highlightAnnotation.id, underlineAnnotation.id]); }); it("should display preview popup on bubble click", async function () { await dialog.IOManager.addItemsToCitation([highlightAnnotation]); let bubble = dialog.document.querySelector("bubble-input .bubble"); let popup = dialog.document.getElementById("itemDetails"); let popupOpenPromise = popup.state == "open" ? Promise.resolve() : waitForDOMEvent(popup, "popupshown"); bubble.click(); await popupOpenPromise; assert.equal(popup.state, "open"); // make sure the annotation-row preview is visible in the popup let annotationPreview = popup.querySelector("annotation-row"); assert.isOk(annotationPreview); assert.notOk(annotationPreview.closest("[hidden]")); }); it("should not display note child rows", async function () { let note = await createDataObject('item', { itemType: 'note', parentID: parentItem.id }); note.setNote('

Test note content

'); await note.saveTx(); // Refresh itemTree await dialog.libraryLayout.search("", { skipDebounce: true }); // Check that the note is not among the itemTree rows let noteRowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(note.id); assert.isFalse(noteRowIndex); await note.eraseTx(); }); }); describe("Helpers.extractLocator", function () { let locator; describe("Invalid locators", function () { it("has no locator label with numeric locator value", function () { locator = dialog.Helpers.extractLocator('history of the US 10-15'); assert.isNull(locator); locator = dialog.Helpers.extractLocator('10-15'); assert.isNull(locator); }); it("has no locator label with textual locator value", function () { locator = dialog.Helpers.extractLocator('history of the US "test"'); assert.isNull(locator); locator = dialog.Helpers.extractLocator('"test"'); assert.isNull(locator); }); it("has no quotes around textual locator value", function () { locator = dialog.Helpers.extractLocator('history of the US chapter something'); assert.isNull(locator); locator = dialog.Helpers.extractLocator('chapter search query'); assert.isNull(locator); }); it("has no locator value", function () { locator = dialog.Helpers.extractLocator('history of the US p'); assert.isNull(locator); locator = dialog.Helpers.extractLocator('page'); assert.isNull(locator); }); it("has textual locator value that does not follow locator label", function () { locator = dialog.Helpers.extractLocator('history of the US note blank "testing"'); assert.isNull(locator); }); it("has numeric locator value that does not follow locator label", function () { locator = dialog.Helpers.extractLocator('history of the US p blank 11-12'); assert.isNull(locator); }); it("has textual locator value not in the end of the string", function () { locator = dialog.Helpers.extractLocator('history of the US chapter "some quotations" some more text'); assert.isNull(locator); }); it("has numeric locator value not in the end of the string", function () { locator = dialog.Helpers.extractLocator('history of the US page 10-15 some more text'); assert.isNull(locator); }); it("is an invalid special page locator with a colon in the middle", function () { locator = dialog.Helpers.extractLocator('history of the US: 10 something else'); assert.isNull(locator); }); it("is an invalid special page locator with a colon and non-numeric value", function () { locator = dialog.Helpers.extractLocator('history of the US:"not a locator"'); assert.isNull(locator); }); }); describe("Valid locator labels", function () { it("is a valid numeric locator by itself", function () { let locators = [ 'line 10-15', 'line10-15', 'l. 10-15', 'l.10-15', 'l 10-15', ' l10-15' ]; for (let locatorString of locators) { let locator = dialog.Helpers.extractLocator(locatorString); assert.isOk(locator); assert.equal(locator.label, 'line'); assert.equal(locator.locator, '10-15'); assert.equal(locator.onlyLocator, true); assert.equal(locator.fullLocatorString, locatorString.trim()); } }); it("is a valid textual locator by itself", function () { let locators = [ 'note "this is a note"', 'note"this is a note"', 'n. "this is a note"', 'n."this is a note"', 'n "this is a note"', ' n"this is a note"' ]; for (let locatorString of locators) { let locator = dialog.Helpers.extractLocator(locatorString); assert.isOk(locator); assert.equal(locator.label, 'note'); assert.equal(locator.locator, 'this is a note'); assert.equal(locator.onlyLocator, true); assert.equal(locator.fullLocatorString, locatorString.trim()); } }); it("is a valid numeric locator with other text", function () { let locators = [ { str: 'history of the US page 10-15', locatorStr: 'page 10-15' }, { str: 'history of the US page10-15', locatorStr: 'page10-15' }, { str: 'history of the US p. 10-15', locatorStr: 'p. 10-15' }, { str: 'history of the US p.10-15', locatorStr: 'p.10-15' }, { str: 'history of the US p 10-15', locatorStr: 'p 10-15' }, { str: 'history of the US p10-15', locatorStr: 'p10-15' } ]; for (let locatorObj of locators) { let locator = dialog.Helpers.extractLocator(locatorObj.str); assert.isOk(locator); assert.equal(locator.label, 'page'); assert.equal(locator.locator, '10-15'); assert.equal(locator.onlyLocator, false); assert.equal(locator.fullLocatorString, locatorObj.locatorStr); } }); it("is a valid textual locator with other text", function () { let locators = [ { str: 'history of the US chapter "one and two"', locatorStr: 'chapter "one and two"' }, { str: 'history of the US chapter"one and two"', locatorStr: 'chapter"one and two"' }, { str: 'history of the US chap. "one and two"', locatorStr: 'chap. "one and two"' }, { str: 'history of the US chap."one and two"', locatorStr: 'chap."one and two"' }, { str: 'history of the US chap "one and two"', locatorStr: 'chap "one and two"' }, { str: 'history of the US chap"one and two"', locatorStr: 'chap"one and two"' } ]; for (let locatorObj of locators) { let locator = dialog.Helpers.extractLocator(locatorObj.str); assert.isOk(locator); assert.equal(locator.label, 'chapter'); assert.equal(locator.locator, 'one and two'); assert.equal(locator.onlyLocator, false); assert.equal(locator.fullLocatorString, locatorObj.locatorStr); } }); it("is a valid special page locator with a colon and a query before it", function () { let locator = dialog.Helpers.extractLocator('history of the US: 10-15'); assert.isOk(locator); assert.equal(locator.label, 'page'); assert.equal(locator.locator, '10-15'); assert.equal(locator.onlyLocator, false); assert.equal(locator.fullLocatorString, ': 10-15'); }); it("is a valid special page locator with a colon and no query before it", function () { let locator = dialog.Helpers.extractLocator(':10'); assert.isOk(locator); assert.equal(locator.label, 'page'); assert.equal(locator.locator, '10'); assert.equal(locator.onlyLocator, true); assert.equal(locator.fullLocatorString, ':10'); }); }); }); describe("SearchHandler.cleanSearchQuery", function () { it("should not override numeric locator in the end of the string", function () { let query = 'US history p10-15'; let cleanedQuery = dialog.SearchHandler.cleanSearchQuery(query); assert.equal(cleanedQuery, query); }); }); });