style: expand one-line summaries and run dotnet format solution-wide
This commit is contained in:
1 parent
9ea551b600
commit
8b7e50a955
95 files changed
+970
-1424
No files matched your search
@@ -7,8 +7,7 @@ namespace YKanBan.Tests.Search;
|
||||
/// records compare by reference for their Terms collections by design (see the
|
||||
/// YYC MARK note in SearchNodes.cs), so tests provide equality explicitly.
|
||||
/// </summary>
|
||||
public sealed class SearchNodeComparer : IEqualityComparer<SearchNode>
|
||||
{
|
||||
public sealed class SearchNodeComparer : IEqualityComparer<SearchNode> {
|
||||
/// <summary>
|
||||
/// Gets the shared comparer instance.
|
||||
/// </summary>
|
||||
@@ -20,19 +19,15 @@ public sealed class SearchNodeComparer : IEqualityComparer<SearchNode>
|
||||
/// <param name="x">The first node.</param>
|
||||
/// <param name="y">The second node.</param>
|
||||
/// <returns><see langword="true"/> when the nodes are structurally equal.</returns>
|
||||
public bool Equals(SearchNode? x, SearchNode? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
public bool Equals(SearchNode? x, SearchNode? y) {
|
||||
if (ReferenceEquals(x, y)) {
|
||||
return true;
|
||||
}
|
||||
if (x is null || y is null || x.GetType() != y.GetType())
|
||||
{
|
||||
if (x is null || y is null || x.GetType() != y.GetType()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (x, y) switch
|
||||
{
|
||||
return (x, y) switch {
|
||||
(SearchNode.Or a, SearchNode.Or b) => SequencesEqual(a.Terms, b.Terms),
|
||||
(SearchNode.And a, SearchNode.And b) => SequencesEqual(a.Terms, b.Terms),
|
||||
(SearchNode.FreeText a, SearchNode.FreeText b) => a.Text == b.Text,
|
||||
@@ -58,10 +53,8 @@ public sealed class SearchNodeComparer : IEqualityComparer<SearchNode>
|
||||
/// <param name="x">The first sequence.</param>
|
||||
/// <param name="y">The second sequence.</param>
|
||||
/// <returns><see langword="true"/> when both sequences match element-wise.</returns>
|
||||
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y)
|
||||
{
|
||||
if (x.Count != y.Count)
|
||||
{
|
||||
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y) {
|
||||
if (x.Count != y.Count) {
|
||||
return false;
|
||||
}
|
||||
return x.Zip(y).All(pair => Instance.Equals(pair.First, pair.Second));
|
||||
|
||||
@@ -8,15 +8,13 @@ namespace YKanBan.Tests.Search;
|
||||
/// <see cref="SearchSyntaxException"/>).
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SearchQueryParserTests
|
||||
{
|
||||
public class SearchQueryParserTests {
|
||||
/// <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>
|
||||
private static void AssertParsesTo(SearchNode expected, string text)
|
||||
{
|
||||
private static void AssertParsesTo(SearchNode expected, string text) {
|
||||
SearchNode? parsed = SearchQueryParser.Parse(text);
|
||||
Assert.IsNotNull(parsed, $"Blank-looking input: '{text}'.");
|
||||
Assert.AreEqual(expected, parsed, SearchNodeComparer.Instance);
|
||||
@@ -26,53 +24,46 @@ public class SearchQueryParserTests
|
||||
/// Asserts that the input violates the grammar.
|
||||
/// </summary>
|
||||
/// <param name="text">The search text.</param>
|
||||
private static void AssertSyntaxError(string text)
|
||||
{
|
||||
private static void AssertSyntaxError(string text) {
|
||||
Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse(text));
|
||||
}
|
||||
|
||||
#region Successful parses
|
||||
|
||||
[TestMethod]
|
||||
public void BlankInputReturnsNull()
|
||||
{
|
||||
public void BlankInputReturnsNull() {
|
||||
Assert.IsNull(SearchQueryParser.Parse(string.Empty));
|
||||
Assert.IsNull(SearchQueryParser.Parse(" "));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleWordIsFreeText()
|
||||
{
|
||||
public void SingleWordIsFreeText() {
|
||||
AssertParsesTo(new SearchNode.FreeText("login"), "login");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AdjacentWordsAreImplicitAnd()
|
||||
{
|
||||
public void AdjacentWordsAreImplicitAnd() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]),
|
||||
"fix login");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExplicitAndKeyword()
|
||||
{
|
||||
public void ExplicitAndKeyword() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
"a AND b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OrKeyword()
|
||||
{
|
||||
public void OrKeyword() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
"a OR b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ImplicitAndBindsTighterThanOr()
|
||||
{
|
||||
public void ImplicitAndBindsTighterThanOr() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.Or([
|
||||
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
@@ -81,8 +72,7 @@ public class SearchQueryParserTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParenthesesForceGrouping()
|
||||
{
|
||||
public void ParenthesesForceGrouping() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([
|
||||
new SearchNode.FreeText("a"),
|
||||
@@ -91,16 +81,14 @@ public class SearchQueryParserTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NestedParenthesesCollapse()
|
||||
{
|
||||
public void NestedParenthesesCollapse() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
"((a OR b))");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LowercaseKeywordsArePlainWords()
|
||||
{
|
||||
public void LowercaseKeywordsArePlainWords() {
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([
|
||||
new SearchNode.FreeText("a"),
|
||||
@@ -111,14 +99,12 @@ public class SearchQueryParserTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PhraseIsFreeText()
|
||||
{
|
||||
public void PhraseIsFreeText() {
|
||||
AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void QuotedOperatorKeywordIsFreeText()
|
||||
{
|
||||
public void QuotedOperatorKeywordIsFreeText() {
|
||||
// A quoted AND/OR is literal text, not an operator.
|
||||
AssertParsesTo(new SearchNode.FreeText("AND"), "\"AND\"");
|
||||
AssertParsesTo(
|
||||
@@ -127,54 +113,46 @@ public class SearchQueryParserTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PhraseSupportsEscapes()
|
||||
{
|
||||
public void PhraseSupportsEscapes() {
|
||||
// Escaped quote and escaped backslash.
|
||||
AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\"");
|
||||
AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagQualifier()
|
||||
{
|
||||
public void TagQualifier() {
|
||||
AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagQualifierAcceptsPhraseValue()
|
||||
{
|
||||
public void TagQualifierAcceptsPhraseValue() {
|
||||
AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void QualifierValueMayContainColons()
|
||||
{
|
||||
public void QualifierValueMayContainColons() {
|
||||
AssertParsesTo(new SearchNode.TagName("a:b"), "tag:a:b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TitleAndContentQualifiers()
|
||||
{
|
||||
public void TitleAndContentQualifiers() {
|
||||
AssertParsesTo(new SearchNode.FieldText(SearchField.Title, "foo"), "title:foo");
|
||||
AssertParsesTo(new SearchNode.FieldText(SearchField.Content, "foo"), "content:foo");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ColumnQualifierAcceptsPhraseValue()
|
||||
{
|
||||
public void ColumnQualifierAcceptsPhraseValue() {
|
||||
AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IdQualifierParsesNumber()
|
||||
{
|
||||
public void IdQualifierParsesNumber() {
|
||||
AssertParsesTo(new SearchNode.CardId(42), "id:42");
|
||||
AssertParsesTo(new SearchNode.CardId(12), "id:\"12\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ColonWithoutKeyPrefixIsFreeText()
|
||||
{
|
||||
public void ColonWithoutKeyPrefixIsFreeText() {
|
||||
AssertParsesTo(new SearchNode.FreeText(":foo"), ":foo");
|
||||
}
|
||||
|
||||
@@ -183,106 +161,89 @@ public class SearchQueryParserTests
|
||||
#region Strict failures
|
||||
|
||||
[TestMethod]
|
||||
public void UnterminatedPhraseIsAnError()
|
||||
{
|
||||
public void UnterminatedPhraseIsAnError() {
|
||||
AssertSyntaxError("\"abc def");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TrailingUnterminatedPhraseIsAnError()
|
||||
{
|
||||
public void TrailingUnterminatedPhraseIsAnError() {
|
||||
AssertSyntaxError("a \"abc");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvalidEscapeInPhraseIsAnError()
|
||||
{
|
||||
public void InvalidEscapeInPhraseIsAnError() {
|
||||
// A bare backslash (not followed by a quote or another backslash) is invalid.
|
||||
AssertSyntaxError("\"a\\b\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvalidEscapeInQualifierPhraseIsAnError()
|
||||
{
|
||||
public void InvalidEscapeInQualifierPhraseIsAnError() {
|
||||
AssertSyntaxError("tag:\"a\\x\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnbalancedOpenParenIsAnError()
|
||||
{
|
||||
public void UnbalancedOpenParenIsAnError() {
|
||||
AssertSyntaxError("(a");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StrayCloseParenIsAnError()
|
||||
{
|
||||
public void StrayCloseParenIsAnError() {
|
||||
AssertSyntaxError("a)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EmptyParensAreAnError()
|
||||
{
|
||||
public void EmptyParensAreAnError() {
|
||||
AssertSyntaxError("()");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DanglingOrIsAnError()
|
||||
{
|
||||
public void DanglingOrIsAnError() {
|
||||
AssertSyntaxError("a OR");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DanglingAndIsAnError()
|
||||
{
|
||||
public void DanglingAndIsAnError() {
|
||||
AssertSyntaxError("a AND");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LeadingAndIsAnError()
|
||||
{
|
||||
public void LeadingAndIsAnError() {
|
||||
AssertSyntaxError("AND a");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OperatorAtOperandPositionIsAnError()
|
||||
{
|
||||
public void OperatorAtOperandPositionIsAnError() {
|
||||
AssertSyntaxError("a AND OR b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void QualifierWithoutValueIsAnError()
|
||||
{
|
||||
public void QualifierWithoutValueIsAnError() {
|
||||
AssertSyntaxError("tag:");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SpacedQualifierValueIsAnError()
|
||||
{
|
||||
public void SpacedQualifierValueIsAnError() {
|
||||
AssertSyntaxError("tag: bug");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NonNumericIdIsAnError()
|
||||
{
|
||||
public void NonNumericIdIsAnError() {
|
||||
AssertSyntaxError("id:12x");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnknownQualifierKeyIsAnError()
|
||||
{
|
||||
public void UnknownQualifierKeyIsAnError() {
|
||||
AssertSyntaxError("foo:bar");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void QualifierKeysAreCaseSensitive()
|
||||
{
|
||||
public void QualifierKeysAreCaseSensitive() {
|
||||
// "Tag" is a key-like prefix but not the known lowercase "tag", so it is unknown.
|
||||
AssertSyntaxError("Tag:x");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SyntaxErrorCarriesDescriptiveMessage()
|
||||
{
|
||||
public void SyntaxErrorCarriesDescriptiveMessage() {
|
||||
SearchSyntaxException exception = Assert.ThrowsExactly<SearchSyntaxException>(
|
||||
() => SearchQueryParser.Parse("a AND"));
|
||||
Assert.IsFalse(string.IsNullOrEmpty(exception.Message));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Search;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
@@ -10,22 +11,18 @@ namespace YKanBan.Tests.Search;
|
||||
/// workspace database.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SearchSqlCompilerTests
|
||||
{
|
||||
public class SearchSqlCompilerTests {
|
||||
/// <summary>
|
||||
/// Owns a workspace with a fixed card/tag fixture.
|
||||
/// </summary>
|
||||
private sealed class SearchFixture : IDisposable
|
||||
{
|
||||
private sealed class SearchFixture : IDisposable {
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public SqliteConnection Connection { get; }
|
||||
|
||||
public SearchFixture()
|
||||
{
|
||||
public SearchFixture() {
|
||||
WorkspaceInitializer.Initialize(Directory.FullPath);
|
||||
using (SqliteConnection setup = SqliteTestHelper.OpenWorkspace(Directory.FullPath))
|
||||
{
|
||||
using (SqliteConnection setup = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) {
|
||||
// Columns: 1 "To Do", 2 "In Progress".
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('To Do', '', 1, 1);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('In Progress', '', 1, 1);");
|
||||
@@ -54,38 +51,33 @@ public class SearchSqlCompilerTests
|
||||
/// </summary>
|
||||
/// <param name="queryText">The search text.</param>
|
||||
/// <returns>The matching card ids in id order.</returns>
|
||||
public List<long> Search(string queryText)
|
||||
{
|
||||
public List<long> Search(string queryText) {
|
||||
SearchNode? query = SearchQueryParser.Parse(queryText);
|
||||
Assert.IsNotNull(query, "Blank fixture queries are not expected here.");
|
||||
CompiledSearch compiled = SearchSqlCompiler.Compile(query);
|
||||
|
||||
using var command = Connection.CreateCommand();
|
||||
command.CommandText = $"SELECT c.id FROM cards c WHERE {compiled.Predicate} ORDER BY c.id ASC;";
|
||||
foreach ((string name, object value) in compiled.Parameters)
|
||||
{
|
||||
foreach ((string name, object value) in compiled.Parameters) {
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var ids = new List<long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
while (reader.Read()) {
|
||||
ids.Add(reader.GetInt64(0));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
public void Dispose() {
|
||||
Connection.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FreeTextMatchesTitleOrContentCaseInsensitively()
|
||||
{
|
||||
public void FreeTextMatchesTitleOrContentCaseInsensitively() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple"));
|
||||
CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("login"));
|
||||
@@ -94,8 +86,7 @@ public class SearchSqlCompilerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FieldQualifiersScopeToTheirField()
|
||||
{
|
||||
public void FieldQualifiersScopeToTheirField() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("title:apple"));
|
||||
CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("content:banana"));
|
||||
@@ -103,8 +94,7 @@ public class SearchSqlCompilerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagQualifierMatchesExactlyAndCaseInsensitively()
|
||||
{
|
||||
public void TagQualifierMatchesExactlyAndCaseInsensitively() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("tag:bug"));
|
||||
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("tag:ui"));
|
||||
@@ -116,8 +106,7 @@ public class SearchSqlCompilerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ColumnQualifierMatchesColumnTitleExactly()
|
||||
{
|
||||
public void ColumnQualifierMatchesColumnTitleExactly() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 1L, 4L }, fixture.Search("column:\"to do\""));
|
||||
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("column:\"in progress\""));
|
||||
@@ -128,16 +117,14 @@ public class SearchSqlCompilerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IdQualifierMatchesExactly()
|
||||
{
|
||||
public void IdQualifierMatchesExactly() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("id:3"));
|
||||
Assert.AreEqual(0, fixture.Search("id:99").Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AndOrCombination()
|
||||
{
|
||||
public void AndOrCombination() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("apple OR banana"));
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple AND id:2"));
|
||||
@@ -145,8 +132,7 @@ public class SearchSqlCompilerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParenthesizedGrouping()
|
||||
{
|
||||
public void ParenthesizedGrouping() {
|
||||
using var fixture = new SearchFixture();
|
||||
|
||||
// (apple OR banana) hits cards 2,3; intersected with column "In Progress" (2,3).
|
||||
@@ -157,16 +143,14 @@ public class SearchSqlCompilerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LikeWildcardsInUserTextMatchLiterally()
|
||||
{
|
||||
public void LikeWildcardsInUserTextMatchLiterally() {
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("100%"));
|
||||
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("a_b"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CompiledPredicateUsesNamedParameters()
|
||||
{
|
||||
public void CompiledPredicateUsesNamedParameters() {
|
||||
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("x"));
|
||||
|
||||
StringAssert.Contains(compiled.Predicate, "$p0");
|
||||
|
||||
Reference in new issue
Block a user