mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Build, Upload (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
Operators can be typed as the Advanced Search shows them, which every locale already translates, and new keyword messages cover the join words, "no"/"has", the units of a relative date, and the range forms. Each range form is given as an example with its two ends filled in, so that a locale can say it its own way and each form keeps its own words (e.g., no "between 1970 to 2000").
1109 lines
43 KiB
JavaScript
1109 lines
43 KiB
JavaScript
"use strict";
|
||
|
||
describe("Zotero.SearchQuery", function () {
|
||
function clauses(query) {
|
||
let { tree } = Zotero.SearchQuery.parse(query);
|
||
return tree ? tree.children : [];
|
||
}
|
||
|
||
describe("#parse()", function () {
|
||
it("should parse a field, operator, and value", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("by:smith");
|
||
assert.equal(text, "");
|
||
assert.deepEqual(tree.children, [
|
||
{ condition: 'creator', operator: 'contains', value: 'smith' }
|
||
]);
|
||
});
|
||
|
||
it("should separate free text from clauses, in either order", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("by:smith crispr screens");
|
||
assert.equal(text, "crispr screens");
|
||
assert.lengthOf(tree.children, 1);
|
||
assert.equal(Zotero.SearchQuery.parse("crispr by:smith").text, "crispr");
|
||
});
|
||
|
||
it("should treat text that only looks like a clause as text", function () {
|
||
for (let query of ["10.1234/foo:bar", "Vol 2: The Return", "https://example.com/a:b"]) {
|
||
let { tree, text } = Zotero.SearchQuery.parse(query);
|
||
assert.isNull(tree, query);
|
||
assert.equal(text, query.replace(/\s+/g, ' '), query);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("operators and comparisons", function () {
|
||
it("should accept operator words as the Advanced Search reads", function () {
|
||
assert.deepEqual(clauses("title is grounded"), [
|
||
{ condition: 'title', operator: 'is', value: 'grounded' }
|
||
]);
|
||
assert.deepEqual(clauses("date before 2015"), [
|
||
{ condition: 'date', operator: 'isBefore', value: '2015' }
|
||
]);
|
||
assert.deepEqual(clauses("year since 2020"), [
|
||
{ condition: 'date', operator: 'isAfter', value: '2020' }
|
||
]);
|
||
// Every date-type field compares as a date
|
||
assert.deepEqual(clauses("filing date is before 2020"), [
|
||
{ condition: 'filingDate', operator: 'isBefore', value: '2020' }
|
||
]);
|
||
assert.deepEqual(clauses("original date is before 2020"), [
|
||
{ condition: 'originalDate', operator: 'isBefore', value: '2020' }
|
||
]);
|
||
});
|
||
|
||
it("should parse an operator of several words, preferring the longest", function () {
|
||
assert.deepEqual(clauses("title begins with grounded"), [
|
||
{ condition: 'title', operator: 'beginsWith', value: 'grounded' }
|
||
]);
|
||
assert.deepEqual(clauses("type is not book"), [
|
||
{ condition: 'itemType', operator: 'isNot', value: 'book' }
|
||
]);
|
||
});
|
||
|
||
it("should compare dates with before, after, and since as fields", function () {
|
||
assert.deepEqual(clauses("after:2020"), [
|
||
{ condition: 'date', operator: 'isAfter', value: '2020' }
|
||
]);
|
||
assert.deepEqual(clauses("before:2015"), [
|
||
{ condition: 'date', operator: 'isBefore', value: '2015' }
|
||
]);
|
||
});
|
||
|
||
it("should keep the unit in a relative date", async function () {
|
||
assert.deepEqual(clauses("added in the last 3 days"), [
|
||
{ condition: 'dateAdded', operator: 'isInTheLast', value: '3 days' }
|
||
]);
|
||
assert.equal(Zotero.SearchQuery.parse("added in the last 3 days").text, "");
|
||
|
||
let item = await createDataObject('item');
|
||
let search = Zotero.SearchQuery.getSearch("added in the last 3 days", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
});
|
||
assert.include(await search.search(), item.id);
|
||
});
|
||
|
||
it("should count weeks in days, which the date comparison understands", function () {
|
||
assert.deepEqual(clauses("added in the last 2 weeks"), [
|
||
{ condition: 'dateAdded', operator: 'isInTheLast', value: '14 days' }
|
||
]);
|
||
});
|
||
|
||
it("should read natural comparisons written with 'is'", function () {
|
||
assert.deepEqual(clauses("year is before 2020"), [
|
||
{ condition: 'date', operator: 'isBefore', value: '2020' }
|
||
]);
|
||
assert.deepEqual(clauses("added is in the last 2 weeks"), [
|
||
{ condition: 'dateAdded', operator: 'isInTheLast', value: '14 days' }
|
||
]);
|
||
assert.deepEqual(clauses("number of tags is greater than 5"), [
|
||
{ condition: 'numTags', operator: 'isGreaterThan', value: '5' }
|
||
]);
|
||
// A condition that doesn't compare still matches the word
|
||
assert.deepEqual(clauses("title is before"), [
|
||
{ condition: 'title', operator: 'is', value: 'before' }
|
||
]);
|
||
});
|
||
|
||
it("should accept 'starts with' and 'within the last'", function () {
|
||
assert.deepEqual(clauses("title starts with grounded"), [
|
||
{ condition: 'title', operator: 'beginsWith', value: 'grounded' }
|
||
]);
|
||
assert.deepEqual(clauses("added within the last 3 days"), [
|
||
{ condition: 'dateAdded', operator: 'isInTheLast', value: '3 days' }
|
||
]);
|
||
});
|
||
|
||
it("should compare numbers", function () {
|
||
assert.deepEqual(clauses("number of tags > 5"), [
|
||
{ condition: 'numTags', operator: 'isGreaterThan', value: '5' }
|
||
]);
|
||
assert.deepEqual(clauses("number of notes greater than 2"), [
|
||
{ condition: 'numNotes', operator: 'isGreaterThan', value: '2' }
|
||
]);
|
||
});
|
||
|
||
it("should read a colon after a word operator as punctuation", function () {
|
||
assert.deepEqual(clauses("year before:2020"), [
|
||
{ condition: 'date', operator: 'isBefore', value: '2020' }
|
||
]);
|
||
assert.deepEqual(clauses("year is before:2020"), [
|
||
{ condition: 'date', operator: 'isBefore', value: '2020' }
|
||
]);
|
||
assert.deepEqual(clauses("tag is:foo"), [
|
||
{ condition: 'tag', operator: 'is', value: 'foo' }
|
||
]);
|
||
});
|
||
|
||
it("should support empty and non-empty conditions", function () {
|
||
assert.deepEqual(clauses("creator is empty"), [
|
||
{ condition: 'creator', operator: 'isEmpty', value: '' }
|
||
]);
|
||
assert.deepEqual(clauses("abstract is not empty"), [
|
||
{ condition: 'abstractNote', operator: 'isNotEmpty', value: '' }
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("ranges", function () {
|
||
// The comparisons are exclusive, so a range that takes in 1970 and
|
||
// 2000 is written as after 1969 and before 2001
|
||
let years = {
|
||
joinMode: 'all',
|
||
children: [
|
||
{ condition: 'date', operator: 'isAfter', value: '1969' },
|
||
{ condition: 'date', operator: 'isBefore', value: '2001' }
|
||
]
|
||
};
|
||
|
||
it("should read a range as the comparisons that bracket it", function () {
|
||
assert.deepEqual(clauses("year is between 1970 and 2000"), [years]);
|
||
assert.deepEqual(clauses("year between 1970 and 2000"), [years]);
|
||
});
|
||
|
||
it("should accept a range written without a word between the ends", function () {
|
||
let queries = [
|
||
"year:1970-2000",
|
||
"year is 1970-2000",
|
||
"year:1970..2000",
|
||
"year:1970 to 2000",
|
||
"year from 1970 to 2000",
|
||
"year:1970 - 2000",
|
||
"year:1970–2000",
|
||
// A whole range reads as one word too, for a locale that
|
||
// doesn't put spaces between its words
|
||
"year:1970to2000"
|
||
];
|
||
for (let query of queries) {
|
||
assert.deepEqual(clauses(query), [years], query);
|
||
}
|
||
});
|
||
|
||
it("should step a range end by however much it was written with", function () {
|
||
assert.deepEqual(clauses("added between 2024-02 and 2024-06"), [{
|
||
joinMode: 'all',
|
||
children: [
|
||
{ condition: 'dateAdded', operator: 'isAfter', value: '2024-01' },
|
||
{ condition: 'dateAdded', operator: 'isBefore', value: '2024-07' }
|
||
]
|
||
}]);
|
||
assert.deepEqual(clauses("date:2020-03-01..2020-03-15"), [{
|
||
joinMode: 'all',
|
||
children: [
|
||
{ condition: 'date', operator: 'isAfter', value: '2020-02-29' },
|
||
{ condition: 'date', operator: 'isBefore', value: '2020-03-16' }
|
||
]
|
||
}]);
|
||
});
|
||
|
||
it("should compare counts as numbers", function () {
|
||
assert.deepEqual(clauses("number of tags between 2 and 5"), [{
|
||
joinMode: 'all',
|
||
children: [
|
||
{ condition: 'numTags', operator: 'isGreaterThan', value: '1' },
|
||
{ condition: 'numTags', operator: 'isLessThan', value: '6' }
|
||
]
|
||
}]);
|
||
});
|
||
|
||
it("should match either end when a range is excluded", function () {
|
||
assert.deepEqual(clauses("year is not between 1970 and 2000"), [{
|
||
joinMode: 'any',
|
||
children: [
|
||
{ condition: 'date', operator: 'isBefore', value: '1970' },
|
||
{ condition: 'date', operator: 'isAfter', value: '2000' }
|
||
]
|
||
}]);
|
||
});
|
||
|
||
it("should read a value that isn't a range as a value", function () {
|
||
// A date of its own, a range a condition that doesn't compare
|
||
// can't take, a quoted value, and ends in the wrong order
|
||
assert.deepEqual(clauses("date:2020-01-05"), [
|
||
{ condition: 'date', operator: 'is', value: '2020-01-05' }
|
||
]);
|
||
assert.deepEqual(clauses("title:1970-2000"), [
|
||
{ condition: 'title', operator: 'contains', value: '1970-2000' }
|
||
]);
|
||
assert.deepEqual(clauses('year:"1970-2000"'), [
|
||
{ condition: 'date', operator: 'is', value: '1970-2000' }
|
||
]);
|
||
assert.deepEqual(clauses("year:2000-1970"), [
|
||
{ condition: 'date', operator: 'is', value: '2000-1970' }
|
||
]);
|
||
// `and` joins clauses, so it separates ends only after `between`
|
||
assert.deepEqual(clauses("year:1970 and tag:foo"), [
|
||
{ condition: 'date', operator: 'is', value: '1970' },
|
||
{ condition: 'tag', operator: 'is', value: 'foo' }
|
||
]);
|
||
// Each separator goes with the form it belongs to
|
||
assert.isNull(Zotero.SearchQuery.parse("year is between 1970 to 2000").tree);
|
||
});
|
||
|
||
it("should scope a range on a child field to one child", function () {
|
||
// Both ends are about the same attachment, so the level scopes the
|
||
// pair rather than each comparison
|
||
assert.deepEqual(clauses("attachment last read between 2020 and 2025"), [{
|
||
joinMode: 'all',
|
||
level: 'attachment',
|
||
children: [
|
||
{ condition: 'lastRead', operator: 'isAfter', value: '2019' },
|
||
{ condition: 'lastRead', operator: 'isBefore', value: '2026' }
|
||
]
|
||
}]);
|
||
});
|
||
|
||
it("should match a child in the range, not one at each end", async function () {
|
||
let read = async (parent, year) => {
|
||
let attachment = await importFileAttachment('test.pdf', { parentItemID: parent.id });
|
||
attachment.attachmentLastRead = Math.floor(Date.UTC(year, 5, 15) / 1000);
|
||
await attachment.saveTx();
|
||
};
|
||
let inRange = await createDataObject('item');
|
||
await read(inRange, 2022);
|
||
let straddling = await createDataObject('item');
|
||
await read(straddling, 2010);
|
||
await read(straddling, 2030);
|
||
|
||
let ids = await Zotero.SearchQuery.getSearch(
|
||
"attachment last read between 2020 and 2025",
|
||
{ libraryID: Zotero.Libraries.userLibraryID }
|
||
).search();
|
||
assert.include(ids, inRange.id);
|
||
assert.notInclude(ids, straddling.id);
|
||
});
|
||
|
||
it("should repeat the field of a range for a value after 'or'", function () {
|
||
assert.deepEqual(clauses("year:2020-2025 or 1999"), [
|
||
{
|
||
joinMode: 'all',
|
||
children: [
|
||
{ condition: 'date', operator: 'isAfter', value: '2019' },
|
||
{ condition: 'date', operator: 'isBefore', value: '2026' }
|
||
]
|
||
},
|
||
{ condition: 'date', operator: 'is', value: '1999' }
|
||
]);
|
||
});
|
||
|
||
it("should read a day the month doesn't have as a value", function () {
|
||
// Stepping past the end of the month would widen the range into
|
||
// the month after it
|
||
assert.deepEqual(clauses("date:2023-02-29..2023-02-29"), [
|
||
{ condition: 'date', operator: 'is', value: '2023-02-29..2023-02-29' }
|
||
]);
|
||
// A day the month does have still reads as a range
|
||
assert.deepEqual(clauses("date:2024-02-29..2024-02-29"), [{
|
||
joinMode: 'all',
|
||
children: [
|
||
{ condition: 'date', operator: 'isAfter', value: '2024-02-28' },
|
||
{ condition: 'date', operator: 'isBefore', value: '2024-03-01' }
|
||
]
|
||
}]);
|
||
});
|
||
|
||
it("should recognize a range before both ends are typed", function () {
|
||
let queries = [
|
||
"year is between",
|
||
"year is between 19",
|
||
"year is between 1970 and",
|
||
"year is between 1970 and 19"
|
||
];
|
||
for (let query of queries) {
|
||
// Nothing to match on yet, and not text to search for
|
||
let { tree, text } = Zotero.SearchQuery.parse("crispr " + query);
|
||
assert.isNull(tree, query);
|
||
assert.equal(text, "crispr", query);
|
||
// Every character still belongs to a token, for highlighting
|
||
let tokens = Zotero.SearchQuery.tokenize(query);
|
||
assert.equal(tokens[tokens.length - 1].end, query.length, query);
|
||
}
|
||
});
|
||
|
||
it("should find items dated anywhere in the range, ends included", async function () {
|
||
let items = {};
|
||
for (let date of ['1969', '1970', '1985-06-15', '2000-12-31', '2001']) {
|
||
items[date] = await createDataObject('item', { setTitle: true });
|
||
items[date].setField('date', date);
|
||
await items[date].saveTx();
|
||
}
|
||
|
||
for (let query of ["year is between 1970 and 2000", "year:1970-2000"]) {
|
||
let search = Zotero.SearchQuery.getSearch(query, {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
});
|
||
let ids = await search.search();
|
||
assert.include(ids, items['1970'].id, query);
|
||
assert.include(ids, items['1985-06-15'].id, query);
|
||
assert.include(ids, items['2000-12-31'].id, query);
|
||
assert.notInclude(ids, items['1969'].id, query);
|
||
assert.notInclude(ids, items['2001'].id, query);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("grouping and precedence", function () {
|
||
it("should group with parentheses and join modes", function () {
|
||
let { tree } = Zotero.SearchQuery.parse('by:smith and (tag:foo or tag:bar)');
|
||
assert.equal(tree.joinMode, 'all');
|
||
assert.lengthOf(tree.children, 2);
|
||
assert.equal(tree.children[0].condition, 'creator');
|
||
assert.equal(tree.children[1].joinMode, 'any');
|
||
assert.deepEqual(tree.children[1].children, [
|
||
{ condition: 'tag', operator: 'is', value: 'foo' },
|
||
{ condition: 'tag', operator: 'is', value: 'bar' }
|
||
]);
|
||
});
|
||
|
||
it("should bind 'and' tighter than 'or'", function () {
|
||
let { tree } = Zotero.SearchQuery.parse("by:smith or by:jones and tag:foo");
|
||
assert.equal(tree.joinMode, 'any');
|
||
assert.lengthOf(tree.children, 2);
|
||
assert.equal(tree.children[0].value, 'smith');
|
||
assert.equal(tree.children[1].joinMode, 'all');
|
||
assert.deepEqual(tree.children[1].children.map(c => c.value), ['jones', 'foo']);
|
||
});
|
||
|
||
it("should find what 'a or b and c' means", async function () {
|
||
let smith = await createDataObject('item');
|
||
smith.setCreators([{ firstName: "A", lastName: "Smith", creatorType: "author" }]);
|
||
await smith.saveTx();
|
||
let jonesTagged = await createDataObject('item');
|
||
jonesTagged.setCreators([{ firstName: "B", lastName: "Jones", creatorType: "author" }]);
|
||
jonesTagged.addTag("zzfoo");
|
||
await jonesTagged.saveTx();
|
||
// Jones without the tag is excluded, since `and` binds tighter
|
||
let jonesOnly = await createDataObject('item');
|
||
jonesOnly.setCreators([{ firstName: "C", lastName: "Jones", creatorType: "author" }]);
|
||
await jonesOnly.saveTx();
|
||
|
||
let search = Zotero.SearchQuery.getSearch("by:smith or by:jones and tag:zzfoo", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
});
|
||
let ids = await search.search();
|
||
assert.include(ids, smith.id);
|
||
assert.include(ids, jonesTagged.id);
|
||
assert.notInclude(ids, jonesOnly.id);
|
||
});
|
||
|
||
it("should treat adjacency as 'and' for precedence", function () {
|
||
let tree = Zotero.SearchQuery.parse("by:smith tag:a or tag:b").tree;
|
||
assert.equal(tree.joinMode, 'any');
|
||
assert.equal(tree.children[0].joinMode, 'all');
|
||
assert.deepEqual(tree.children[0].children.map(c => c.value), ['smith', 'a']);
|
||
assert.equal(tree.children[1].value, 'b');
|
||
|
||
tree = Zotero.SearchQuery.parse("by:smith or by:jones tag:foo").tree;
|
||
assert.equal(tree.joinMode, 'any');
|
||
assert.equal(tree.children[0].value, 'smith');
|
||
assert.deepEqual(tree.children[1].children.map(c => c.value), ['jones', 'foo']);
|
||
});
|
||
|
||
it("should keep parentheses that aren't grouping clauses", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("by:smith The (Dis)United States");
|
||
assert.lengthOf(tree.children, 1);
|
||
assert.equal(text, "The (Dis)United States");
|
||
});
|
||
});
|
||
|
||
describe("repeated values after 'or'", function () {
|
||
it("should repeat the preceding field for a bare value", function () {
|
||
let { tree } = Zotero.SearchQuery.parse('creator is "smith" and (tag is foo or bar)');
|
||
assert.deepEqual(tree.children[1].children, [
|
||
{ condition: 'tag', operator: 'is', value: 'foo' },
|
||
{ condition: 'tag', operator: 'is', value: 'bar' }
|
||
]);
|
||
});
|
||
|
||
it("should read a repeated value the same way as the first", function () {
|
||
assert.deepEqual(clauses('type:book or "journal article"').map(c => c.value),
|
||
['book', 'journalArticle']);
|
||
assert.deepEqual(clauses("annotation color:red or blue").map(c => c.value),
|
||
['#ff6666', '#2ea8e5']);
|
||
// A value the condition doesn't accept isn't a clause at all
|
||
let { tree, text } = Zotero.SearchQuery.parse("type:book or widget");
|
||
assert.lengthOf(tree.children, 1);
|
||
assert.equal(tree.children[0].value, 'book');
|
||
// The join word itself isn't text to search for
|
||
assert.equal(text, "widget");
|
||
});
|
||
|
||
it("should repeat the operator along with the field", function () {
|
||
assert.deepEqual(clauses("added in the last 2 days or 3 days"), [
|
||
{ condition: 'dateAdded', operator: 'isInTheLast', value: '2 days' },
|
||
{ condition: 'dateAdded', operator: 'isInTheLast', value: '3 days' }
|
||
]);
|
||
assert.equal(
|
||
Zotero.SearchQuery.parse("added in the last 2 days or 3 days").text, ""
|
||
);
|
||
});
|
||
|
||
it("should repeat only the clause the 'or' follows", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("by:smith research or development");
|
||
assert.deepEqual(tree.children, [
|
||
{ condition: 'creator', operator: 'contains', value: 'smith' }
|
||
]);
|
||
assert.equal(text, "research development");
|
||
});
|
||
|
||
it("should not repeat the preceding field after 'and'", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("by:smith and crispr");
|
||
assert.deepEqual(tree.children, [
|
||
{ condition: 'creator', operator: 'contains', value: 'smith' }
|
||
]);
|
||
assert.equal(text, "crispr");
|
||
});
|
||
|
||
it("should not repeat a field into something written as a clause", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("tag:foo or type:widget");
|
||
assert.deepEqual(tree.children, [
|
||
{ condition: 'tag', operator: 'is', value: 'foo' }
|
||
]);
|
||
assert.equal(text, "type:widget");
|
||
});
|
||
});
|
||
|
||
describe("quoting", function () {
|
||
it("should keep quoted values together", function () {
|
||
assert.deepEqual(clauses('tag:"to read"'), [
|
||
{ condition: 'tag', operator: 'is', value: 'to read' }
|
||
]);
|
||
});
|
||
|
||
it("should accept curly quotes and a spaced colon", function () {
|
||
assert.deepEqual(clauses("by:“Mary Ann Test”"), [
|
||
{ condition: 'creator', operator: 'contains', value: 'Mary Ann Test' }
|
||
]);
|
||
assert.deepEqual(clauses("tag : foo"), [
|
||
{ condition: 'tag', operator: 'is', value: 'foo' }
|
||
]);
|
||
});
|
||
|
||
it("should read an unterminated quote as text", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse('by:"Mary Ann');
|
||
assert.isNull(tree);
|
||
assert.equal(text, 'by:"Mary Ann');
|
||
});
|
||
|
||
it("should format a value so it reads back as one value", function () {
|
||
for (let value of ['to read', '"Death is different"', 'it\'s "fine" (really)']) {
|
||
let query = 'tag:' + Zotero.SearchQuery.formatValue(value);
|
||
assert.deepEqual(clauses(query), [
|
||
{ condition: 'tag', operator: 'is', value }
|
||
], query);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("invalid input", function () {
|
||
it("should ignore an unknown field name", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("colour:blue");
|
||
assert.isNull(tree);
|
||
assert.equal(text, "colour:blue");
|
||
});
|
||
|
||
it("should ignore an operator the field doesn't take", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("type before book");
|
||
assert.isNull(tree);
|
||
assert.include(text, "type");
|
||
});
|
||
|
||
it("should read an unusable clause as text", function () {
|
||
for (let query of ["annotation color:chartreuse", 'title:""', "tag: ("]) {
|
||
let { tree } = Zotero.SearchQuery.parse(query);
|
||
assert.isNull(tree, query);
|
||
}
|
||
});
|
||
|
||
it("should reject a value a condition's own filter rejects", function () {
|
||
for (let query of ["number of tags > five", "number of tags:foo"]) {
|
||
assert.isNull(Zotero.SearchQuery.parse(query).tree, query);
|
||
}
|
||
assert.deepEqual(clauses("number of tags > 5"), [
|
||
{ condition: 'numTags', operator: 'isGreaterThan', value: '5' }
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("values shown in the interface", function () {
|
||
it("should accept an item type by its displayed name", function () {
|
||
for (let query of ["type:\"journal article\"", "type is journal article"]) {
|
||
assert.equal(clauses(query)[0].value, 'journalArticle', query);
|
||
}
|
||
assert.equal(clauses("type is Book Section")[0].value, 'bookSection');
|
||
// The stored name is Zotero's, not something to type
|
||
assert.isEmpty(clauses("type:journalArticle"));
|
||
});
|
||
|
||
it("should accept an annotation color by name", function () {
|
||
assert.deepEqual(clauses("annotation color:yellow"), [
|
||
{
|
||
condition: 'annotationColor',
|
||
operator: 'is',
|
||
value: '#ffd400',
|
||
level: 'annotation'
|
||
}
|
||
]);
|
||
// The stored hex is Zotero's, not something to type
|
||
assert.isEmpty(clauses("annotation color:#5fb236"));
|
||
});
|
||
|
||
it("should accept 'color' for annotation color", function () {
|
||
let { tree } = Zotero.SearchQuery.parse("type is annotation and color is red");
|
||
assert.deepEqual(tree.children[1], {
|
||
condition: 'annotationColor',
|
||
operator: 'is',
|
||
value: '#ff6666',
|
||
level: 'annotation'
|
||
});
|
||
assert.deepEqual(clauses("color:yellow"), [
|
||
{
|
||
condition: 'annotationColor',
|
||
operator: 'is',
|
||
value: '#ffd400',
|
||
level: 'annotation'
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("should store an annotation type as its id", function () {
|
||
assert.deepEqual(clauses("annotation type:highlight"), [
|
||
{
|
||
condition: 'annotationType',
|
||
operator: 'is',
|
||
value: String(Zotero.Annotations.ANNOTATION_TYPE_HIGHLIGHT),
|
||
level: 'annotation'
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("should accept storage types, including web links", function () {
|
||
assert.deepEqual(clauses('attachment storage type:"Web Link"').map(c => c.value),
|
||
['webLink']);
|
||
assert.deepEqual(clauses('attachment storage type:"Linked File"').map(c => c.value),
|
||
['linkedFile']);
|
||
});
|
||
|
||
it("should read an unknown value as text", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("type:widget");
|
||
assert.isNull(tree);
|
||
assert.equal(text, "type:widget");
|
||
});
|
||
});
|
||
|
||
describe("name conditions", function () {
|
||
it("should match within a name for 'is', which compares the full name", function () {
|
||
assert.deepEqual(clauses("creator is okonkwo"), [
|
||
{ condition: 'creator', operator: 'contains', value: 'okonkwo' }
|
||
]);
|
||
assert.deepEqual(clauses("author is not okonkwo"), [
|
||
{ condition: 'author', operator: 'doesNotContain', value: 'okonkwo' }
|
||
]);
|
||
});
|
||
|
||
it("should find an item by a creator's last name", async function () {
|
||
let item = await createDataObject('item');
|
||
item.setCreators([
|
||
{ firstName: "Adaeze", lastName: "Okonkwo", creatorType: "author" }
|
||
]);
|
||
await item.saveTx();
|
||
|
||
for (let query of ["creator is okonkwo", "by:okonkwo", "creator is Adaeze"]) {
|
||
let search = Zotero.SearchQuery.getSearch(query, {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
});
|
||
assert.include(await search.search(), item.id, query);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("multi-word names", function () {
|
||
it("should parse a field name of several words", function () {
|
||
assert.deepEqual(clauses("annotation comment:unclear"), [
|
||
{
|
||
condition: 'annotationComment',
|
||
operator: 'contains',
|
||
value: 'unclear',
|
||
level: 'annotation'
|
||
}
|
||
]);
|
||
});
|
||
|
||
it("should accept a condition by the name shown in the interface", function () {
|
||
let localized = Zotero.SearchConditions.getLocalizedName('publicationTitle');
|
||
assert.deepEqual(clauses(localized + ":Nature"), [
|
||
{ condition: 'publicationTitle', operator: 'contains', value: 'Nature' }
|
||
]);
|
||
// The stored name is Zotero's, not something to type
|
||
assert.isFalse(Zotero.SearchQuery.getField('publicationTitle'));
|
||
});
|
||
|
||
it("should scope a child-item field to its level", function () {
|
||
assert.deepEqual(clauses("attachment tag:important"), [
|
||
{ condition: 'tag', operator: 'is', value: 'important', level: 'attachment' }
|
||
]);
|
||
// A condition with a level of its own is scoped to it as well
|
||
assert.deepEqual(clauses("annotation text:important"), [
|
||
{
|
||
condition: 'annotationText',
|
||
operator: 'contains',
|
||
value: 'important',
|
||
level: 'annotation'
|
||
}
|
||
]);
|
||
// Bare `annotation` could mean the text or the comment, so it's
|
||
// neither
|
||
assert.isNull(Zotero.SearchQuery.parse("annotation:important").tree);
|
||
});
|
||
});
|
||
|
||
describe("'has' and 'no' shorthand", function () {
|
||
it("should read 'has <condition>' and 'no <condition>' as non-empty and empty conditions", function () {
|
||
assert.deepEqual(clauses('type is "journal article" and no doi'), [
|
||
{ condition: 'itemType', operator: 'is', value: 'journalArticle' },
|
||
{ condition: 'DOI', operator: 'isEmpty', value: '' }
|
||
]);
|
||
assert.deepEqual(clauses("has doi"), [
|
||
{ condition: 'DOI', operator: 'isNotEmpty', value: '' }
|
||
]);
|
||
// A word that isn't a condition keeps the `no` as text
|
||
let { tree, text } = Zotero.SearchQuery.parse("no dice");
|
||
assert.isNull(tree);
|
||
assert.equal(text, "no dice");
|
||
// Before a clause of its own, the `no` is text as well
|
||
({ tree, text } = Zotero.SearchQuery.parse("no title:foo"));
|
||
assert.deepEqual(tree.children, [
|
||
{ condition: 'title', operator: 'contains', value: 'foo' }
|
||
]);
|
||
assert.equal(text, "no");
|
||
});
|
||
|
||
it("should compare counts for a count-backed target", async function () {
|
||
assert.deepEqual(clauses("no annotation"), [
|
||
{ condition: 'numAnnotations', operator: 'is', value: '0' }
|
||
]);
|
||
assert.deepEqual(clauses("no tag"), [
|
||
{ condition: 'numTags', operator: 'is', value: '0' }
|
||
]);
|
||
assert.deepEqual(clauses("has attachment"), [
|
||
{ condition: 'numAttachments', operator: 'isGreaterThan', value: '0' }
|
||
]);
|
||
|
||
// An image annotation with no text is still an annotation
|
||
let item = await createDataObject('item');
|
||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||
await createAnnotation('image', attachment);
|
||
let bare = await createDataObject('item');
|
||
|
||
let ids = await Zotero.SearchQuery.getSearch("has annotation", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.include(ids, item.id);
|
||
assert.notInclude(ids, bare.id);
|
||
|
||
ids = await Zotero.SearchQuery.getSearch("no annotation", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.include(ids, bare.id);
|
||
assert.notInclude(ids, item.id);
|
||
});
|
||
|
||
it("should read the plural forms as counts", function () {
|
||
assert.deepEqual(clauses("no tags"), [
|
||
{ condition: 'numTags', operator: 'is', value: '0' }
|
||
]);
|
||
assert.deepEqual(clauses("has attachments"), [
|
||
{ condition: 'numAttachments', operator: 'isGreaterThan', value: '0' }
|
||
]);
|
||
});
|
||
|
||
it("should keep 'no' on a child-scoped field as text", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("no attachment title");
|
||
assert.isNull(tree);
|
||
assert.equal(text, "no attachment title");
|
||
// `has` on one means a child with the field filled in
|
||
assert.deepEqual(clauses("has attachment title"), [
|
||
{ condition: 'title', operator: 'isNotEmpty', value: '', level: 'attachment' }
|
||
]);
|
||
});
|
||
|
||
it("should read 'no' after 'or' as a condition, not a repeated value", function () {
|
||
let { tree, text } = Zotero.SearchQuery.parse("tag:foo or no doi");
|
||
assert.equal(tree.joinMode, 'any');
|
||
assert.deepEqual(tree.children, [
|
||
{ condition: 'tag', operator: 'is', value: 'foo' },
|
||
{ condition: 'DOI', operator: 'isEmpty', value: '' }
|
||
]);
|
||
assert.equal(text, "");
|
||
});
|
||
});
|
||
|
||
describe("#getSearch()", function () {
|
||
it("should return false for a query with no clauses", function () {
|
||
assert.isFalse(Zotero.SearchQuery.getSearch("crispr screens"));
|
||
});
|
||
|
||
it("should match free text the way the mode does", function () {
|
||
// A quicksearch condition expands into the fields it covers
|
||
let conditions = mode => Object.values(
|
||
Zotero.SearchQuery.getSearch("by:smith crispr", { mode }).getConditions()
|
||
).map(condition => condition.condition);
|
||
assert.notInclude(conditions('fields'), 'fulltextContent');
|
||
assert.include(conditions('fields'), 'tag');
|
||
assert.include(conditions('everything'), 'fulltextContent');
|
||
// Best Match with no index falls back to matching text
|
||
assert.notInclude(conditions('bestMatch'), 'bestMatch');
|
||
});
|
||
|
||
it("should find items matching both the clauses and the text", async function () {
|
||
let match = await createDataObject('item', { title: "Glucose monitoring in the wild" });
|
||
match.setCreators([{ firstName: "Ada", lastName: "Nakamura", creatorType: "author" }]);
|
||
await match.saveTx();
|
||
let wrongText = await createDataObject('item', { title: "Something else entirely" });
|
||
wrongText.setCreators([{ firstName: "Ada", lastName: "Nakamura", creatorType: "author" }]);
|
||
await wrongText.saveTx();
|
||
|
||
let search = Zotero.SearchQuery.getSearch("by:nakamura glucose", {
|
||
libraryID: Zotero.Libraries.userLibraryID,
|
||
mode: 'fields'
|
||
});
|
||
let ids = await search.search();
|
||
assert.include(ids, match.id);
|
||
assert.notInclude(ids, wrongText.id);
|
||
});
|
||
|
||
it("should keep an 'any' query separate from the free text", async function () {
|
||
let tagged = await createDataObject('item', { title: "Glucose sensing" });
|
||
tagged.addTag("wearables");
|
||
await tagged.saveTx();
|
||
let otherTag = await createDataObject('item', { title: "Glucose sensing" });
|
||
otherTag.addTag("clinical");
|
||
await otherTag.saveTx();
|
||
let noMatch = await createDataObject('item', { title: "Unrelated" });
|
||
noMatch.addTag("wearables");
|
||
await noMatch.saveTx();
|
||
|
||
let search = Zotero.SearchQuery.getSearch("(tag:wearables or tag:clinical) glucose", {
|
||
libraryID: Zotero.Libraries.userLibraryID,
|
||
mode: 'fields'
|
||
});
|
||
let ids = await search.search();
|
||
assert.include(ids, tagged.id);
|
||
assert.include(ids, otherTag.id);
|
||
assert.notInclude(ids, noMatch.id);
|
||
});
|
||
|
||
it("should match items by conditions on their children", async function () {
|
||
// The tag lives on the attachment, the type on the item
|
||
let book = await createDataObject('item', { itemType: 'book' });
|
||
let attachment = await importFileAttachment('test.pdf', { parentItemID: book.id });
|
||
attachment.addTag('zzchildtag');
|
||
await attachment.saveTx();
|
||
let untagged = await createDataObject('item', { itemType: 'book' });
|
||
|
||
let ids = await Zotero.SearchQuery.getSearch("type:book tag:zzchildtag", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.include(ids, book.id);
|
||
assert.notInclude(ids, untagged.id);
|
||
assert.notInclude(ids, attachment.id);
|
||
|
||
// Attachment content alongside an item-level condition
|
||
let article = await createDataObject('item', { itemType: 'journalArticle' });
|
||
await importFileAttachment('search/foobar.html', { parentItemID: article.id });
|
||
ids = await Zotero.SearchQuery.getSearch(
|
||
'type:"journal article" attachment content:"foo bar"',
|
||
{ libraryID: Zotero.Libraries.userLibraryID }
|
||
).search();
|
||
assert.include(ids, article.id);
|
||
assert.notInclude(ids, book.id);
|
||
});
|
||
|
||
it("should scope a condition that matches at a child level", async function () {
|
||
let item = await createDataObject('item');
|
||
item.setCreators([{ firstName: "Ada", lastName: "Ferrer", creatorType: "author" }]);
|
||
await item.saveTx();
|
||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||
let annotation = await createAnnotation('highlight', attachment);
|
||
annotation.annotationText = 'zzmarker';
|
||
await annotation.saveTx();
|
||
|
||
let search = Zotero.SearchQuery.getSearch("by:ferrer annotation text:zzmarker", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
});
|
||
assert.sameMembers(await search.search(), [item.id]);
|
||
});
|
||
|
||
it("should return child rows for a child item type", async function () {
|
||
let item = await createDataObject('item');
|
||
item.setCreators([{ firstName: 'Ada', lastName: 'Zzpin', creatorType: 'author' }]);
|
||
await item.saveTx();
|
||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||
let other = await createDataObject('item');
|
||
let otherAttachment = await importFileAttachment('test.pdf', { parentItemID: other.id });
|
||
|
||
// The type names the rows to return; other conditions map to them
|
||
let ids = await Zotero.SearchQuery.getSearch("type:attachment by:zzpin", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.sameMembers(ids, [attachment.id]);
|
||
|
||
// Alone, every attachment
|
||
ids = await Zotero.SearchQuery.getSearch("type:attachment", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.include(ids, attachment.id);
|
||
assert.include(ids, otherAttachment.id);
|
||
assert.notInclude(ids, item.id);
|
||
|
||
// Annotations are rows too
|
||
let annotation = await createAnnotation('highlight', attachment);
|
||
ids = await Zotero.SearchQuery.getSearch("type:annotation by:zzpin", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.sameMembers(ids, [annotation.id]);
|
||
|
||
// Negated, it's an ordinary condition on items
|
||
ids = await Zotero.SearchQuery.getSearch("type is not attachment", {
|
||
libraryID: Zotero.Libraries.userLibraryID
|
||
}).search();
|
||
assert.include(ids, item.id);
|
||
assert.notInclude(ids, attachment.id);
|
||
});
|
||
});
|
||
|
||
describe("#addToSearch()", function () {
|
||
it("should build a search that matches the right items", async function () {
|
||
let match = await createDataObject('item', {
|
||
itemType: 'book',
|
||
title: "Grounded theory and its discontents"
|
||
});
|
||
match.setCreators([{ firstName: "Alice", lastName: "Smith", creatorType: "author" }]);
|
||
match.addTag("methodology");
|
||
await match.saveTx();
|
||
|
||
let wrongCreator = await createDataObject('item', { itemType: 'book' });
|
||
wrongCreator.addTag("methodology");
|
||
await wrongCreator.saveTx();
|
||
|
||
let wrongTag = await createDataObject('item', { itemType: 'book' });
|
||
wrongTag.setCreators([{ firstName: "Alice", lastName: "Smith", creatorType: "author" }]);
|
||
await wrongTag.saveTx();
|
||
|
||
let { tree } = Zotero.SearchQuery.parse('by:smith and (tag:methodology or tag:missing)');
|
||
let search = new Zotero.Search();
|
||
search.libraryID = Zotero.Libraries.userLibraryID;
|
||
Zotero.SearchQuery.addToSearch(search, tree);
|
||
let ids = await search.search();
|
||
|
||
assert.include(ids, match.id);
|
||
assert.notInclude(ids, wrongCreator.id);
|
||
assert.notInclude(ids, wrongTag.id);
|
||
});
|
||
});
|
||
|
||
describe("#tokenize()", function () {
|
||
it("should cover every character of the input", function () {
|
||
let query = 'by:smith and (tag:"to read" or bar) crispr';
|
||
let tokens = Zotero.SearchQuery.tokenize(query);
|
||
assert.equal(tokens[0].start, 0);
|
||
assert.equal(tokens[tokens.length - 1].end, query.length);
|
||
for (let i = 1; i < tokens.length; i++) {
|
||
assert.equal(tokens[i].start, tokens[i - 1].end);
|
||
}
|
||
});
|
||
|
||
it("should recognize a condition before its value is typed", function () {
|
||
let types = Zotero.SearchQuery.tokenize("tag:")
|
||
.map(token => token.type);
|
||
assert.deepEqual(types, ['field', 'operator']);
|
||
|
||
// Nothing to match on yet, and not text to search for
|
||
let { tree, text } = Zotero.SearchQuery.parse("crispr tag:");
|
||
assert.isNull(tree);
|
||
assert.equal(text, "crispr");
|
||
assert.isFalse(Zotero.SearchQuery.getSearch("tag:"));
|
||
});
|
||
|
||
it("should label field, operator, and value tokens for highlighting", function () {
|
||
let types = Zotero.SearchQuery.tokenize("by:smith crispr")
|
||
.filter(token => token.type !== 'space')
|
||
.map(token => token.type);
|
||
assert.deepEqual(types, ['field', 'operator', 'value', 'text']);
|
||
});
|
||
});
|
||
|
||
describe("multiple libraries", function () {
|
||
it("should match in whatever library the results come from", async function () {
|
||
let group = await createGroup();
|
||
let groupItem = await createDataObject('item',
|
||
{ libraryID: group.libraryID, tags: [{ tag: 'zzlib' }] });
|
||
|
||
let query = Zotero.SearchQuery.getSearch('tag:zzlib');
|
||
assert.isNull(query.libraryID);
|
||
|
||
// Scoped to a row the way CollectionTreeRow scopes a filter search
|
||
let scope = new Zotero.Search();
|
||
scope.libraryID = group.libraryID;
|
||
scope.addCondition('noChildren', 'true');
|
||
let scoped = new Zotero.Search();
|
||
scoped.fromJSON(query.toJSON());
|
||
scoped.setScope(scope, true);
|
||
assert.sameMembers(await scoped.search(), [groupItem.id]);
|
||
});
|
||
});
|
||
|
||
describe("#getCompletions()", function () {
|
||
let labels = (query, caret) => (Zotero.SearchQuery.getCompletions(query, caret)
|
||
|| { completions: [] }).completions.map(c => c.label);
|
||
|
||
it("should complete a condition name partway through a word", function () {
|
||
let completions = Zotero.SearchQuery.getCompletions("ta");
|
||
assert.equal(completions.type, 'field');
|
||
assert.equal(completions.start, 0);
|
||
assert.equal(completions.end, 2);
|
||
assert.include(completions.completions.map(c => c.label), "tag:");
|
||
// One row per condition, by its shortest name
|
||
assert.notInclude(completions.completions.map(c => c.label), "tag name:");
|
||
});
|
||
|
||
it("should complete a condition name of several words", function () {
|
||
let completions = Zotero.SearchQuery.getCompletions("annotation ty");
|
||
assert.deepEqual(completions.completions.map(c => c.label), ["annotation type:"]);
|
||
assert.equal(completions.start, 0);
|
||
|
||
// The words before it are still text
|
||
completions = Zotero.SearchQuery.getCompletions("crispr annotation ty");
|
||
assert.deepEqual(completions.completions.map(c => c.label), ["annotation type:"]);
|
||
assert.equal(completions.start, "crispr ".length);
|
||
});
|
||
|
||
it("should complete a condition name after other text", function () {
|
||
let completions = Zotero.SearchQuery.getCompletions("crispr ta");
|
||
assert.equal(completions.start, 7);
|
||
assert.include(completions.completions.map(c => c.label), "tag:");
|
||
});
|
||
|
||
it("should complete the values a condition takes", function () {
|
||
assert.include(labels("type:"), "journal article");
|
||
assert.include(labels("type:jour"), "journal article");
|
||
// Inserted so that the parser reads it back
|
||
let completions = Zotero.SearchQuery.getCompletions("type:jour");
|
||
let article = completions.completions.find(c => c.label === "journal article");
|
||
assert.equal(article.text, '"journal article"');
|
||
assert.equal(completions.start, "type:".length);
|
||
|
||
assert.include(labels("annotation color:"), "yellow");
|
||
// A color value carries its color, for the swatch in the list
|
||
let yellow = Zotero.SearchQuery.getCompletions("color:").completions
|
||
.find(c => c.label === "yellow");
|
||
assert.equal(yellow.color, '#ffd400');
|
||
});
|
||
|
||
it("should replace an opening quote along with the value being completed", function () {
|
||
let head = 'type:"jour';
|
||
let completions = Zotero.SearchQuery.getCompletions(head);
|
||
let article = completions.completions.find(c => c.label === "journal article");
|
||
let result = head.slice(0, completions.start) + article.text;
|
||
assert.equal(result, 'type:"journal article"');
|
||
assert.equal(clauses(result)[0].value, 'journalArticle');
|
||
});
|
||
|
||
it("should defer to a lookup for values that come from the library", function () {
|
||
let completions = Zotero.SearchQuery.getCompletions("tag:zz");
|
||
assert.equal(completions.lookup.fieldName, 'tag');
|
||
assert.equal(completions.prefix, 'zz');
|
||
assert.equal(completions.start, "tag:".length);
|
||
assert.isEmpty(completions.completions);
|
||
|
||
// Creator conditions search both single- and two-field names
|
||
completions = Zotero.SearchQuery.getCompletions("by:smi");
|
||
assert.equal(completions.lookup.fieldName, 'creator');
|
||
assert.equal(completions.lookup.fieldMode, 2);
|
||
|
||
// The word form of an operator reads the same as the colon
|
||
completions = Zotero.SearchQuery.getCompletions("creator is a");
|
||
assert.equal(completions.lookup.fieldName, 'creator');
|
||
assert.equal(completions.prefix, 'a');
|
||
assert.equal(completions.start, "creator is ".length);
|
||
completions = Zotero.SearchQuery.getCompletions("creator is not a");
|
||
assert.equal(completions.prefix, 'a');
|
||
// The last condition in the query is the one being typed
|
||
completions = Zotero.SearchQuery.getCompletions("tag is x creator is a");
|
||
assert.equal(completions.lookup.fieldName, 'creator');
|
||
assert.equal(completions.prefix, 'a');
|
||
assert.include(labels("item type is boo"), "book");
|
||
|
||
// Nothing typed yet, so there's nothing to narrow the library to
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("tag:"));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("by:"));
|
||
// A condition with a fixed set of values still offers all of them
|
||
assert.include(labels("type:"), "journal article");
|
||
});
|
||
|
||
it("should complete a value repeated with 'or'", function () {
|
||
let completions = Zotero.SearchQuery.getCompletions("type:book or jour");
|
||
assert.include(completions.completions.map(c => c.label), "journal article");
|
||
assert.equal(completions.start, "type:book or ".length);
|
||
// A library lookup, as for the first value
|
||
completions = Zotero.SearchQuery.getCompletions("tag:foo or zz");
|
||
assert.equal(completions.lookup.fieldName, 'tag');
|
||
assert.equal(completions.prefix, 'zz');
|
||
});
|
||
|
||
it("should complete a condition after 'has' or 'no' without a colon", function () {
|
||
let head = "type:book and no d";
|
||
let completions = Zotero.SearchQuery.getCompletions(head);
|
||
let doi = completions.completions.find(c => c.label === 'doi');
|
||
assert.isDefined(doi);
|
||
let result = head.slice(0, completions.start) + doi.text;
|
||
assert.deepEqual(clauses(result)[1],
|
||
{ condition: 'DOI', operator: 'isEmpty', value: '' });
|
||
// Only conditions that can be empty are offered
|
||
let names = labels("no t");
|
||
assert.include(names, 'title');
|
||
assert.notInclude(names, 'type');
|
||
assert.notInclude(names, 'type:');
|
||
assert.include(labels("has d"), 'doi');
|
||
// Count-backed targets complete by their bare names, even ones
|
||
// that aren't conditions on their own
|
||
assert.include(labels("no att"), 'attachment');
|
||
// The plural forms work but complete by the shortest name
|
||
let tagNames = labels("no ta");
|
||
assert.include(tagNames, 'tag');
|
||
assert.notInclude(tagNames, 'tags');
|
||
});
|
||
|
||
it("should have nothing to offer for free text", function () {
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("crispr "));
|
||
// Partway through a word, where a completion would leave a tail
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("tag:foo", 2));
|
||
});
|
||
|
||
it("should offer nothing inside an unfinished quote", function () {
|
||
assert.isNull(Zotero.SearchQuery.getCompletions('"annotation t'));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions('title:"annotation t'));
|
||
});
|
||
|
||
it("should not read a possible operator as a new field", function () {
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("year is b"));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("title does not c"));
|
||
});
|
||
|
||
it("should offer nothing after an operator the condition doesn't take", function () {
|
||
// The word at the value position gets neither the values the
|
||
// operator can't apply to nor a new-field suggestion
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("type before boo"));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("type is before boo"));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("annotation color greater than y"));
|
||
});
|
||
|
||
it("should not offer a field for the value being typed", function () {
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("title is ta"));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("title contains ta"));
|
||
assert.isNull(Zotero.SearchQuery.getCompletions("title is before"));
|
||
// A word after a complete clause can still start one
|
||
let completions = Zotero.SearchQuery.getCompletions("title is grounded ta");
|
||
assert.include(completions.completions.map(c => c.label), "tag:");
|
||
});
|
||
});
|
||
});
|