feat(m3): search and export
- Pidgin lexer: words, escaped phrases (strict closing), parens - Full-Pidgin token-level parser, strict: malformed input throws SearchSyntaxException (BREAKING: lenient degradation removed from the plan; M7 must surface the error, M8 USAGE.md documents strict behavior) - AST with reference-equality collection terms; structural comparison lives in the test-side SearchNodeComparer (YYC MARK note records the record-equality pitfall) - SQL compiler: parameterized predicate over cards alias, LIKE escaping, COLLATE NOCASE exact matches, tag EXISTS / column IN subqueries - Markdown exporter: ResX-driven templates, per-language export time AND card time patterns, kebab i18n file names, table cell escaping - New RgbColor value type (byte components, strict #RRGGBB parse, canonical lowercase text) wired through TagModel and repository - 57 new tests (grammar branches, strict errors, SQL end-to-end, en/zh export snapshots, color conversions)
This commit is contained in:
1 parent
e2bb0298b1
commit
b5614ff59b
11 files changed
+1195
No files matched your search
@@ -0,0 +1,171 @@
|
||||
using System.Globalization;
|
||||
using YKanBan.Export;
|
||||
using YKanBan.Models;
|
||||
|
||||
namespace YKanBan.Tests.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Markdown exporter. Mutates the global Resources.Culture,
|
||||
/// therefore marked DoNotParallelize. Asserts real localized output for
|
||||
/// both supported languages.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class MarkdownExporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero);
|
||||
|
||||
/// <summary>A local wall-clock moment, matching the exporter's conversion path.</summary>
|
||||
private static DateTimeOffset Local(int year, int month, int day, int hour, int minute) =>
|
||||
new DateTimeOffset(new DateTime(year, month, day, hour, minute, 0)).ToLocalTime();
|
||||
|
||||
/// <summary>Unix seconds for a local wall-clock time.</summary>
|
||||
private static long UnixTimeOf(int year, int month, int day, int hour, int minute) =>
|
||||
Local(year, month, day, hour, minute).ToUnixTimeSeconds();
|
||||
|
||||
/// <summary>Builds the board fixture: one working column with two cards, one empty column, two tags.</summary>
|
||||
private static (IReadOnlyList<ColumnModel> Columns, IReadOnlyList<CardModel> Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard()
|
||||
{
|
||||
long created = UnixTimeOf(2026, 9, 1, 10, 0);
|
||||
long updated = UnixTimeOf(2026, 9, 28, 18, 30);
|
||||
|
||||
var columns = new List<ColumnModel>
|
||||
{
|
||||
new(1, "In Progress", "The work column", 100, 100),
|
||||
new(2, "Empty", string.Empty, 100, 100),
|
||||
};
|
||||
var bug = new TagModel(1, "bug", RgbColor.Parse("#ff0000"), string.Empty);
|
||||
var ui = new TagModel(2, "ui", RgbColor.Parse("#00ff00"), "Interface work");
|
||||
var cards = new List<CardModel>
|
||||
{
|
||||
new(61, 1, "Fix login", "Body text", created, updated, [bug, ui]),
|
||||
new(62, 1, string.Empty, string.Empty, created, updated, []),
|
||||
};
|
||||
var tags = new List<(TagModel, long)> { (bug, 1), (ui, 1) };
|
||||
return (columns, cards, tags);
|
||||
}
|
||||
|
||||
private static string Export(CultureInfo culture)
|
||||
{
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
Resources.Culture = culture;
|
||||
(IReadOnlyList<ColumnModel> columns, IReadOnlyList<CardModel> cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard();
|
||||
return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string text) => text.Replace("\r\n", "\n");
|
||||
|
||||
[TestMethod]
|
||||
public void EnglishOutputMatchesTemplate()
|
||||
{
|
||||
string created = Local(2026, 9, 1, 10, 0).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
string updated = Local(2026, 9, 28, 18, 30).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
string expected = Normalize($"""
|
||||
# MyRepo
|
||||
> Exported at: 2026-09-30 14:25:37
|
||||
|
||||
## Column: In Progress (2)
|
||||
The work column
|
||||
|
||||
### #61 Fix login
|
||||
Body text
|
||||
- Tags: `bug` `ui`
|
||||
- Created: {created} Modified: {updated}
|
||||
|
||||
### #62
|
||||
- Created: {created} Modified: {updated}
|
||||
|
||||
## Column: Empty (0)
|
||||
|
||||
## Tags
|
||||
| Name | Color | Description | Usage |
|
||||
|---|---|---|---|
|
||||
| bug | #ff0000 | | 1 |
|
||||
| ui | #00ff00 | Interface work | 1 |
|
||||
""");
|
||||
|
||||
Assert.AreEqual(expected + "\n", Normalize(Export(new CultureInfo("en"))));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ChineseOutputMatchesTemplate()
|
||||
{
|
||||
var chinese = new CultureInfo("zh-Hans");
|
||||
string created = Local(2026, 9, 1, 10, 0).ToString("yyyy年M月d日 HH:mm", chinese);
|
||||
string updated = Local(2026, 9, 28, 18, 30).ToString("yyyy年M月d日 HH:mm", chinese);
|
||||
|
||||
string expected = Normalize($"""
|
||||
# MyRepo
|
||||
> 导出时间:2026年9月30日 14:25
|
||||
|
||||
## 列:In Progress(2)
|
||||
The work column
|
||||
|
||||
### #61 Fix login
|
||||
Body text
|
||||
- 标签:`bug` `ui`
|
||||
- 创建:{created} 修改:{updated}
|
||||
|
||||
### #62
|
||||
- 创建:{created} 修改:{updated}
|
||||
|
||||
## 列:Empty(0)
|
||||
|
||||
## 标签总表
|
||||
| 名称 | 颜色 | 描述 | 使用数 |
|
||||
|---|---|---|---|
|
||||
| bug | #ff0000 | | 1 |
|
||||
| ui | #00ff00 | Interface work | 1 |
|
||||
""");
|
||||
|
||||
Assert.AreEqual(expected + "\n", Normalize(Export(new CultureInfo("zh-Hans"))));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileNameFollowsLanguage()
|
||||
{
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
Assert.AreEqual("MyRepo-export-20260930-142537.md", MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
|
||||
|
||||
Resources.Culture = new CultureInfo("zh-Hans");
|
||||
Assert.AreEqual("MyRepo-导出-20260930-142537.md", MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagTableCellsEscapePipesAndLineBreaks()
|
||||
{
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
var tag = new TagModel(1, "a|b", RgbColor.Parse("#112233"), "first\nsecond");
|
||||
string markdown = MarkdownExporter.Export("W",
|
||||
[new ColumnModel(1, "C", string.Empty, 1, 1)],
|
||||
[],
|
||||
[(tag, 0L)],
|
||||
ExportedAt);
|
||||
|
||||
StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using YKanBan.Search;
|
||||
|
||||
namespace YKanBan.Tests.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Structural comparer for search AST nodes, used by assertions only. The
|
||||
/// AST 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>
|
||||
{
|
||||
/// <summary>Shared comparer instance.</summary>
|
||||
public static SearchNodeComparer Instance { get; } = new();
|
||||
|
||||
/// <summary>Determines structural equality of two nodes.</summary>
|
||||
public bool Equals(SearchNode? x, SearchNode? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (x is null || y is null || x.GetType() != y.GetType())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
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,
|
||||
(SearchNode.FieldText a, SearchNode.FieldText b) => a.Field == b.Field && a.Text == b.Text,
|
||||
(SearchNode.TagName a, SearchNode.TagName b) => a.Name == b.Name,
|
||||
(SearchNode.ColumnTitle a, SearchNode.ColumnTitle b) => a.Title == b.Title,
|
||||
(SearchNode.CardId a, SearchNode.CardId b) => a.Id == b.Id,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Not supported: the comparer exists for assertions, not hashing.</summary>
|
||||
public int GetHashCode(SearchNode obj) =>
|
||||
throw new NotSupportedException("Structural comparison for assertions only; hashing is not supported.");
|
||||
|
||||
/// <summary>Order-sensitive element-wise comparison.</summary>
|
||||
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y)
|
||||
{
|
||||
if (x.Count != y.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return Enumerable.Zip(x, y, (a, b) => (a, b)).All(pair => Instance.Equals(pair.a, pair.b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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 SearchSyntaxException).
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SearchQueryParserTests
|
||||
{
|
||||
/// <summary>Parses and asserts structural equality against the expected AST.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Asserts that the input violates the grammar.</summary>
|
||||
private static void AssertSyntaxError(string text)
|
||||
{
|
||||
Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse(text));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BlankInputReturnsNull()
|
||||
{
|
||||
Assert.IsNull(SearchQueryParser.Parse(string.Empty));
|
||||
Assert.IsNull(SearchQueryParser.Parse(" "));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleWordIsFreeText()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.FreeText("login"), "login");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AdjacentWordsAreImplicitAnd()
|
||||
{
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]),
|
||||
"fix login");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExplicitAndKeyword()
|
||||
{
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
"a AND b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OrKeyword()
|
||||
{
|
||||
AssertParsesTo(
|
||||
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
"a OR b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ImplicitAndBindsTighterThanOr()
|
||||
{
|
||||
AssertParsesTo(
|
||||
new SearchNode.Or([
|
||||
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
new SearchNode.FreeText("c")]),
|
||||
"a b OR c");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParenthesesForceGrouping()
|
||||
{
|
||||
AssertParsesTo(
|
||||
new SearchNode.And([
|
||||
new SearchNode.FreeText("a"),
|
||||
new SearchNode.Or([new SearchNode.FreeText("b"), new SearchNode.FreeText("c")])]),
|
||||
"a (b OR c)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NestedParenthesesCollapse()
|
||||
{
|
||||
AssertParsesTo(
|
||||
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||
"((a OR b))");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LowercaseKeywordsArePlainWords()
|
||||
{
|
||||
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]
|
||||
public void PhraseIsFreeText()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PhraseSupportsEscapes()
|
||||
{
|
||||
// Escaped quote, escaped backslash, and a lone backslash kept literally.
|
||||
AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\"");
|
||||
AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\"");
|
||||
AssertParsesTo(new SearchNode.FreeText("C:\\x"), "\"C:\\x\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagQualifier()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagQualifierAcceptsPhraseValue()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
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()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IdQualifierParsesNumber()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.CardId(42), "id:42");
|
||||
AssertParsesTo(new SearchNode.CardId(12), "id:\"12\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void QualifierKeysAreCaseSensitive()
|
||||
{
|
||||
AssertParsesTo(new SearchNode.FreeText("Tag:x"), "Tag:x");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnterminatedPhraseIsAnError()
|
||||
{
|
||||
AssertSyntaxError("\"abc def");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnbalancedOpenParenIsAnError()
|
||||
{
|
||||
AssertSyntaxError("(a");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StrayCloseParenIsAnError()
|
||||
{
|
||||
AssertSyntaxError("a)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EmptyParensAreAnError()
|
||||
{
|
||||
AssertSyntaxError("()");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DanglingOrIsAnError()
|
||||
{
|
||||
AssertSyntaxError("a OR");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DanglingAndIsAnError()
|
||||
{
|
||||
AssertSyntaxError("a AND");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LeadingAndIsAnError()
|
||||
{
|
||||
AssertSyntaxError("AND a");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OperatorAtOperandPositionIsAnError()
|
||||
{
|
||||
AssertSyntaxError("a AND OR b");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void QualifierWithoutValueIsAnError()
|
||||
{
|
||||
AssertSyntaxError("tag:");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NonNumericIdIsAnError()
|
||||
{
|
||||
AssertSyntaxError("id:12x");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SyntaxErrorCarriesDescriptiveMessage()
|
||||
{
|
||||
SearchSyntaxException ex = Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse("a AND"));
|
||||
Assert.IsFalse(string.IsNullOrEmpty(ex.Message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Search;
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the query-to-SQL compiler, executed end-to-end against a
|
||||
/// fixture workspace database.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SearchSqlCompilerTests
|
||||
{
|
||||
/// <summary>Owns a workspace with a fixed card/tag fixture.</summary>
|
||||
private sealed class SearchFixture : IDisposable
|
||||
{
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public SqliteConnection Connection { get; }
|
||||
|
||||
public SearchFixture()
|
||||
{
|
||||
WorkspaceInitializer.Initialize(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);");
|
||||
|
||||
// Cards: 1 titled, 2 title-or-content matches, 3 untitled, 4 wildcard chars.
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'Fix login', 'Cannot sign in', 1, 1);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, 'Green apple', 'the apple pie', 1, 1);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, '', 'banana split', 1, 1);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, '100% done', 'a_b test', 1, 1);");
|
||||
|
||||
// Tags: mixed case names plus one containing spaces.
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('Bug', '#ff0000', '');");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('UI', '#00ff00', '');");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('release notes', '#0000ff', '');");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (2, 2);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (3, 2);");
|
||||
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (4, 3);");
|
||||
}
|
||||
|
||||
Connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath);
|
||||
}
|
||||
|
||||
/// <summary>Runs a query and returns the ids of the matching cards.</summary>
|
||||
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)
|
||||
{
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var ids = new List<long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
ids.Add(reader.GetInt64(0));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Connection.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FreeTextMatchesTitleOrContentCaseInsensitively()
|
||||
{
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple"));
|
||||
CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("login"));
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("APPLE"));
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("pie"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FieldQualifiersScopeToTheirField()
|
||||
{
|
||||
using var fixture = new SearchFixture();
|
||||
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("title:apple"));
|
||||
CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("content:banana"));
|
||||
Assert.AreEqual(0, fixture.Search("title:pie").Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
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"));
|
||||
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("tag:\"release notes\""));
|
||||
|
||||
// Exact match: prefixes and substrings do not hit.
|
||||
Assert.AreEqual(0, fixture.Search("tag:bu").Count);
|
||||
Assert.AreEqual(0, fixture.Search("tag:release").Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
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\""));
|
||||
|
||||
// Not a prefix or substring match.
|
||||
Assert.AreEqual(0, fixture.Search("column:To").Count);
|
||||
Assert.AreEqual(0, fixture.Search("column:progress").Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
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()
|
||||
{
|
||||
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"));
|
||||
Assert.AreEqual(0, fixture.Search("apple AND id:1").Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParenthesizedGrouping()
|
||||
{
|
||||
using var fixture = new SearchFixture();
|
||||
|
||||
// (apple OR banana) hits cards 2,3; intersected with column "In Progress" (2,3).
|
||||
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("(apple OR banana) AND column:\"In Progress\""));
|
||||
|
||||
// ...while column "To Do" holds cards 1,4 — the intersection is empty.
|
||||
Assert.AreEqual(0, fixture.Search("(apple OR banana) AND column:\"To Do\"").Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
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()
|
||||
{
|
||||
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("x"));
|
||||
|
||||
StringAssert.Contains(compiled.Predicate, "$p0");
|
||||
StringAssert.Contains(compiled.Predicate, "$p1");
|
||||
Assert.AreEqual(2, compiled.Parameters.Count);
|
||||
Assert.AreEqual("%x%", compiled.Parameters[0].Value);
|
||||
Assert.AreEqual("%x%", compiled.Parameters[1].Value);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user