zotero/test/tests/advancedSearchTest.js
Dan Stillman 77a3a8815e
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Add a query syntax to the quick search
Zotero.SearchQuery turns a query like `by:smith after:2020 tag:"to read"
crispr` or `creator is smith and (tag is foo or bar)` into a
Zotero.Search, matching whatever text is left over using the current
search mode. Anything that doesn't look like a clause is free text, so a
DOI or a title with a colon in it is matched literally.

The search box syntax-highlights the parts of recognized conditions and
offers autocomplete for condition names, for the values of conditions
that have a fixed set of them (like item type), and for tags and
creators from the selected libraries.
2026-08-20 16:09:20 -04:00

2151 lines
79 KiB
JavaScript

"use strict";
describe("Advanced Search", function () {
var win, zp, deck;
before(function* () {
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
win = yield loadZoteroPane();
zp = win.ZoteroPane;
deck = win.document.getElementById('zotero-advanced-search-pane-deck');
});
after(function () {
win.close();
});
it("should perform a search", async function () {
var item = await createDataObject('item', { setTitle: true });
var otherItem = await createDataObject('item', { setTitle: true });
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
// Opening the pane shouldn't filter the items list
await zp.itemsView.waitForLoad();
assert.equal(zp.itemsView.rowCount, 2);
// Add condition
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('title', 'is', item.getField('title'));
pane.search = s;
// Run search and wait for results
var iv = zp.itemsView;
await pane.submit();
await iv.waitForLoad();
// Check results
assert.equal(iv.rowCount, 1);
var index = iv.getRowIndexByID(item.id);
assert.isNumber(index);
// Closing should restore the unfiltered view
await zp.setAdvancedSearchState('closed');
assert.equal(iv.rowCount, 2);
await item.eraseTx();
await otherItem.eraseTx();
});
it("shouldn't invalidate the collection tree row when clearing an unset advanced search", async function () {
// setAdvancedSearch() returns whether the row's filter changed, which
// setFilter() uses to decide whether to refresh the items list
let row = zp.getCollectionTreeRows()[0];
// Nothing to clear
assert.isFalse(row.setAdvancedSearch(null));
let s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', 'foo');
// Applying and clearing a search are changes; clearing again isn't
assert.isTrue(row.setAdvancedSearch(s));
assert.isTrue(row.setAdvancedSearch(null));
assert.isFalse(row.setAdvancedSearch(null));
});
it("should seed conditions written in the quick search query", async function () {
var match = await createDataObject('item', { title: "alpha beta" });
match.addTag('zztag');
await match.saveTx();
var wrongTag = await createDataObject('item', { title: "alpha beta" });
await wrongTag.saveTx();
await zp.openAdvancedSearchFromQuickSearch('tag:zztag alpha', 'fields');
var iv = zp.itemsView;
await iv.waitForLoad();
var conditions = Object.values(deck.pane.search.getConditions());
var tag = conditions.find(c => c.condition === 'tag');
assert.isDefined(tag);
assert.equal(tag.value, 'zztag');
assert.equal(tag.operator, 'is');
// The rest of the query is still words to match
assert.sameMembers(
conditions.filter(c => c.condition === 'anyField').map(c => c.value),
['alpha']
);
assert.equal(iv.rowCount, 1);
assert.isNumber(iv.getRowIndexByID(match.id));
await zp.setAdvancedSearchState('closed');
await iv.waitForLoad();
await Zotero.Items.erase([match.id, wrongTag.id]);
});
it("should seed from the quick search text and reset on close", async function () {
var match = await createDataObject('item', { title: "alpha beta" });
var partial = await createDataObject('item', { title: "alpha gamma" });
var neither = await createDataObject('item', { title: "delta" });
var searchBox = win.document.getElementById('zotero-tb-search');
searchBox.value = 'alpha beta';
await zp.itemsView.setFilter('search', 'alpha beta');
await zp.openAdvancedSearchFromQuickSearch('alpha beta', 'fields');
var iv = zp.itemsView;
await iv.waitForLoad();
// One "Any Field" condition per word, joined with "all"
var conditions = Object.values(deck.pane.search.getConditions())
.filter(c => c.condition === 'anyField');
assert.lengthOf(conditions, 2);
assert.sameMembers(conditions.map(c => c.value), ['alpha', 'beta']);
// Only the item matching both words, and the quick search field is cleared
assert.equal(iv.rowCount, 1);
assert.isNumber(iv.getRowIndexByID(match.id));
assert.equal(searchBox.value, '');
// Closing resets to an unfiltered view (doesn't restore the quick search)
await zp.setAdvancedSearchState('closed');
await iv.waitForLoad();
assert.equal(iv.rowCount, 3);
await Zotero.Items.erase([match.id, partial.id, neither.id]);
});
it("should seed an \"Everything\" quick search as full-text groups", async function () {
await zp.openAdvancedSearchFromQuickSearch('alpha beta', 'everything');
await zp.itemsView.waitForLoad();
// Each word -> an "any" group of [Any Field, Full Text Content]
var conds = Object.values(deck.pane.search.getConditions());
assert.lengthOf(conds.filter(c => c.condition === 'groupStart'), 2);
assert.lengthOf(conds.filter(c => c.condition === 'anyField'), 2);
assert.sameMembers(
conds.filter(c => c.condition === 'fulltextContent').map(c => c.value),
['alpha', 'beta']
);
await zp.setAdvancedSearchState('closed');
});
it("should prefill a Title/Creator/Year quick search, restricted to top-level items", async function () {
// A top-level item whose title matches both words, and an item whose only match is a
// child attachment's title (which shouldn't broaden a top-level-only search)
var topMatch = await createDataObject('item', { title: "alpha beta" });
var childOnly = await createDataObject('item', { title: "zzznomatch" });
var attachment = await importPDFAttachment(childOnly);
attachment.setField('title', 'alpha beta');
await attachment.saveTx();
await zp.openAdvancedSearchFromQuickSearch('alpha beta', 'titleCreatorYear');
var iv = zp.itemsView;
await iv.waitForLoad();
// One "Title, Creator, Year" condition per word, plus a result level of item
var conds = Object.values(deck.pane.search.getConditions());
var tcy = conds.filter(c => c.condition === 'titleCreatorYear');
assert.lengthOf(tcy, 2);
assert.sameMembers(tcy.map(c => c.value), ['alpha', 'beta']);
assert.isTrue(conds.some(c => c.condition === 'resultLevel' && c.operator === 'item'));
// The top-level title matches; the child attachment's matching title doesn't broaden it
assert.equal(iv.rowCount, 1);
assert.isNumber(iv.getRowIndexByID(topMatch.id));
await zp.setAdvancedSearchState('closed');
await topMatch.eraseTx();
await childOnly.eraseTx();
});
it("should run a cross-level search across a multi-collection selection", async function () {
var word = 'zmc' + Zotero.Utilities.randomString();
var makeMatch = async function (collection) {
var item = await createDataObject('item', { collections: [collection.id] });
var attachment = await importPDFAttachment(item);
await createAnnotation('highlight', attachment, { comment: 'foo ' + word + ' bar' });
return item;
};
var c1 = await createDataObject('collection');
var c2 = await createDataObject('collection');
var c3 = await createDataObject('collection');
var itemA = await makeMatch(c1);
var itemB = await makeMatch(c2);
var itemC = await makeMatch(c3); // in an unselected collection
// Select c1 and c2, leaving c3 out
var cv = zp.collectionsView;
await cv.selectByID("C" + c1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
// Top-level items with a descendant annotation matching the comment
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationComment', 'contains', word);
var iv = zp.itemsView;
await iv.setFilter('advanced-search', s);
await iv.waitForLoad();
// The view merges getItems() across the selected rows (collectionViewItemTree),
// so the cross-level search runs scoped to each collection and the results union:
// both selected collections' matching items, but not the unselected one's
var rows = zp.getCollectionTreeRows();
var ids = new Set();
for (let arr of await Promise.all(rows.map(row => row.getItems()))) {
for (let item of arr) ids.add(item.id);
}
assert.sameMembers([...ids], [itemA.id, itemB.id]);
await iv.setFilter('advanced-search', null);
await selectLibrary(win);
await Zotero.Items.erase([itemA.id, itemB.id, itemC.id]);
await Zotero.Collections.erase([c1.id, c2.id, c3.id]);
});
it("shouldn't match an image embedded in a note", async function () {
var item = await createDataObject('item');
item.addTag('a');
await item.saveTx();
var note = new Zotero.Item('note');
note.setNote('foo');
note.parentItemID = item.id;
note.addTag('a');
await note.saveTx();
await createEmbeddedImage(note);
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('numTags', 'is', '0');
pane.search = s;
var iv = zp.itemsView;
await pane.submit();
await iv.waitForLoad();
// The item and its note are tagged, so the only untagged row under the item is
// the embedded image, which is hidden and shouldn't pull the item into the results
assert.isFalse(iv.getRowIndexByID(item.id));
await zp.setAdvancedSearchState('closed');
});
it("shouldn't show trashed items outside the trash", async function () {
var item = await createDataObject('item', { setTitle: true });
item.deleted = true;
await item.saveTx();
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('title', 'is', item.getField('title'));
pane.search = s;
var iv = zp.itemsView;
await pane.submit();
await iv.waitForLoad();
assert.isFalse(iv.getRowIndexByID(item.id));
await zp.setAdvancedSearchState('closed');
await item.eraseTx();
});
it("should remove a trashed result and not show it again on re-run", async function () {
var item = await createDataObject('item', { setTitle: true });
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('title', 'is', item.getField('title'));
pane.search = s;
var iv = zp.itemsView;
await pane.submit();
await iv.waitForLoad();
assert.isNumber(iv.getRowIndexByID(item.id));
// Trashing the item should remove it from the results
await Zotero.Items.trashTx(item.id);
assert.isFalse(iv.getRowIndexByID(item.id));
// Re-running the search shouldn't bring it back
await pane.submit();
await iv.waitForLoad();
assert.isFalse(iv.getRowIndexByID(item.id));
await zp.setAdvancedSearchState('closed');
await item.eraseTx();
});
it("should show trashed items when in the trash", async function () {
var item = await createDataObject('item', { setTitle: true });
item.deleted = true;
await item.saveTx();
await selectTrash(win, item.libraryID);
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('title', 'is', item.getField('title'));
pane.search = s;
var iv = zp.itemsView;
await pane.submit();
await iv.waitForLoad();
assert.isNumber(iv.getRowIndexByID(item.id));
await zp.setAdvancedSearchState('closed');
await selectLibrary(win, item.libraryID);
await item.eraseTx();
});
it("shouldn't reapply previous search when reopened while closing", async function () {
var item = await createDataObject('item', { setTitle: true });
await zp.toggleAdvancedSearchState('open');
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('title', 'is', 'nomatch');
deck.pane.search = s;
await deck.pane.submit();
await zp.itemsView.waitForLoad();
assert.equal(zp.itemsView.rowCount, 0);
// Close and immediately reopen, without waiting, as with UI clicks
zp.toggleAdvancedSearchState('closed');
await zp.toggleAdvancedSearchState('open');
await zp.itemsView.waitForLoad();
// The previous search shouldn't have been reapplied
assert.isNumber(zp.itemsView.getRowIndexByID(item.id));
await zp.setAdvancedSearchState('closed');
await item.eraseTx();
});
it("should scope results to the selected saved search", async function () {
var inBoth = await createDataObject('item', { title: "foo bar" });
var inSavedOnly = await createDataObject('item', { title: "foo baz" });
var inAdvancedOnly = await createDataObject('item', { title: "bar qux" });
var saved = new Zotero.Search();
saved.libraryID = Zotero.Libraries.userLibraryID;
saved.name = "Scope Test";
saved.addCondition('title', 'contains', 'foo');
await saved.saveTx();
await select(win, saved);
await zp.toggleAdvancedSearchState('open');
var s = new Zotero.Search();
s.libraryID = saved.libraryID;
s.addCondition('title', 'contains', 'bar');
deck.pane.search = s;
var iv = zp.itemsView;
await deck.pane.submit();
await iv.waitForLoad();
// Only the item matching both the saved search and the advanced search
assert.equal(iv.rowCount, 1);
assert.isNumber(iv.getRowIndexByID(inBoth.id));
// The saved search itself shouldn't have been modified
assert.lengthOf(Object.keys(saved.getConditions()), 1);
await zp.setAdvancedSearchState('closed');
await Zotero.Items.erase([inBoth.id, inSavedOnly.id, inAdvancedOnly.id]);
await saved.eraseTx();
});
it("should scope results to multiple selected collections", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
var inFirst = await createDataObject('item', { title: "foo bar", collections: [collection1.id] });
var inSecond = await createDataObject('item', { title: "foo baz", collections: [collection2.id] });
var noMatch = await createDataObject('item', { title: "qux", collections: [collection2.id] });
var notInCollections = await createDataObject('item', { title: "foo qux" });
var cv = zp.collectionsView;
await cv.selectByID("C" + collection1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
await zp.toggleAdvancedSearchState('open');
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
deck.pane.search = s;
var iv = zp.itemsView;
await deck.pane.submit();
await iv.waitForLoad();
// Matching items from both collections, but not the non-matching item or the
// matching item outside the selected collections (the row count also includes the
// "2 collections selected" section header)
assert.isNumber(iv.getRowIndexByID(inFirst.id));
assert.isNumber(iv.getRowIndexByID(inSecond.id));
assert.isFalse(iv.getRowIndexByID(noMatch.id));
assert.isFalse(iv.getRowIndexByID(notInCollections.id));
await zp.setAdvancedSearchState('closed');
await selectLibrary(win);
await Zotero.Items.erase([inFirst.id, inSecond.id, noMatch.id, notInCollections.id]);
await Zotero.Collections.erase([collection1.id, collection2.id]);
});
it("should search across feeds in Feeds view", async function () {
let feed = await createFeed();
let feedItem = await createDataObject('feedItem', { libraryID: feed.libraryID, setTitle: true }, { skipSelect: true });
let otherFeedItem = await createDataObject('feedItem', { libraryID: feed.libraryID, setTitle: true }, { skipSelect: true });
await zp.collectionsView.selectFeeds();
await waitForItemsLoad(win);
await zp.toggleAdvancedSearchState('open');
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', feedItem.getField('title'));
deck.pane.search = s;
var iv = zp.itemsView;
await deck.pane.submit();
await iv.waitForLoad();
assert.equal(iv.rowCount, 1);
assert.isNumber(iv.getRowIndexByID(feedItem.id));
await zp.setAdvancedSearchState('closed');
await selectLibrary(win);
await feed.eraseTx();
});
// Run the 'zotero' autocomplete provider directly with given params
function autocomplete(searchString, params) {
return new Promise((resolve) => {
let search = Cc["@mozilla.org/autocomplete/search;1?name=zotero"]
.createInstance(Ci.nsIAutoCompleteSearch);
let listener = {
onSearchResult(_search, result) {
// Ignore intermediate (ongoing) updates
if (result.searchResult == Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING) {
return;
}
let values = [];
for (let i = 0; i < result.matchCount; i++) {
values.push(result.getValueAt(i));
}
resolve(values);
}
};
search.startSearch(searchString, JSON.stringify(params), null, listener);
});
}
it("should scope value autocomplete to the given libraries", async function () {
var group = await getGroup();
var groupLibraryID = group.libraryID;
// Autocomplete matches a value prefix, so both values start with the token
var token = Zotero.Utilities.randomString() + ' ';
var userPublisher = token + 'User';
var groupPublisher = token + 'Group';
var userItem = await createDataObject('item', { itemType: 'book' });
userItem.setField('publisher', userPublisher);
await userItem.saveTx();
var groupItem = await createDataObject('item', { itemType: 'book', libraryID: groupLibraryID });
groupItem.setField('publisher', groupPublisher);
await groupItem.saveTx();
// Scoped to the user library: only its value
var userOnly = await autocomplete(token, { fieldName: 'publisher', libraryIDs: [Zotero.Libraries.userLibraryID] });
assert.deepEqual(userOnly, [userPublisher]);
// Scoped to both libraries: both values
var both = await autocomplete(token, {
fieldName: 'publisher',
libraryIDs: [Zotero.Libraries.userLibraryID, groupLibraryID]
});
assert.includeMembers(both, [userPublisher, groupPublisher]);
await Zotero.Items.erase([userItem.id, groupItem.id]);
});
it("should not autocomplete a two-field creator without a first name as a leading space", async function () {
var item = createUnsavedDataObject('item');
item.setCreators([{ firstName: '', lastName: 'Solodova', creatorType: 'author' }]);
await item.saveTx();
var values = await autocomplete('solodova', { fieldName: 'creator', fieldMode: 2 });
assert.deepEqual(values, ['Solodova']);
await item.eraseTx();
});
it("should save a search in an editable group library root but not the trash", async function () {
var group = await getGroup();
var groupLibraryID = group.libraryID;
await selectLibrary(win, groupLibraryID);
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
// Saving should be enabled at an editable group library root
assert.isFalse(pane._saveButton.disabled);
var s = new Zotero.Search();
s.libraryID = groupLibraryID;
s.addCondition('title', 'contains', 'foo');
pane.search = s;
// Saving prompts for a name
var promptService = Services.prompt;
Services.prompt = {
prompt: (parent, title, message, nameObj) => {
nameObj.value = 'Group Search';
return true;
}
};
try {
await pane.save();
}
finally {
Services.prompt = promptService;
}
// The search should have been saved to the group library
var searches = await Zotero.Searches.getAll(groupLibraryID);
assert.lengthOf(searches, 1);
var conditions = Object.values(searches[0].getConditions());
assert.lengthOf(conditions, 1);
assert.equal(conditions[0].condition, 'title');
assert.equal(conditions[0].value, 'foo');
// Saving should be disabled in the trash, which can't be scoped to
await selectTrash(win, groupLibraryID);
await zp.toggleAdvancedSearchState('open');
assert.isTrue(deck.pane._saveButton.disabled);
await zp.setAdvancedSearchState('closed');
await Zotero.Searches.erase(searches.map(s => s.id));
await selectLibrary(win);
});
it("should scope a saved search to the selected collection", async function () {
var collection = await createDataObject('collection');
var inCollection = await createDataObject('item', { title: "scoped foo", collections: [collection.id] });
var noMatch = await createDataObject('item', { title: "bar", collections: [collection.id] });
var outsideCollection = await createDataObject('item', { title: "scoped foo" });
await selectCollection(win, collection.id);
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
assert.isFalse(pane._saveButton.disabled);
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'scoped');
pane.search = s;
var promptService = Services.prompt;
Services.prompt = {
prompt: (parent, title, message, nameObj) => {
nameObj.value = 'Collection Scoped';
return true;
}
};
try {
await pane.save();
}
finally {
Services.prompt = promptService;
}
var saved = (await Zotero.Searches.getAll(Zotero.Libraries.userLibraryID))
.find(x => x.name == 'Collection Scoped');
assert.ok(saved);
// The collection scope is a top-level condition above the existing one
var conditions = Object.values(saved.getConditions());
assert.equal(conditions[0].condition, 'collection');
assert.equal(conditions[0].value, collection.key);
// Only the matching item within the collection
var ids = await saved.search();
assert.sameMembers(ids, [inCollection.id]);
await saved.eraseTx();
await Zotero.Items.erase([inCollection.id, noMatch.id, outsideCollection.id]);
await collection.eraseTx();
await selectLibrary(win);
});
it("should group the scope and the existing conditions when saving an 'any' search with a collection and a saved search selected", async function () {
var scopeSearch = new Zotero.Search();
scopeSearch.libraryID = Zotero.Libraries.userLibraryID;
scopeSearch.name = "Scope Search";
scopeSearch.addCondition('title', 'contains', 'blue');
await scopeSearch.saveTx();
var collection = await createDataObject('collection');
// Matches a condition and is in the collection
var inCollection = await createDataObject('item', { title: "red apple", collections: [collection.id] });
// Matches a condition and the scope search
var inScopeSearch = await createDataObject('item', { title: "blue sky" });
// Matches a condition but is in neither scope
var outsideScopes = await createDataObject('item', { title: "red car" });
// In the collection but matches no condition
var noMatch = await createDataObject('item', { title: "green grass", collections: [collection.id] });
var cv = zp.collectionsView;
await cv.selectByID("C" + collection.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("S" + scopeSearch.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
await zp.toggleAdvancedSearchState('open');
var pane = deck.pane;
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('title', 'contains', 'red');
s.addCondition('title', 'contains', 'sky');
pane.search = s;
var promptService = Services.prompt;
Services.prompt = {
prompt: (parent, title, message, nameObj) => {
nameObj.value = 'Scoped Any Search';
return true;
}
};
try {
await pane.save();
}
finally {
Services.prompt = promptService;
}
var saved = (await Zotero.Searches.getAll(Zotero.Libraries.userLibraryID))
.find(x => x.name == 'Scoped Any Search');
assert.ok(saved);
// An 'any' group of the two scopes ANDed with an 'any' group of the conditions
var conditions = Object.values(saved.getConditions());
assert.lengthOf(conditions.filter(c => c.condition == 'groupStart'), 2);
assert.isTrue(conditions.some(c => c.condition == 'collection' && c.value == collection.key));
assert.isTrue(conditions.some(c => c.condition == 'savedSearch' && c.value == scopeSearch.key));
var ids = await saved.search();
assert.sameMembers(ids, [inCollection.id, inScopeSearch.id]);
await saved.eraseTx();
await Zotero.Items.erase([inCollection.id, inScopeSearch.id, outsideScopes.id, noMatch.id]);
await collection.eraseTx();
await scopeSearch.eraseTx();
await selectLibrary(win);
});
it("should close the saved-search editor when the edited search is deleted", async function () {
var saved = await createDataObject('search', { name: "DeleteWhileEditing" });
await select(win, saved);
await zp.setSavedSearchEditorState('open');
assert.equal(deck.state, 'open');
assert.equal(deck.selectedSearchType, 'saved');
// Deleting the search being edited should close the editor without a
// save-changes prompt (which would otherwise block here)
await saved.eraseTx();
for (let i = 0; i < 60 && deck.state !== 'closed'; i++) {
await Zotero.Promise.delay(50);
}
assert.equal(deck.state, 'closed');
await selectLibrary(win);
});
it("should close the saved-search editor without prompting when the edited search is trashed", async function () {
var saved = await createDataObject('search', { name: "TrashWhileEditing" });
await select(win, saved);
await zp.setSavedSearchEditorState('open');
assert.equal(deck.state, 'open');
assert.equal(deck.selectedSearchType, 'saved');
// Trashing the search being edited should close the editor without a
// save-changes prompt, as when it's deleted outright
let stub = sinon.stub().returns(1);
let promptService = win.Services.prompt;
win.Services.prompt = { confirmEx: stub };
try {
saved.deleted = true;
await saved.saveTx();
for (let i = 0; i < 60 && deck.state !== 'closed'; i++) {
await Zotero.Promise.delay(50);
}
assert.equal(deck.state, 'closed');
assert.equal(stub.callCount, 0);
}
finally {
win.Services.prompt = promptService;
}
await saved.eraseTx();
await selectLibrary(win);
});
it("should close the saved-search editor without prompting when nothing was changed", async function () {
var saved = await createDataObject('search', { name: "UnchangedEditing" });
await select(win, saved);
await zp.setSavedSearchEditorState('open');
assert.equal(deck.selectedSearchType, 'saved');
let stub = sinon.stub().returns(1); // Cancel
let promptService = win.Services.prompt;
win.Services.prompt = { confirmEx: stub };
try {
await selectLibrary(win);
assert.equal(stub.callCount, 0);
assert.equal(deck.state, 'closed');
}
finally {
win.Services.prompt = promptService;
}
await saved.eraseTx();
});
it("should prompt when only the saved search's name was changed", async function () {
var saved = await createDataObject('search', { name: "RenameWhileEditing" });
await select(win, saved);
await zp.setSavedSearchEditorState('open');
deck.pane.querySelector('#saved-search-name').value = "RenameWhileEditing 2";
let stub = sinon.stub().returns(0); // Save
let promptService = win.Services.prompt;
win.Services.prompt = { confirmEx: stub };
try {
await selectLibrary(win);
assert.equal(stub.callCount, 1);
assert.equal(saved.name, "RenameWhileEditing 2");
}
finally {
win.Services.prompt = promptService;
}
await saved.eraseTx();
});
it("should revert to the edited search without re-prompting when canceling", async function () {
var search1 = await createDataObject('search', { name: "CancelEditing1" });
var search2 = await createDataObject('search', { name: "CancelEditing2" });
let cv = zp.collectionsView;
await select(win, search1);
await zp.setSavedSearchEditorState('open');
assert.equal(deck.selectedSearchType, 'saved');
// Edit the editor's working copy, so that closing it prompts
var searchBox = deck.pane.querySelector('zoterosearch');
searchBox.querySelector('.conditions').firstChild.querySelector('#valuefield').value = 'edited';
searchBox.updateSearch();
// zoteroPane.js uses the pane window's Services, so stub there. Set it before
// touching the selection so no prompt can reach the real (modal) service.
let stub = sinon.stub().returns(1); // Cancel
let promptService = win.Services.prompt;
win.Services.prompt = { confirmEx: stub };
try {
// Form a [search1, search2] multi-selection without firing a selection change
cv.selection.selectEventsSuppressed = true;
cv.selection.select(cv.getRowIndexByID("S" + search1.id));
cv.selection.toggleSelect(cv.getRowIndexByID("S" + search2.id));
cv.selection.selectEventsSuppressed = false;
await zp.onCollectionSelected();
// Prompted once (no loop) and reverted to just the edited search
assert.equal(stub.callCount, 1);
assert.deepEqual(cv.getSelectedRows().map(r => r.id), ["S" + search1.id]);
}
finally {
win.Services.prompt = promptService;
}
await zp.setSavedSearchEditorState('closed');
await Zotero.Searches.erase([search1.id, search2.id]);
await selectLibrary(win);
});
it("should run a focused button's action on Enter instead of the pane default", async function () {
var saved = await createDataObject('search', { name: "EnterOnCancel" });
await select(win, saved);
await zp.setSavedSearchEditorState('open');
assert.equal(deck.selectedSearchType, 'saved');
var savedPane = deck.pane;
var save = sinon.stub(savedPane, 'save');
var cancel = sinon.stub(savedPane, 'cancel');
try {
var cancelButton = savedPane.querySelector('.cancel-button');
cancelButton.focus();
cancelButton.dispatchEvent(
new win.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
// Enter on the focused Cancel button cancels -- it mustn't save
assert.isTrue(save.notCalled);
assert.isTrue(cancel.called);
}
finally {
save.restore();
cancel.restore();
}
await zp.setSavedSearchEditorState('closed');
await saved.eraseTx();
await selectLibrary(win);
});
it("should prompt before replacing the saved-search editor with a new advanced search", async function () {
var saved = await createDataObject('search', { name: "ToggleWhileEditing" });
await select(win, saved);
await zp.setSavedSearchEditorState('open');
// Edit the editor's working copy
var searchBox = deck.pane.querySelector('zoterosearch');
searchBox.querySelector('.conditions').firstChild.querySelector('#valuefield').value = 'edited';
searchBox.updateSearch();
let stub = sinon.stub().returns(1); // Cancel
let promptService = win.Services.prompt;
win.Services.prompt = { confirmEx: stub };
try {
// Cmd-Shift-F opens a new temporary search over the editor
await zp.toggleAdvancedSearchState('open');
// Cancel keeps the editor open with the edits intact
assert.equal(stub.callCount, 1);
assert.equal(deck.selectedSearchType, 'saved');
assert.equal(deck.state, 'open');
assert.include(
Object.values(deck.pane.search.getConditions()).map(c => c.value), 'edited');
// Save saves the edits and continues to the temporary pane
stub.returns(0);
await zp.toggleAdvancedSearchState('open');
assert.equal(stub.callCount, 2);
assert.equal(deck.selectedSearchType, 'temporary');
assert.equal(deck.state, 'open');
assert.include(Object.values(saved.getConditions()).map(c => c.value), 'edited');
}
finally {
win.Services.prompt = promptService;
}
await zp.setAdvancedSearchState('closed');
await saved.eraseTx();
await selectLibrary(win);
});
it("should prompt for a name when saving a new search", async function () {
await selectLibrary(win);
await zp.toggleAdvancedSearchState('open');
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
deck.pane.search = s;
var promptService = Services.prompt;
try {
// Cancelling the name prompt leaves the pane open and saves nothing
Services.prompt = { prompt: () => false };
await deck.pane.save();
assert.equal(deck.state, 'open');
// Accepting saves with the entered name and closes the pane
Services.prompt = {
prompt: (parent, title, message, nameObj) => {
nameObj.value = 'My Named Search';
return true;
}
};
await deck.pane.save();
}
finally {
Services.prompt = promptService;
}
var searches = await Zotero.Searches.getAll(Zotero.Libraries.userLibraryID);
var saved = searches.find(x => x.name === 'My Named Search');
assert.ok(saved);
assert.equal(deck.state, 'closed');
await saved.eraseTx();
});
describe("Conditions", function () {
var pane, searchBox, conditions;
before(async function () {
await zp.toggleAdvancedSearchState('open');
pane = deck.pane;
searchBox = pane.querySelector('zoterosearch');
conditions = searchBox.querySelector('.conditions');
});
after(async function () {
await zp.setAdvancedSearchState('closed');
});
it("should reset to a single empty condition when the last populated condition is removed", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
pane.search = s;
assert.lengthOf(conditions.childNodes, 1);
var row = conditions.firstChild;
// With a value entered, the last condition can be removed
assert.isFalse(row.querySelector('#remove').hasAttribute('disabled'));
row.onRemoveClicked();
// It's replaced by a single empty default condition with the remove button disabled
assert.lengthOf(conditions.childNodes, 1);
var newRow = conditions.firstChild;
assert.equal(newRow.selectedCondition, 'title');
assert.equal(newRow.querySelector('#valuefield').value, '');
assert.isTrue(newRow.querySelector('#remove').hasAttribute('disabled'));
// Focus moves to the new condition's drop-down
assert.equal(win.document.activeElement, newRow.querySelector('#conditionsmenu'));
});
it("should hide the value textbox for a menu-based condition", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var row = conditions.firstChild;
row.onConditionSelected('fileTypeID');
// File Type uses a value menu, so the textbox must actually be hidden, not just
// sitting alongside the menu
var valuefield = row.querySelector('#valuefield');
assert.isTrue(valuefield.hidden);
assert.equal(win.getComputedStyle(valuefield).display, 'none');
assert.isFalse(row.querySelector('#valuemenu').hidden);
});
it("should blank the value field for an isEmpty condition", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('place', 'isEmpty', '');
pane.search = s;
// The value field keeps its space in the row but must not be visible, and the
// other value controls are hidden
var row = conditions.firstChild;
var valuefield = row.querySelector('#valuefield');
assert.isFalse(valuefield.hidden);
assert.equal(win.getComputedStyle(valuefield).visibility, 'hidden');
assert.isTrue(row.querySelector('#valuemenu').hidden);
assert.isTrue(row.querySelector('#value-date-age').hidden);
// The row serializes with no value
var data = row.getConditionData();
assert.equal(data.condition, 'place');
assert.equal(data.operator, 'isEmpty');
assert.equal(data.value, '');
});
it("should keep a loaded annotationAuthor value while its author menu is populating", async function () {
var deferred = Zotero.Promise.defer();
var stub = sinon.stub(Zotero.Annotations, 'getAllAuthors').returns(deferred.promise);
try {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('annotationAuthor', 'is', '12345');
pane.search = s;
// Serializing while the author list is still loading shouldn't drop the value
var row = conditions.firstChild;
assert.equal(row.getConditionData().value, '12345');
deferred.resolve([{ name: "Some User", userID: 12345 }]);
// Let onConditionSelected() finish populating the menu
await Zotero.Promise.delay(0);
assert.isFalse(row.querySelector('#valuemenu').hidden);
assert.equal(row.getConditionData().value, '12345');
}
finally {
stub.restore();
}
});
it("should place attachment and annotation conditions in their submenus", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var row = conditions.firstChild;
var attachmentMenu = row.querySelector('#attachment-conditions-menu');
var annotationMenu = row.querySelector('#annotation-conditions-menu');
// An attachment-level condition is in the Attachment submenu rather than at
// the top level of the conditions menu
assert.isFalse(row.isPrimaryCondition('fileTypeID'));
assert.ok(attachmentMenu.querySelector('menuitem[value="fileTypeID"]'));
// An annotation-level condition is in the Annotation submenu
assert.isFalse(row.isPrimaryCondition('annotationColor'));
assert.ok(annotationMenu.querySelector('menuitem[value="annotationColor"]'));
// Selecting one shows its full name as the closed-menu label
row.onConditionSelected('annotationColor');
assert.equal(row.selectedCondition, 'annotationColor');
assert.equal(
row.querySelector('#conditionsmenu').getAttribute('label'),
Zotero.SearchConditions.getLocalizedName('annotationColor')
);
});
it("should sort the submenus alphabetically among the top-level conditions", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var row = conditions.firstChild;
var popup = row.querySelector('#conditionsmenu > menupopup');
// Top-level entries (including the Attachment/Annotation submenus) are in
// alphabetical order, with the catch-all "More" submenu kept last
var labels = [...popup.children]
.filter(node => node.id != 'more-conditions-menu')
.map(node => node.getAttribute('label'));
var collation = Zotero.getLocaleCollation();
var sorted = [...labels].sort((a, b) => collation.compareString(1, a, b));
assert.deepEqual(labels, sorted);
assert.equal(popup.lastElementChild.id, 'more-conditions-menu');
});
it("should keep an edited value when switching to another text condition", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', '');
pane.search = s;
var row = conditions.firstChild;
// Simulate the user typing into the value field after the row was loaded
row.querySelector('#valuefield').value = 'foo';
row.onConditionSelected('publicationTitle');
// The typed value carries over rather than reverting to the stale loaded value
assert.equal(row.querySelector('#valuefield').value, 'foo');
assert.equal(row.getConditionData().value, 'foo');
});
describe("Find-as-you-type", function () {
function typeInMenu(menu, str) {
for (let ch of str) {
menu.dispatchEvent(new win.KeyboardEvent('keydown', {
key: ch,
bubbles: true,
cancelable: true
}));
}
}
it("should select a condition from the More submenu by typing its name", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
// "Language" lives in the More submenu, so it's only reachable via type-ahead
assert.isFalse(searchCondition.isPrimaryCondition('language'));
typeInMenu(conditionsMenu, 'lang');
assert.equal(searchCondition.selectedCondition, 'language');
});
it("should cycle through matches when the same letter is typed repeatedly", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
typeInMenu(conditionsMenu, 'd');
var first = searchCondition.selectedCondition;
typeInMenu(conditionsMenu, 'd');
var second = searchCondition.selectedCondition;
assert.notEqual(first, second);
assert.match(Zotero.SearchConditions.getLocalizedName(first), /^d/i);
assert.match(Zotero.SearchConditions.getLocalizedName(second), /^d/i);
});
});
describe("Keyboard", function () {
// Focus is deferred to after the originating key event is handled
function nextTick() {
return new Promise(resolve => win.setTimeout(resolve));
}
it("should focus the new row's condition drop-down when adding via the + button", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var before = conditions.childNodes.length;
// Native keyboard activation of the button dispatches a click with detail 0
conditions.firstChild.querySelector('#add').dispatchEvent(
new win.MouseEvent('click', { detail: 0, bubbles: true, cancelable: true })
);
assert.equal(conditions.childNodes.length, before + 1);
await nextTick();
assert.equal(
win.document.activeElement,
conditions.lastChild.querySelector('#conditionsmenu')
);
});
it("shouldn't move focus when adding via the + button with the mouse", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var before = conditions.childNodes.length;
var addButton = conditions.firstChild.querySelector('#add');
addButton.focus();
// A mouse click has detail >= 1
addButton.dispatchEvent(
new win.MouseEvent('click', { detail: 1, bubbles: true, cancelable: true })
);
assert.equal(conditions.childNodes.length, before + 1);
assert.equal(win.document.activeElement, addButton);
});
it("should add a condition and focus its drop-down on Shift-Enter", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var before = conditions.childNodes.length;
conditions.firstChild.querySelector('#conditionsmenu').dispatchEvent(
new win.KeyboardEvent('keypress', {
key: 'Enter',
keyCode: 13,
shiftKey: true,
bubbles: true,
cancelable: true
})
);
assert.equal(conditions.childNodes.length, before + 1);
await nextTick();
assert.equal(
win.document.activeElement,
conditions.lastChild.querySelector('#conditionsmenu')
);
});
it("shouldn't run the search on Shift-Enter", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
function pressEnter(shiftKey) {
conditions.firstChild.querySelector('#conditionsmenu').dispatchEvent(
new win.KeyboardEvent('keydown', {
key: 'Enter',
shiftKey,
bubbles: true,
cancelable: true
})
);
}
var submit = sinon.stub(pane, 'submit');
try {
// Plain Enter runs the search
pressEnter(false);
assert.isTrue(submit.calledOnce);
// Shift-Enter does not
pressEnter(true);
assert.isTrue(submit.calledOnce);
}
finally {
submit.restore();
}
});
// Native keyboard activation of a button dispatches a click with detail 0
function clickFromKeyboard(button) {
button.dispatchEvent(
new win.MouseEvent('click', { detail: 0, bubbles: true, cancelable: true })
);
}
function threeConditionSearch() {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'a');
s.addCondition('title', 'contains', 'b');
s.addCondition('title', 'contains', 'c');
pane.search = s;
}
it("should focus the next row's remove button when removing via the keyboard", async function () {
threeConditionSearch();
assert.equal(conditions.childNodes.length, 3);
// Remove the first row
clickFromKeyboard(conditions.childNodes[0].querySelector('#remove'));
assert.equal(conditions.childNodes.length, 2);
await nextTick();
// Focus moves to the row that took its place
assert.equal(
win.document.activeElement,
conditions.childNodes[0].querySelector('#remove')
);
});
it("should focus the previous row's remove button when removing the last row", async function () {
threeConditionSearch();
// Remove the last row
clickFromKeyboard(conditions.childNodes[2].querySelector('#remove'));
assert.equal(conditions.childNodes.length, 2);
await nextTick();
// Focus moves up to the new last row
assert.equal(
win.document.activeElement,
conditions.childNodes[1].querySelector('#remove')
);
});
it("should focus the row that took a pruned group's place when removing via the keyboard", async function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'a');
s.addCondition('groupStart', 'true', '');
s.addCondition('tag', 'is', 'x');
s.addCondition('groupEnd', 'true', '');
s.addCondition('title', 'contains', 'b');
pane.search = s;
// Removing the group's only condition prunes the group itself
var group = conditions.querySelector('search-condition-group');
clickFromKeyboard(group.conditionsContainer.firstChild.querySelector('#remove'));
assert.notOk(conditions.querySelector('search-condition-group'));
assert.lengthOf(conditions.children, 2);
await nextTick();
// Focus lands on the row that took the group's place ('b'), not the first row
assert.equal(
win.document.activeElement,
conditions.children[1].querySelector('#remove')
);
});
});
it("should insert a condition right after the row whose + is clicked", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'a');
s.addCondition('title', 'contains', 'b');
pane.search = s;
assert.lengthOf(conditions.children, 2);
conditions.children[0].onAddClicked({ preventDefault() {} });
// New (empty) condition lands between the two, not at the end
var values = [...conditions.children]
.map(row => row.querySelector('#valuefield').value);
assert.deepEqual(values, ['a', '', 'b']);
});
describe("Collection", function () {
it("should show only collections", async function () {
var col1 = await createDataObject('collection', { name: "A" });
var col2 = await createDataObject('collection', { name: "C", parentID: col1.id });
var col3 = await createDataObject('collection', { name: "D", parentID: col2.id });
var col4 = await createDataObject('collection', { name: "B" });
var search1 = await createDataObject('search', { name: "A" });
var search2 = await createDataObject('search', { name: "B" });
// Add condition
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
var valueMenu = searchCondition.querySelector('#valuemenu');
assert.isTrue(valueMenu.hidden);
// Select 'Collection' condition
for (let i = 0; i < conditionsMenu.itemCount; i++) {
let menuitem = conditionsMenu.getItemAtIndex(i);
if (menuitem.value == 'collection') {
menuitem.click();
break;
}
}
assert.isFalse(valueMenu.hidden);
// Only the collections, with the saved searches no longer mixed in
assert.equal(valueMenu.itemCount, 4);
// Subcollections are indented via a margin on the icon
function getIndent(menuitem) {
return win.getComputedStyle(menuitem.querySelector('.menu-icon'))
.marginInlineStart;
}
var valueMenuItem = valueMenu.getItemAtIndex(1);
assert.equal(valueMenuItem.getAttribute('label'), col2.name);
assert.equal(valueMenuItem.getAttribute('value'), "C" + col2.key);
assert.equal(getIndent(valueMenuItem), '16px');
valueMenuItem = valueMenu.getItemAtIndex(2);
assert.equal(valueMenuItem.getAttribute('label'), col3.name);
assert.equal(valueMenuItem.getAttribute('value'), "C" + col3.key);
assert.equal(getIndent(valueMenuItem), '32px');
var values = [];
for (let i = 0; i < valueMenu.itemCount; i++) {
values.push(valueMenu.getItemAtIndex(i).getAttribute('value'));
}
assert.notInclude(values, "S" + search1.key);
assert.notInclude(values, "S" + search2.key);
await Zotero.Collections.erase([col1.id, col2.id, col3.id, col4.id]);
await Zotero.Searches.erase([search1.id, search2.id]);
});
it("should update when the library is changed", async function () {
var group = await getGroup();
var groupLibraryID = group.libraryID;
var collection1 = await createDataObject('collection', { name: "A" });
var collection2 = await createDataObject('collection', { name: "C", libraryID: groupLibraryID });
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
var valueMenu = searchCondition.querySelector('#valuemenu');
// Select 'Collection' condition
for (let i = 0; i < conditionsMenu.itemCount; i++) {
let menuitem = conditionsMenu.getItemAtIndex(i);
if (menuitem.value == 'collection') {
menuitem.click();
break;
}
}
for (let i = 0; i < valueMenu.itemCount; i++) {
let menuitem = valueMenu.getItemAtIndex(i);
if (menuitem.getAttribute('value') == "C" + collection1.key) {
menuitem.click();
break;
}
}
assert.equal(valueMenu.value, "C" + collection1.key);
// Switch to the group library in the collection tree, which changes
// the search library and re-renders the conditions
await selectLibrary(win, groupLibraryID);
var values = [];
searchCondition = conditions.firstChild;
valueMenu = searchCondition.querySelector('#valuemenu');
assert.equal(valueMenu.value, "C" + collection2.key);
for (let i = 0; i < valueMenu.itemCount; i++) {
let menuitem = valueMenu.getItemAtIndex(i);
values.push(menuitem.getAttribute('value'));
}
assert.notInclude(values, "C" + collection1.key);
assert.include(values, "C" + collection2.key);
await selectLibrary(win);
await Zotero.Collections.erase([collection1.id, collection2.id]);
});
it("shouldn't appear in a cross-library scope", async function () {
var group = await getGroup();
var groupLibraryID = group.libraryID;
// Simulate the cross-library scope that advancedSearchPane.refresh() sets
// from a multi-library collection selection
searchBox.scopeLibraryIDs = [Zotero.Libraries.userLibraryID, groupLibraryID];
try {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
// Neither Collection nor Saved Search can resolve across libraries
for (let i = 0; i < conditionsMenu.itemCount; i++) {
assert.notEqual(conditionsMenu.getItemAtIndex(i).value, 'collection');
assert.notEqual(conditionsMenu.getItemAtIndex(i).value, 'savedSearch');
}
}
finally {
searchBox.scopeLibraryIDs = null;
}
});
});
describe("Saved Search", function () {
it("should appear as a condition", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
var values = [];
for (let i = 0; i < conditionsMenu.itemCount; i++) {
values.push(conditionsMenu.getItemAtIndex(i).value);
}
assert.include(values, 'savedSearch');
});
it("should show only saved searches", async function () {
var collection = await createDataObject('collection', { name: "A" });
var search1 = await createDataObject('search', { name: "A" });
var search2 = await createDataObject('search', { name: "B" });
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
var valueMenu = searchCondition.querySelector('#valuemenu');
assert.isTrue(valueMenu.hidden);
// Select 'Saved Search' condition
for (let i = 0; i < conditionsMenu.itemCount; i++) {
let menuitem = conditionsMenu.getItemAtIndex(i);
if (menuitem.value == 'savedSearch') {
menuitem.click();
break;
}
}
assert.isFalse(valueMenu.hidden);
var values = [];
for (let i = 0; i < valueMenu.itemCount; i++) {
values.push(valueMenu.getItemAtIndex(i).getAttribute('value'));
}
assert.sameMembers(values, ["S" + search1.key, "S" + search2.key]);
assert.notInclude(values, "C" + collection.key);
await collection.eraseTx();
await Zotero.Searches.erase([search1.id, search2.id]);
});
it("should be selected for a 'savedSearch' condition", async function () {
var search = await createDataObject('search', { name: "A" });
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('savedSearch', 'is', search.key);
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
var valueMenu = searchCondition.querySelector('#valuemenu');
assert.equal(conditionsMenu.selectedItem.value, 'savedSearch');
assert.isFalse(valueMenu.hidden);
assert.equal(valueMenu.selectedItem.value, "S" + search.key);
await search.eraseTx();
});
it("should set a 'savedSearch' condition when a search is selected", async function () {
var search = await createDataObject('search', { name: "B" });
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
var valueMenu = searchCondition.querySelector('#valuemenu');
// Select 'Saved Search' condition
for (let i = 0; i < conditionsMenu.itemCount; i++) {
let menuitem = conditionsMenu.getItemAtIndex(i);
if (menuitem.value == 'savedSearch') {
menuitem.click();
break;
}
}
for (let i = 0; i < valueMenu.itemCount; i++) {
let menuitem = valueMenu.getItemAtIndex(i);
if (menuitem.getAttribute('value') == "S" + search.key) {
menuitem.click();
break;
}
}
searchBox.updateSearch();
var condition = searchBox.search.getConditions()[0];
assert.equal(condition.condition, 'savedSearch');
assert.equal(condition.value, search.key);
await search.eraseTx();
});
});
describe("Search subcollections", function () {
it("should appear only when there's a Collection condition", async function () {
var collection = await createDataObject('collection');
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var options = searchBox.querySelector('#search-option-checkboxes');
// Hidden while the search has no Collection condition to recurse into
assert.isTrue(options.hidden);
// Switch the row to a Collection condition
var conditionsMenu = conditions.firstChild.querySelector('#conditionsmenu');
for (let i = 0; i < conditionsMenu.itemCount; i++) {
let menuitem = conditionsMenu.getItemAtIndex(i);
if (menuitem.value == 'collection') {
menuitem.click();
break;
}
}
assert.isFalse(options.hidden);
await collection.eraseTx();
});
});
describe("Groups", function () {
function groupedSearch() {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
s.addCondition('groupStart', 'true', '');
s.addCondition('joinMode', 'any');
s.addCondition('tag', 'is', 'x');
s.addCondition('tag', 'is', 'y');
s.addCondition('groupEnd', 'true', '');
return s;
}
it("should render a nested group from group markers", function () {
pane.search = groupedSearch();
// Root holds the title row plus one nested group
assert.lengthOf(conditions.children, 2);
var group = conditions.querySelector('search-condition-group');
assert.equal(group.joinMode, 'any');
assert.lengthOf(group.conditionsContainer.children, 2);
assert.equal(group.conditionsContainer.firstChild.selectedCondition, 'tag');
});
it("should serialize a nested group back to markers", function () {
pane.search = groupedSearch();
searchBox.updateSearch();
var sequence = Object.values(searchBox.search.getConditions())
.map(c => c.condition);
assert.deepEqual(sequence,
['title', 'groupStart', 'joinMode', 'tag', 'tag', 'groupEnd']);
});
it("should keep a binding that scopes a condition matching at any level", async function () {
// An item whose attachment has the tag, and one where the tag is on a
// note instead
var hit = await createDataObject('item');
var attachment = await importFileAttachment('test.png', { parentID: hit.id });
attachment.setTags([{ tag: 'zbound' }]);
await attachment.saveTx();
var miss = await createDataObject('item');
var note = new Zotero.Item('note');
note.parentID = miss.id;
note.setNote('note');
note.setTags([{ tag: 'zbound' }]);
await note.saveTx();
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'attachment');
s.addCondition('tag', 'is', 'zbound');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
searchBox.updateSearch();
var group = conditions.querySelector('search-condition-group');
assert.equal(group.resultLevel, 'attachment');
// The tag has to be on the attachment, not anywhere in the item
var ids = await searchBox.search.search();
assert.include(ids, hit.id);
assert.notInclude(ids, miss.id);
});
it("should drop a binding that the result level makes meaningless", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'attachment');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'attachment');
s.addCondition('tag', 'is', 'zbound');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
searchBox.updateSearch();
// Results are attachments, so binding the group to the attachment says
// nothing the result level doesn't
var group = conditions.querySelector('search-condition-group');
assert.equal(group.resultLevel, 'any');
assert.deepEqual(
Object.values(searchBox.search.getConditions()).map(c => c.condition),
['resultLevel', 'groupStart', 'tag', 'groupEnd']
);
});
it("should wrap a condition in a new group in its place", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
pane.search = s;
conditions.firstChild.onGroupClicked({ preventDefault() {} });
// The condition is now the sole member of a group in its old place,
// keeping its value
assert.lengthOf(conditions.children, 1);
var group = conditions.querySelector('search-condition-group');
assert.lengthOf(group.conditionsContainer.children, 1);
var row = group.conditionsContainer.firstChild;
assert.equal(row.selectedCondition, 'title');
assert.equal(row.querySelector('#valuefield').value, 'foo');
});
it("should ungroup a group back into its parent", function () {
pane.search = groupedSearch();
var group = conditions.querySelector('search-condition-group');
group.onUngroupClicked();
// The group is gone and its two tag conditions now sit alongside the title row
assert.notOk(conditions.querySelector('search-condition-group'));
searchBox.updateSearch();
var sequence = Object.values(searchBox.search.getConditions())
.map(c => c.condition);
assert.deepEqual(sequence, ['title', 'tag', 'tag']);
});
it("should remove a group when its last condition is removed", function () {
pane.search = groupedSearch();
var group = conditions.querySelector('search-condition-group');
group.conditionsContainer.firstChild.onRemoveClicked();
// Removing the group's first tag leaves it with the second
assert.ok(conditions.querySelector('search-condition-group'));
group.conditionsContainer.firstChild.onRemoveClicked();
// With no conditions left, the group itself is gone
assert.notOk(conditions.querySelector('search-condition-group'));
// The root's title condition remains
assert.lengthOf(conditions.children, 1);
assert.equal(conditions.firstChild.selectedCondition, 'title');
});
it("should reset the root to an empty condition when its last group empties", function () {
// A search whose only content is a single group with a single condition
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('groupStart', 'true', '');
s.addCondition('tag', 'is', 'x');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
group.conditionsContainer.firstChild.onRemoveClicked();
// The emptied group is gone and the root falls back to one empty condition
assert.notOk(conditions.querySelector('search-condition-group'));
assert.lengthOf(conditions.children, 1);
assert.equal(conditions.firstChild.selectedCondition, 'title');
assert.equal(conditions.firstChild.querySelector('#valuefield').value, '');
});
it("should default a fresh search to the top-level item result level", function () {
// Opening a new (unseeded) advanced search defaults the result level to
// top-level items, so child conditions map up without grouping
pane.search = null;
assert.equal(searchBox.rootGroup.resultLevel, 'item');
});
it("should render and serialize the root result level", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', 'foo');
pane.search = s;
assert.equal(searchBox.rootGroup.resultLevel, 'annotation');
searchBox.updateSearch();
var sequence = Object.values(searchBox.search.getConditions())
.map(c => c.condition);
assert.deepEqual(sequence, ['resultLevel', 'annotationText']);
var scopeCond = Object.values(searchBox.search.getConditions())
.find(c => c.condition == 'resultLevel');
assert.equal(scopeCond.operator, 'annotation');
});
it("should render and serialize a nested group result level", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('creator', 'contains', 'Smith');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.equal(group.resultLevel, 'annotation');
searchBox.updateSearch();
var sequence = Object.values(searchBox.search.getConditions())
.map(c => c.condition);
assert.deepEqual(sequence,
['creator', 'groupStart', 'resultLevel', 'annotationText', 'groupEnd']);
});
it("should show a binding menu for a group of same-level descendant conditions", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item'); // result level
s.addCondition('groupStart', 'true', '');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('annotationComment', 'contains', 'bar');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.isFalse(group.bindingMenu.hidden);
var values = [...group.bindingMenu.querySelectorAll('menuitem')].map(i => i.value);
assert.includeMembers(values, ['any', 'annotation']);
// No attachment conditions, so it isn't offered
assert.notInclude(values, 'attachment');
// Bind to the same annotation and confirm it serializes
group.resultLevel = 'annotation';
searchBox.updateSearch();
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.deepEqual(seq,
['resultLevel', 'groupStart', 'resultLevel', 'annotationText', 'annotationComment', 'groupEnd']);
var groupScope = Object.values(searchBox.search.getConditions())
.filter(c => c.condition == 'resultLevel')[1];
assert.equal(groupScope.operator, 'annotation');
});
it("should not show a binding menu for a group of item-level conditions", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('title', 'contains', 'a');
s.addCondition('title', 'contains', 'b');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.isTrue(group.bindingMenu.hidden);
});
it("should render a stored group binding into the menu", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('annotationComment', 'contains', 'bar');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.isFalse(group.bindingMenu.hidden);
assert.equal(group.resultLevel, 'annotation');
assert.equal(group.bindingMenu.value, 'annotation');
});
it("should keep showing a group binding when a condition switches level", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('annotationComment', 'contains', 'bar');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.isFalse(group.bindingMenu.hidden);
// The binding stays visible (and clearable) rather than silently
// constraining the group from a hidden menu
group.conditionsContainer.firstChild.onConditionSelected('title');
searchBox.updateSearch();
assert.isFalse(group.bindingMenu.hidden);
assert.equal(group.bindingMenu.value, 'annotation');
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.deepEqual(seq,
['resultLevel', 'groupStart', 'resultLevel', 'title', 'annotationComment', 'groupEnd']);
});
it("should keep showing a group binding for a lone condition at the bound level", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.isFalse(group.bindingMenu.hidden);
assert.equal(group.bindingMenu.value, 'annotation');
searchBox.updateSearch();
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.deepEqual(seq,
['resultLevel', 'groupStart', 'resultLevel', 'annotationText', 'groupEnd']);
});
it("should drop a group binding when no condition at its level remains", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('annotationComment', 'contains', 'bar');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
group.conditionsContainer.firstChild.onConditionSelected('title');
group.conditionsContainer.lastChild.onConditionSelected('title');
searchBox.updateSearch();
assert.isTrue(group.bindingMenu.hidden);
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.deepEqual(seq,
['resultLevel', 'groupStart', 'title', 'title', 'groupEnd']);
});
it("should offer a binding hint for ungrouped sibling descendant conditions", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('annotationComment', 'contains', 'bar');
pane.search = s;
var hint = searchBox.querySelector('#search-binding-hint');
assert.isFalse(hint.hidden);
// One bindable level (annotation), so one suggestion line with one button
assert.lengthOf([...hint.querySelectorAll('button')], 1);
});
it("should not offer a binding hint for item-level sibling conditions", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('title', 'contains', 'a');
s.addCondition('title', 'contains', 'b');
pane.search = s;
assert.isTrue(searchBox.querySelector('#search-binding-hint').hidden);
});
it("should offer a binding hint for descendant conditions with no values", function () {
// Two descendant conditions suggest binding as soon as they're selected, with no
// values needed
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationText', 'contains', '');
s.addCondition('annotationComment', 'contains', '');
pane.search = s;
assert.isFalse(searchBox.querySelector('#search-binding-hint').hidden);
});
it("should not offer a binding hint for a freshly added condition until it's changed", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationText', 'contains', 'foo');
pane.search = s;
// "+" seeds a second annotation condition, but it shouldn't suggest binding until
// the user engages with it
conditions.firstChild.onAddClicked({ preventDefault() {}, detail: 1 });
assert.isTrue(searchBox.querySelector('#search-binding-hint').hidden);
// Engaging with the new row (here, typing in it) lets it count
conditions.lastChild.querySelector('#search-textbox')
.dispatchEvent(new win.Event('input', { bubbles: true }));
assert.isFalse(searchBox.querySelector('#search-binding-hint').hidden);
});
it("should wrap conditions into a bound group when the hint is taken", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationText', 'contains', 'foo');
s.addCondition('annotationComment', 'contains', 'bar');
pane.search = s;
searchBox.rootGroup.bindSameEntity('annotation');
// The two conditions are now one group bound to annotation
var group = conditions.querySelector('search-condition-group');
assert.ok(group);
assert.equal(group.resultLevel, 'annotation');
assert.lengthOf(
[...group.conditionsContainer.children].filter(c => c.localName == 'zoterosearchcondition'),
2);
searchBox.updateSearch();
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.deepEqual(seq,
['resultLevel', 'groupStart', 'resultLevel', 'annotationText', 'annotationComment', 'groupEnd']);
// Conditions are no longer ungrouped siblings, so the hint is gone
assert.isTrue(searchBox.querySelector('#search-binding-hint').hidden);
});
it("should fold a legacy noChildren into the result level", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('noChildren', 'true');
s.addCondition('title', 'contains', 'foo');
pane.search = s;
// Shown as result level = top-level items, no separate checkbox
assert.equal(searchBox.rootGroup.resultLevel, 'item');
searchBox.updateSearch();
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.notInclude(seq, 'noChildren');
assert.deepEqual(seq, ['resultLevel', 'title']);
});
it("should warn when ALL conditions can't match the same item at a mixed result level", function () {
// No result type (mixed), so an item-level and an annotation-level condition
// can't both be true of one row
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'a');
s.addCondition('annotationText', 'contains', 'b');
pane.search = s;
assert.isFalse(searchBox.querySelector('.level-warning').hidden);
});
it("should not warn when a result type lets the conditions combine", function () {
// Result type item: the annotation condition maps up, so it's satisfiable
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('title', 'contains', 'a');
s.addCondition('annotationText', 'contains', 'b');
pane.search = s;
assert.isTrue(searchBox.querySelector('.level-warning').hidden);
});
it("should warn when a condition can't reach the result type", function () {
// A note can never be (or be under) an attachment
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'attachment');
s.addCondition('note', 'contains', 'a');
pane.search = s;
assert.isFalse(searchBox.querySelector('.level-warning').hidden);
});
it("should warn on the group when a condition can't reach its binding", function () {
// A group bound to "same annotation" with a note condition: the conflict is the
// group's binding, so the warning belongs on the group, not at the root.
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationComment', 'contains', 'a');
s.addCondition('note', 'contains', 'b');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
var group = conditions.querySelector('search-condition-group');
assert.isFalse(group.levelWarning.hidden);
assert.isTrue(searchBox.rootGroup.levelWarning.hidden);
});
it("should not warn for a \"separately\" group whose conditions roll up", function () {
// "Separately" inherits the result level (item), so annotations and a note all
// roll up independently -- satisfiable, no warning (so "match separately" really
// does clear the bound-group warning)
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('annotationComment', 'contains', 'a');
s.addCondition('annotationComment', 'contains', 'b');
s.addCondition('note', 'contains', 'c');
s.addCondition('groupEnd', 'true', '');
pane.search = s;
assert.isTrue(conditions.querySelector('search-condition-group').levelWarning.hidden);
});
it("should not warn for an ordinary search", function () {
pane.search = null;
assert.isTrue(searchBox.querySelector('.level-warning').hidden);
});
it("should keep a legacy includeParentsAndChildren editable and round-tripping", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
s.addCondition('includeParentsAndChildren', 'true');
pane.search = s;
assert.isFalse(searchBox.querySelector('#search-legacy-options').hidden);
assert.isTrue(searchBox.querySelector('#includeParentsAndChildrenCheckbox').checked);
searchBox.updateSearch();
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.include(seq, 'includeParentsAndChildren');
});
it("should drop includeParentsAndChildren when its legacy checkbox is unchecked", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
s.addCondition('includeParentsAndChildren', 'true');
pane.search = s;
searchBox.querySelector('#includeParentsAndChildrenCheckbox').checked = false;
searchBox.updateSearch();
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
assert.notInclude(seq, 'includeParentsAndChildren');
});
it("should add a sibling condition outside the group from the group's +", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
pane.search = s;
// The root has no parent, so its caption "+" stays hidden
assert.isTrue(searchBox.rootGroup.addConditionButton.hidden);
// Wrap the only root condition, so the root now holds just the group
conditions.firstChild.onGroupClicked({ preventDefault() {} });
assert.lengthOf(conditions.children, 1);
var group = conditions.querySelector('search-condition-group');
// The group's "+" adds a sibling in the parent (root), not inside the group
group.onAddSiblingClicked();
assert.lengthOf(conditions.children, 2);
var rows = [...conditions.children]
.filter(c => c.localName === 'zoterosearchcondition');
assert.lengthOf(rows, 1);
assert.equal(rows[0].selectedCondition, 'title');
// Nothing was added inside the group
assert.lengthOf(group.conditionsContainer.children, 1);
});
});
});
});