2026-10-02 22:32:33 +08:00
|
|
|
using YKanBan.Search;
|
|
|
|
|
|
|
|
|
|
namespace YKanBan.Tests.Search;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Tests for the search parser: the full grammar branches plus the strict
|
|
|
|
|
/// failure behavior (malformed input always throws
|
|
|
|
|
/// <see cref="SearchSyntaxException"/>).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestClass]
|
2026-10-04 10:22:02 +08:00
|
|
|
public class SearchQueryParserTests {
|
2026-10-02 22:32:33 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// Parses and asserts structural equality against the expected AST.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="expected">The expected AST.</param>
|
|
|
|
|
/// <param name="text">The search text.</param>
|
2026-10-04 10:22:02 +08:00
|
|
|
private static void AssertParsesTo(SearchNode expected, string text) {
|
2026-10-02 22:32:33 +08:00
|
|
|
SearchNode? parsed = SearchQueryParser.Parse(text);
|
|
|
|
|
Assert.IsNotNull(parsed, $"Blank-looking input: '{text}'.");
|
|
|
|
|
Assert.AreEqual(expected, parsed, SearchNodeComparer.Instance);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Asserts that the input violates the grammar.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="text">The search text.</param>
|
2026-10-04 10:22:02 +08:00
|
|
|
private static void AssertSyntaxError(string text) {
|
2026-10-02 22:32:33 +08:00
|
|
|
Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse(text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#region Successful parses
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void BlankInputReturnsNull() {
|
2026-10-02 22:32:33 +08:00
|
|
|
Assert.IsNull(SearchQueryParser.Parse(string.Empty));
|
|
|
|
|
Assert.IsNull(SearchQueryParser.Parse(" "));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void SingleWordIsFreeText() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.FreeText("login"), "login");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void AdjacentWordsAreImplicitAnd() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]),
|
|
|
|
|
"fix login");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void ExplicitAndKeyword() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
|
|
|
|
"a AND b");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void OrKeyword() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
|
|
|
|
"a OR b");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void ImplicitAndBindsTighterThanOr() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.Or([
|
|
|
|
|
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
|
|
|
|
new SearchNode.FreeText("c")]),
|
|
|
|
|
"a b OR c");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void ParenthesesForceGrouping() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.And([
|
|
|
|
|
new SearchNode.FreeText("a"),
|
|
|
|
|
new SearchNode.Or([new SearchNode.FreeText("b"), new SearchNode.FreeText("c")])]),
|
|
|
|
|
"a (b OR c)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void NestedParenthesesCollapse() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
|
|
|
|
"((a OR b))");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void LowercaseKeywordsArePlainWords() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.And([
|
|
|
|
|
new SearchNode.FreeText("a"),
|
|
|
|
|
new SearchNode.FreeText("and"),
|
|
|
|
|
new SearchNode.FreeText("or"),
|
|
|
|
|
new SearchNode.FreeText("b")]),
|
|
|
|
|
"a and or b");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void PhraseIsFreeText() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void QuotedOperatorKeywordIsFreeText() {
|
2026-10-02 22:32:33 +08:00
|
|
|
// A quoted AND/OR is literal text, not an operator.
|
|
|
|
|
AssertParsesTo(new SearchNode.FreeText("AND"), "\"AND\"");
|
|
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("OR")]),
|
|
|
|
|
"a \"OR\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void PhraseSupportsEscapes() {
|
2026-10-02 22:32:33 +08:00
|
|
|
// Escaped quote and escaped backslash.
|
|
|
|
|
AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\"");
|
|
|
|
|
AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void TagQualifier() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void TagQualifierAcceptsPhraseValue() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 16:10:14 +08:00
|
|
|
public void QuotedQualifierValueMayContainColonsAndOperators() {
|
|
|
|
|
AssertParsesTo(new SearchNode.TagName("a:b"), "tag:\"a:b\"");
|
|
|
|
|
AssertParsesTo(new SearchNode.TagName("AND"), "tag:\"AND\"");
|
2026-10-02 22:32:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void TitleAndContentQualifiers() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.FieldText(SearchField.Title, "foo"), "title:foo");
|
|
|
|
|
AssertParsesTo(new SearchNode.FieldText(SearchField.Content, "foo"), "content:foo");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void ColumnQualifierAcceptsPhraseValue() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void IdQualifierParsesNumber() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertParsesTo(new SearchNode.CardId(42), "id:42");
|
2026-10-04 16:10:14 +08:00
|
|
|
AssertParsesTo(new SearchNode.CardId(61), "id:#61");
|
2026-10-02 22:32:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 16:10:14 +08:00
|
|
|
public void QuotedTextWithColonIsFreeText() {
|
|
|
|
|
AssertParsesTo(new SearchNode.FreeText("http://x"), "\"http://x\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void WordMayContainHashAndPunctuation() {
|
|
|
|
|
AssertParsesTo(new SearchNode.FreeText("#61"), "#61");
|
|
|
|
|
AssertParsesTo(new SearchNode.FreeText("c#/.net"), "c#/.net");
|
2026-10-02 22:32:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Strict failures
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void UnterminatedPhraseIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("\"abc def");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void TrailingUnterminatedPhraseIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("a \"abc");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void InvalidEscapeInPhraseIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
// A bare backslash (not followed by a quote or another backslash) is invalid.
|
|
|
|
|
AssertSyntaxError("\"a\\b\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void InvalidEscapeInQualifierPhraseIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("tag:\"a\\x\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void UnbalancedOpenParenIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("(a");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void StrayCloseParenIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("a)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void EmptyParensAreAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("()");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void DanglingOrIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("a OR");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void DanglingAndIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("a AND");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void LeadingAndIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("AND a");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void OperatorAtOperandPositionIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("a AND OR b");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void QualifierWithoutValueIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("tag:");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void SpacedQualifierValueIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("tag: bug");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 16:10:14 +08:00
|
|
|
[DataRow("tag: \"x\"")]
|
|
|
|
|
[DataRow("title: \"a b\"")]
|
|
|
|
|
public void SpacedQualifierPhraseValueIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("a\"b\"")]
|
|
|
|
|
[DataRow("\"a\"b")]
|
|
|
|
|
[DataRow("\"a\"\"b\"")]
|
|
|
|
|
[DataRow("tag:\"x\"y")]
|
|
|
|
|
[DataRow("tag:\"x\"\"y\"")]
|
|
|
|
|
public void PhraseGluedToOtherTextIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("\"\"")]
|
|
|
|
|
[DataRow("a \"\"")]
|
|
|
|
|
[DataRow("tag:\"\"")]
|
|
|
|
|
public void EmptyPhraseIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void PhraseMayTouchParentheses() {
|
|
|
|
|
AssertParsesTo(
|
|
|
|
|
new SearchNode.And([new SearchNode.FreeText("a b"), new SearchNode.FreeText("c")]),
|
|
|
|
|
"(\"a b\")(c)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("id:12x")]
|
|
|
|
|
[DataRow("id:abc")]
|
|
|
|
|
[DataRow("id:0")]
|
|
|
|
|
[DataRow("id:-1")]
|
|
|
|
|
[DataRow("id:+1")]
|
|
|
|
|
[DataRow("id:#")]
|
|
|
|
|
[DataRow("id:##1")]
|
|
|
|
|
[DataRow("id:1.5")]
|
|
|
|
|
[DataRow("id:٣")]
|
|
|
|
|
[DataRow("id:99999999999999999999")]
|
|
|
|
|
public void InvalidIdValueIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void QuotedIdValueIsAnError() {
|
|
|
|
|
AssertSyntaxError("id:\"61\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("tag:AND")]
|
|
|
|
|
[DataRow("tag:OR")]
|
|
|
|
|
[DataRow("title:AND")]
|
|
|
|
|
public void BareOperatorAsQualifierValueIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void UnquotedQualifierValueWithColonIsAnError() {
|
|
|
|
|
AssertSyntaxError("tag:a:b");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow(":foo")]
|
|
|
|
|
[DataRow("http://x")]
|
|
|
|
|
[DataRow("a:")]
|
|
|
|
|
public void ColonAfterNonKeyIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("a\\b")]
|
|
|
|
|
[DataRow("\\")]
|
|
|
|
|
[DataRow("tag:a\\b")]
|
|
|
|
|
public void BackslashInBareWordIsAnError(string text) {
|
|
|
|
|
AssertSyntaxError(text);
|
2026-10-02 22:32:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void UnknownQualifierKeyIsAnError() {
|
2026-10-02 22:32:33 +08:00
|
|
|
AssertSyntaxError("foo:bar");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void QualifierKeysAreCaseSensitive() {
|
2026-10-02 22:32:33 +08:00
|
|
|
// "Tag" is a key-like prefix but not the known lowercase "tag", so it is unknown.
|
|
|
|
|
AssertSyntaxError("Tag:x");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[TestMethod]
|
2026-10-04 10:22:02 +08:00
|
|
|
public void SyntaxErrorCarriesDescriptiveMessage() {
|
2026-10-02 22:32:33 +08:00
|
|
|
SearchSyntaxException exception = Assert.ThrowsExactly<SearchSyntaxException>(
|
|
|
|
|
() => SearchQueryParser.Parse("a AND"));
|
|
|
|
|
Assert.IsFalse(string.IsNullOrEmpty(exception.Message));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
}
|