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
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
using YKanBan.Export;
|
||||
using YKanBan.Models;
|
||||
|
||||
@@ -14,8 +15,7 @@ namespace YKanBan.Tests.Export;
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class MarkdownExporterTests
|
||||
{
|
||||
public class MarkdownExporterTests {
|
||||
private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
@@ -46,8 +46,7 @@ public class MarkdownExporterTests
|
||||
/// Builds the board fixture: one working column with two cards, one empty column and two tags.
|
||||
/// </summary>
|
||||
/// <returns>The columns, cards and tag/usage tuples.</returns>
|
||||
private static (IReadOnlyList<ColumnModel> Columns, IReadOnlyList<CardModel> Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard()
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -71,8 +70,7 @@ public class MarkdownExporterTests
|
||||
/// Renders the fixture board with the exporter under the current culture.
|
||||
/// </summary>
|
||||
/// <returns>The produced Markdown document.</returns>
|
||||
private static string ExportBoard()
|
||||
{
|
||||
private static string ExportBoard() {
|
||||
(IReadOnlyList<ColumnModel> columns, IReadOnlyList<CardModel> cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard();
|
||||
return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt);
|
||||
}
|
||||
@@ -82,8 +80,7 @@ public class MarkdownExporterTests
|
||||
/// </summary>
|
||||
/// <param name="culture">The UI culture.</param>
|
||||
/// <returns>The expected Markdown document.</returns>
|
||||
private static string BuildExpected(CultureInfo culture)
|
||||
{
|
||||
private static string BuildExpected(CultureInfo culture) {
|
||||
string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_CardTimePattern, culture);
|
||||
string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_CardTimePattern, culture);
|
||||
|
||||
@@ -133,38 +130,30 @@ public class MarkdownExporterTests
|
||||
/// Asserts that the exporter output matches the resource-built expected document.
|
||||
/// </summary>
|
||||
/// <param name="culture">The UI culture to test.</param>
|
||||
private static void AssertOutputMatches(CultureInfo culture)
|
||||
{
|
||||
private static void AssertOutputMatches(CultureInfo culture) {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = culture;
|
||||
Assert.AreEqual(Normalize(BuildExpected(culture)), Normalize(ExportBoard()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EnglishOutputMatchesTemplate()
|
||||
{
|
||||
public void EnglishOutputMatchesTemplate() {
|
||||
AssertOutputMatches(new CultureInfo("en"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ChineseOutputMatchesTemplate()
|
||||
{
|
||||
public void ChineseOutputMatchesTemplate() {
|
||||
AssertOutputMatches(new CultureInfo("zh-Hans"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileNameFollowsLanguage()
|
||||
{
|
||||
public void FileNameFollowsLanguage() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
Assert.AreEqual(
|
||||
string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt),
|
||||
@@ -174,19 +163,15 @@ public class MarkdownExporterTests
|
||||
Assert.AreEqual(
|
||||
string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt),
|
||||
MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagTableCellsEscapePipesAndLineBreaks()
|
||||
{
|
||||
public void TagTableCellsEscapePipesAndLineBreaks() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
var tag = new TagModel(1, "a|b", RgbColor.Parse("#112233"), "first\nsecond");
|
||||
string markdown = MarkdownExporter.Export(
|
||||
@@ -197,9 +182,7 @@ public class MarkdownExporterTests
|
||||
ExportedAt);
|
||||
|
||||
StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |");
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,32 +7,27 @@ namespace YKanBan.Tests.Launching;
|
||||
/// three branches: no argument, one valid path, and invalid input.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class LaunchArgumentsTests
|
||||
{
|
||||
public class LaunchArgumentsTests {
|
||||
[TestMethod]
|
||||
public void NoArgumentsIsRejected()
|
||||
{
|
||||
public void NoArgumentsIsRejected() {
|
||||
Assert.IsFalse(LaunchArguments.TryParse([], out string? path));
|
||||
Assert.IsNull(path);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BlankArgumentIsRejected()
|
||||
{
|
||||
public void BlankArgumentIsRejected() {
|
||||
Assert.IsFalse(LaunchArguments.TryParse([" "], out string? path));
|
||||
Assert.IsNull(path);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoreThanOneArgumentIsRejected()
|
||||
{
|
||||
public void MoreThanOneArgumentIsRejected() {
|
||||
Assert.IsFalse(LaunchArguments.TryParse(["a", "b"], out string? path));
|
||||
Assert.IsNull(path);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SinglePathIsAccepted()
|
||||
{
|
||||
public void SinglePathIsAccepted() {
|
||||
bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path);
|
||||
Assert.IsTrue(parsed);
|
||||
Assert.AreEqual(@"C:\Repo\MyProject", path);
|
||||
|
||||
@@ -7,11 +7,9 @@ namespace YKanBan.Tests.Models;
|
||||
/// boundary in both directions.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class RgbColorTests
|
||||
{
|
||||
public class RgbColorTests {
|
||||
[TestMethod]
|
||||
public void ParseSplitsComponents()
|
||||
{
|
||||
public void ParseSplitsComponents() {
|
||||
RgbColor color = RgbColor.Parse("#102030");
|
||||
Assert.AreEqual((byte)0x10, color.R);
|
||||
Assert.AreEqual((byte)0x20, color.G);
|
||||
@@ -22,8 +20,7 @@ public class RgbColorTests
|
||||
[DataRow("#a1B2c3")]
|
||||
[DataRow("#A1B2C3")]
|
||||
[DataRow("#a1b2c3")]
|
||||
public void ParseIsCaseInsensitive(string text)
|
||||
{
|
||||
public void ParseIsCaseInsensitive(string text) {
|
||||
Assert.AreEqual(new RgbColor(0xA1, 0xB2, 0xC3), RgbColor.Parse(text));
|
||||
}
|
||||
|
||||
@@ -36,41 +33,35 @@ public class RgbColorTests
|
||||
[DataRow("#12345g")]
|
||||
[DataRow(" #112233")]
|
||||
[DataRow("#112233 ")]
|
||||
public void ParseRejectsMalformedText(string text)
|
||||
{
|
||||
public void ParseRejectsMalformedText(string text) {
|
||||
Assert.ThrowsExactly<FormatException>(() => RgbColor.Parse(text));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParseRejectsNull()
|
||||
{
|
||||
public void TryParseRejectsNull() {
|
||||
Assert.IsFalse(RgbColor.TryParse(null, out RgbColor color));
|
||||
Assert.AreEqual(default, color);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParseAcceptsValidText()
|
||||
{
|
||||
public void TryParseAcceptsValidText() {
|
||||
Assert.IsTrue(RgbColor.TryParse("#010203", out RgbColor color));
|
||||
Assert.AreEqual(new RgbColor(1, 2, 3), color);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToStringUsesCanonicalLowercase()
|
||||
{
|
||||
public void ToStringUsesCanonicalLowercase() {
|
||||
Assert.AreEqual("#ff0a00", new RgbColor(0xFF, 0x0A, 0x00).ToString());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TextRoundTrips()
|
||||
{
|
||||
public void TextRoundTrips() {
|
||||
var color = new RgbColor(0x12, 0xAB, 0xCD);
|
||||
Assert.AreEqual(color, RgbColor.Parse(color.ToString()));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EqualityComparesComponents()
|
||||
{
|
||||
public void EqualityComparesComponents() {
|
||||
Assert.AreEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 3));
|
||||
Assert.AreNotEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 4));
|
||||
}
|
||||
|
||||
@@ -7,15 +7,13 @@ namespace YKanBan.Tests;
|
||||
/// application project and that the test runner can discover and execute tests.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ProjectReferenceTests
|
||||
{
|
||||
public class ProjectReferenceTests {
|
||||
/// <summary>
|
||||
/// Verifies that a type defined in the application assembly can be
|
||||
/// referenced from the test project.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ApplicationAssemblyIsReferenceable()
|
||||
{
|
||||
public void ApplicationAssemblyIsReferenceable() {
|
||||
Assert.IsNotNull(typeof(App));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -8,11 +8,9 @@ namespace YKanBan.Tests.Storage.AppData;
|
||||
/// degradation on a missing or corrupt file.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AppConfigStoreTests
|
||||
{
|
||||
public class AppConfigStoreTests {
|
||||
[TestMethod]
|
||||
public void LoadMissingFileReturnsDefaults()
|
||||
{
|
||||
public void LoadMissingFileReturnsDefaults() {
|
||||
using var directory = new TempDirectory();
|
||||
var store = new AppConfigStore(Path.Combine(directory.FullPath, "app.json"));
|
||||
|
||||
@@ -32,8 +30,7 @@ public class AppConfigStoreTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadCorruptFileBacksUpAndReturnsDefaults()
|
||||
{
|
||||
public void LoadCorruptFileBacksUpAndReturnsDefaults() {
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
File.WriteAllText(path, "{ this is not valid json");
|
||||
@@ -47,8 +44,7 @@ public class AppConfigStoreTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadJsonNullReturnsDefaults()
|
||||
{
|
||||
public void LoadJsonNullReturnsDefaults() {
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
File.WriteAllText(path, "null");
|
||||
@@ -58,8 +54,7 @@ public class AppConfigStoreTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadReadsKebabCaseKeysAndEnumValues()
|
||||
{
|
||||
public void LoadReadsKebabCaseKeysAndEnumValues() {
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
File.WriteAllText(path, $$"""
|
||||
@@ -97,8 +92,7 @@ public class AppConfigStoreTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadMissingSectionsFillsDefaults()
|
||||
{
|
||||
public void LoadMissingSectionsFillsDefaults() {
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
File.WriteAllText(path, $$"""{ "version": {{AppConfig.CurrentFormatVersion}} }""");
|
||||
@@ -113,17 +107,14 @@ public class AppConfigStoreTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SaveWritesKebabCaseKeysAndEnumValues()
|
||||
{
|
||||
public void SaveWritesKebabCaseKeysAndEnumValues() {
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
var store = new AppConfigStore(path);
|
||||
|
||||
var config = new AppConfig
|
||||
{
|
||||
var config = new AppConfig {
|
||||
Theme = ThemeOption.FollowSystem,
|
||||
Display = new DisplaySettings
|
||||
{
|
||||
Display = new DisplaySettings {
|
||||
CardSort = SortOption.UpdatedDescending,
|
||||
ColumnSort = SortOption.CreatedAscending,
|
||||
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 },
|
||||
@@ -147,18 +138,15 @@ public class AppConfigStoreTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SaveThenLoadRoundTrips()
|
||||
{
|
||||
public void SaveThenLoadRoundTrips() {
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
var store = new AppConfigStore(path);
|
||||
|
||||
var config = new AppConfig
|
||||
{
|
||||
var config = new AppConfig {
|
||||
Language = "zh-Hans",
|
||||
Theme = ThemeOption.Light,
|
||||
Display = new DisplaySettings
|
||||
{
|
||||
Display = new DisplaySettings {
|
||||
CardSort = SortOption.CreatedDescending,
|
||||
ColumnSort = SortOption.TitleAscending,
|
||||
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
@@ -10,11 +11,9 @@ namespace YKanBan.Tests.Storage;
|
||||
/// user_version-driven migration runner.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SqliteDatabaseTests
|
||||
{
|
||||
public class SqliteDatabaseTests {
|
||||
[TestMethod]
|
||||
public void OpenAppliesMandatoryPragmas()
|
||||
{
|
||||
public void OpenAppliesMandatoryPragmas() {
|
||||
using var directory = new TempDirectory();
|
||||
string databasePath = Path.Combine(directory.FullPath, "pragmas.db");
|
||||
|
||||
@@ -25,8 +24,7 @@ public class SqliteDatabaseTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenSetsUserVersionToLatestMigration()
|
||||
{
|
||||
public void OpenSetsUserVersionToLatestMigration() {
|
||||
using var directory = new TempDirectory();
|
||||
string databasePath = Path.Combine(directory.FullPath, "version.db");
|
||||
|
||||
@@ -35,13 +33,11 @@ public class SqliteDatabaseTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReopenKeepsWalJournalMode()
|
||||
{
|
||||
public void ReopenKeepsWalJournalMode() {
|
||||
using var directory = new TempDirectory();
|
||||
string databasePath = Path.Combine(directory.FullPath, "wal.db");
|
||||
|
||||
using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations))
|
||||
{
|
||||
using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) {
|
||||
}
|
||||
|
||||
// WAL mode is stored in the database header, so a fresh connection keeps it.
|
||||
@@ -50,14 +46,12 @@ public class SqliteDatabaseTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenRejectsNewerStoredSchemaVersion()
|
||||
{
|
||||
public void OpenRejectsNewerStoredSchemaVersion() {
|
||||
using var directory = new TempDirectory();
|
||||
string databasePath = Path.Combine(directory.FullPath, "newer.db");
|
||||
|
||||
long newerVersion = WorkspaceSchema.CurrentVersion + 1;
|
||||
using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations))
|
||||
{
|
||||
using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) {
|
||||
SqliteTestHelper.Exec(connection, $"PRAGMA user_version={newerVersion};");
|
||||
}
|
||||
|
||||
@@ -68,8 +62,7 @@ public class SqliteDatabaseTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenRejectsNonContiguousMigrationList()
|
||||
{
|
||||
public void OpenRejectsNonContiguousMigrationList() {
|
||||
using var directory = new TempDirectory();
|
||||
string databasePath = Path.Combine(directory.FullPath, "gap.db");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
@@ -11,11 +12,9 @@ namespace YKanBan.Tests.Storage.Workspace;
|
||||
/// content.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceInitializerTests
|
||||
{
|
||||
public class WorkspaceInitializerTests {
|
||||
[TestMethod]
|
||||
public void IsWorkspaceReflectsInitializationState()
|
||||
{
|
||||
public void IsWorkspaceReflectsInitializationState() {
|
||||
using var directory = new TempDirectory();
|
||||
Assert.IsFalse(WorkspaceInitializer.IsWorkspace(directory.FullPath));
|
||||
|
||||
@@ -24,8 +23,7 @@ public class WorkspaceInitializerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InitializeCreatesStructureAndMigratedEmptyDatabase()
|
||||
{
|
||||
public void InitializeCreatesStructureAndMigratedEmptyDatabase() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
@@ -43,8 +41,7 @@ public class WorkspaceInitializerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InitializeMissingFolderThrows()
|
||||
{
|
||||
public void InitializeMissingFolderThrows() {
|
||||
using var directory = new TempDirectory();
|
||||
string missing = Path.Combine(directory.FullPath, "not-created");
|
||||
|
||||
@@ -54,8 +51,7 @@ public class WorkspaceInitializerTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InitializeTwiceThrows()
|
||||
{
|
||||
public void InitializeTwiceThrows() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@ namespace YKanBan.Tests.Storage.Workspace;
|
||||
/// Tests for exclusive lock acquisition and release.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceLockTests
|
||||
{
|
||||
public class WorkspaceLockTests {
|
||||
[TestMethod]
|
||||
public void AcquireThenDisposeThenAcquireAgainSucceeds()
|
||||
{
|
||||
public void AcquireThenDisposeThenAcquireAgainSucceeds() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
@@ -22,8 +20,7 @@ public class WorkspaceLockTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SecondAcquireWhileHeldThrows()
|
||||
{
|
||||
public void SecondAcquireWhileHeldThrows() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
@@ -34,21 +31,18 @@ public class WorkspaceLockTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AcquireWithoutYkanbanThrowsNotInitialized()
|
||||
{
|
||||
public void AcquireWithoutYkanbanThrowsNotInitialized() {
|
||||
using var directory = new TempDirectory();
|
||||
Assert.ThrowsExactly<WorkspaceNotInitializedException>(
|
||||
() => WorkspaceLock.Acquire(directory.FullPath));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LockFileIsEmptyAfterRelease()
|
||||
{
|
||||
public void LockFileIsEmptyAfterRelease() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
using (WorkspaceLock.Acquire(directory.FullPath))
|
||||
{
|
||||
using (WorkspaceLock.Acquire(directory.FullPath)) {
|
||||
}
|
||||
|
||||
// The lock file survives release but carries no content; only the OS handle matters.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
@@ -12,14 +14,11 @@ namespace YKanBan.Tests.Storage.Workspace;
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class WorkspacePresetTests
|
||||
{
|
||||
public class WorkspacePresetTests {
|
||||
[TestMethod]
|
||||
public void AddPresetColumnsStoresEnglishTextUnderEnglishCulture()
|
||||
{
|
||||
public void AddPresetColumnsStoresEnglishTextUnderEnglishCulture() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
|
||||
using var directory = new TempDirectory();
|
||||
@@ -43,19 +42,15 @@ public class WorkspacePresetTests
|
||||
Resources.PresetColumn_Done_Description,
|
||||
},
|
||||
SqliteTestHelper.GetColumnDescriptions(connection));
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddPresetColumnsStoresChineseTextUnderChineseCulture()
|
||||
{
|
||||
public void AddPresetColumnsStoresChineseTextUnderChineseCulture() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("zh-Hans");
|
||||
|
||||
using var directory = new TempDirectory();
|
||||
@@ -74,19 +69,15 @@ public class WorkspacePresetTests
|
||||
// Chinese resources must resolve to different text than English.
|
||||
Assert.AreEqual("待办", Resources.PresetColumn_Todo);
|
||||
Assert.AreEqual("已完成的任务。", Resources.PresetColumn_Done_Description);
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddPresetTagsStoresEightTagsUnderEnglishCulture()
|
||||
{
|
||||
public void AddPresetTagsStoresEightTagsUnderEnglishCulture() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
|
||||
using var directory = new TempDirectory();
|
||||
@@ -100,19 +91,15 @@ public class WorkspacePresetTests
|
||||
Assert.AreEqual(("bug", "#d73a4a", "Something isn't working"), tags[0]);
|
||||
Assert.AreEqual(("documentation", "#0075ca", "Improvements or additions to documentation"), tags[1]);
|
||||
Assert.AreEqual(("wontfix", "#ffffff", "This will not be worked on"), tags[7]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddPresetTagsStoresChineseNamesUnderChineseCulture()
|
||||
{
|
||||
public void AddPresetTagsStoresChineseNamesUnderChineseCulture() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("zh-Hans");
|
||||
|
||||
using var directory = new TempDirectory();
|
||||
@@ -125,19 +112,15 @@ public class WorkspacePresetTests
|
||||
Assert.AreEqual(8, tags.Count);
|
||||
Assert.AreEqual("缺陷", tags[0].Name);
|
||||
Assert.AreEqual("功能没有正常工作", tags[0].Description);
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PresetContentIsDataAndIgnoresLaterCultureChanges()
|
||||
{
|
||||
public void PresetContentIsDataAndIgnoresLaterCultureChanges() {
|
||||
CultureInfo? original = Resources.Culture;
|
||||
try
|
||||
{
|
||||
try {
|
||||
Resources.Culture = new CultureInfo("en");
|
||||
|
||||
using var directory = new TempDirectory();
|
||||
@@ -150,9 +133,7 @@ public class WorkspacePresetTests
|
||||
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
|
||||
Assert.AreEqual("To Do", SqliteTestHelper.GetColumnTitles(connection)[0]);
|
||||
Assert.AreEqual("bug", SqliteTestHelper.GetTags(connection)[0].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
} finally {
|
||||
Resources.Culture = original;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Search;
|
||||
using YKanBan.Storage.Workspace;
|
||||
@@ -11,16 +12,14 @@ namespace YKanBan.Tests.Storage.Workspace;
|
||||
/// transaction atomicity and the timestamp refresh rules.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceRepositoryTests
|
||||
{
|
||||
public class WorkspaceRepositoryTests {
|
||||
/// <summary>
|
||||
/// Owns a workspace with fixed-timestamp fixture data (one column, one
|
||||
/// card, two tags linked to the card) plus the repository under test.
|
||||
/// Fixture timestamps are small literal values so that any real write,
|
||||
/// which stamps the current Unix time, is observable.
|
||||
/// </summary>
|
||||
private sealed class RepositoryFixture : IDisposable
|
||||
{
|
||||
private sealed class RepositoryFixture : IDisposable {
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public WorkspaceRepository Repository { get; }
|
||||
@@ -33,12 +32,10 @@ public class WorkspaceRepositoryTests
|
||||
|
||||
public long UiTagId { get; } = 2;
|
||||
|
||||
public RepositoryFixture()
|
||||
{
|
||||
public RepositoryFixture() {
|
||||
WorkspaceInitializer.Initialize(Directory.FullPath);
|
||||
|
||||
using (SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath))
|
||||
{
|
||||
using (SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) {
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Col', '', 100, 100);");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'T', 'C', 100, 100);");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('bug', '#ff0000', '');");
|
||||
@@ -50,16 +47,14 @@ public class WorkspaceRepositoryTests
|
||||
Repository = new WorkspaceRepository(Directory.FullPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
public void Dispose() {
|
||||
Repository.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetColumnsReturnsTheSeededColumn()
|
||||
{
|
||||
public void GetColumnsReturnsTheSeededColumn() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
ColumnModel column = fixture.Repository.GetColumn(fixture.ColumnId)!;
|
||||
Assert.AreEqual("Col", column.Title);
|
||||
@@ -67,8 +62,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddColumnSetsEqualTimestamps()
|
||||
{
|
||||
public void AddColumnSetsEqualTimestamps() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
ColumnModel column = fixture.Repository.AddColumn("New", "desc");
|
||||
Assert.IsTrue(column.CreatedAt > 0L);
|
||||
@@ -76,8 +70,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateColumnWithoutChangeWritesNothing()
|
||||
{
|
||||
public void UpdateColumnWithoutChangeWritesNothing() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", "");
|
||||
@@ -87,8 +80,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateColumnChangeRefreshesTimestamp()
|
||||
{
|
||||
public void UpdateColumnChangeRefreshesTimestamp() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc");
|
||||
@@ -98,8 +90,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteColumnCascadesToCardsAndTagLinks()
|
||||
{
|
||||
public void DeleteColumnCascadesToCardsAndTagLinks() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId));
|
||||
@@ -112,8 +103,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteCardRemovesTagLinksButKeepsTags()
|
||||
{
|
||||
public void DeleteCardRemovesTagLinksButKeepsTags() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId));
|
||||
@@ -125,8 +115,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteTagDetachesFromCardsButKeepsCards()
|
||||
{
|
||||
public void DeleteTagDetachesFromCardsButKeepsCards() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId));
|
||||
@@ -138,8 +127,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardWithoutEffectiveChangeWritesNothing()
|
||||
{
|
||||
public void UpdateCardWithoutEffectiveChangeWritesNothing() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
CardModel result = fixture.Repository.UpdateCard(
|
||||
@@ -149,8 +137,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardTitleChangeRefreshesTimestamp()
|
||||
{
|
||||
public void UpdateCardTitleChangeRefreshesTimestamp() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
CardModel updated = fixture.Repository.UpdateCard(
|
||||
@@ -161,8 +148,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp()
|
||||
{
|
||||
public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]);
|
||||
@@ -172,8 +158,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardWithUnknownTagRollsBackAtomically()
|
||||
{
|
||||
public void UpdateCardWithUnknownTagRollsBackAtomically() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
// Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back.
|
||||
@@ -188,8 +173,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveCardUpdatesColumnWithoutRefreshingTimestamp()
|
||||
{
|
||||
public void MoveCardUpdatesColumnWithoutRefreshingTimestamp() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
long target = fixture.Repository.AddColumn("Target", "").Id;
|
||||
|
||||
@@ -202,8 +186,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveCardToCurrentColumnIsNoOp()
|
||||
{
|
||||
public void MoveCardToCurrentColumnIsNoOp() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId));
|
||||
@@ -212,8 +195,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddCardCreatesWithEqualTimestampsAndLinkedTags()
|
||||
{
|
||||
public void AddCardCreatesWithEqualTimestampsAndLinkedTags() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]);
|
||||
@@ -225,8 +207,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateTagWithoutChangeWritesNothing()
|
||||
{
|
||||
public void UpdateTagWithoutChangeWritesNothing() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
TagModel result = fixture.Repository.UpdateTag(
|
||||
@@ -237,8 +218,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateTagChangeWrites()
|
||||
{
|
||||
public void UpdateTagChangeWrites() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
TagModel updated = fixture.Repository.UpdateTag(
|
||||
@@ -251,16 +231,14 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddTagWithDuplicateNameIsRejected()
|
||||
{
|
||||
public void AddTagWithDuplicateNameIsRejected() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetCardIdsMatchingRunsTheCompiledSearch()
|
||||
{
|
||||
public void GetCardIdsMatchingRunsTheCompiledSearch() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("T"));
|
||||
|
||||
@@ -270,8 +248,7 @@ public class WorkspaceRepositoryTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetTagsWithUsageCountsAssignments()
|
||||
{
|
||||
public void GetTagsWithUsageCountsAssignments() {
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
@@ -10,19 +11,16 @@ namespace YKanBan.Tests.Storage.Workspace;
|
||||
/// and that the cascading delete chains work as specified.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceSchemaTests
|
||||
{
|
||||
public class WorkspaceSchemaTests {
|
||||
/// <summary>
|
||||
/// Owns a temp directory plus a fresh workspace-schema database for a single test.
|
||||
/// </summary>
|
||||
private sealed class FreshDatabase : IDisposable
|
||||
{
|
||||
private sealed class FreshDatabase : IDisposable {
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public SqliteConnection Connection { get; }
|
||||
|
||||
public FreshDatabase()
|
||||
{
|
||||
public FreshDatabase() {
|
||||
Connection = SqliteDatabase.Open(
|
||||
Path.Combine(Directory.FullPath, "ws.db"), WorkspaceSchema.Migrations);
|
||||
}
|
||||
@@ -30,24 +28,21 @@ public class WorkspaceSchemaTests
|
||||
/// <summary>
|
||||
/// Closes the connection before removing the temporary directory.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
public void Dispose() {
|
||||
Connection.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EmptyColumnTitleIsRejected()
|
||||
{
|
||||
public void EmptyColumnTitleIsRejected() {
|
||||
using var db = new FreshDatabase();
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('', 1, 1);"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void WhitespaceColumnTitleIsAccepted()
|
||||
{
|
||||
public void WhitespaceColumnTitleIsAccepted() {
|
||||
using var db = new FreshDatabase();
|
||||
// No trimming: a single space is a non-empty title as far as the DDL is concerned.
|
||||
int affected = SqliteTestHelper.Exec(
|
||||
@@ -56,8 +51,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DuplicateColumnTitleIsRejected()
|
||||
{
|
||||
public void DuplicateColumnTitleIsRejected() {
|
||||
using var db = new FreshDatabase();
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
@@ -65,8 +59,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ColumnTitlesCompareCaseSensitively()
|
||||
{
|
||||
public void ColumnTitlesCompareCaseSensitively() {
|
||||
using var db = new FreshDatabase();
|
||||
// BINARY collation: 'Foo' and 'foo' are distinct titles.
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
|
||||
@@ -75,28 +68,24 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EmptyTagNameIsRejected()
|
||||
{
|
||||
public void EmptyTagNameIsRejected() {
|
||||
using var db = new FreshDatabase();
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('', '#112233');"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TagNamesAcceptSpacesEmojiAndReservedWords()
|
||||
{
|
||||
public void TagNamesAcceptSpacesEmojiAndReservedWords() {
|
||||
using var db = new FreshDatabase();
|
||||
// Tag names are fully open: no charset or length rules, no trimming.
|
||||
foreach (string name in new[] { " ", "a b", "🏷️", "AND", "tag:with:colons" })
|
||||
{
|
||||
foreach (string name in new[] { " ", "a b", "🏷️", "AND", "tag:with:colons" }) {
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ($n, '#112233');", ("$n", name));
|
||||
}
|
||||
Assert.AreEqual(5L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DuplicateTagNameIsRejected()
|
||||
{
|
||||
public void DuplicateTagNameIsRejected() {
|
||||
using var db = new FreshDatabase();
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
@@ -107,8 +96,7 @@ public class WorkspaceSchemaTests
|
||||
[DataRow("#000000")]
|
||||
[DataRow("#FFFFFF")]
|
||||
[DataRow("#a1B2c3")]
|
||||
public void ValidTagColorsAreAccepted(string color)
|
||||
{
|
||||
public void ValidTagColorsAreAccepted(string color) {
|
||||
using var db = new FreshDatabase();
|
||||
int affected = SqliteTestHelper.Exec(
|
||||
db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color));
|
||||
@@ -124,16 +112,14 @@ public class WorkspaceSchemaTests
|
||||
[DataRow(" #112233")]
|
||||
[DataRow("#112233 ")]
|
||||
[DataRow("")]
|
||||
public void InvalidTagColorsAreRejected(string color)
|
||||
{
|
||||
public void InvalidTagColorsAreRejected(string color) {
|
||||
using var db = new FreshDatabase();
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CardRequiresExistingColumn()
|
||||
{
|
||||
public void CardRequiresExistingColumn() {
|
||||
using var db = new FreshDatabase();
|
||||
// foreign_keys=ON makes the FK enforceable; this is the DDL contract behind the cascades.
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
@@ -141,8 +127,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CardDefaultsToEmptyTitleAndContent()
|
||||
{
|
||||
public void CardDefaultsToEmptyTitleAndContent() {
|
||||
using var db = new FreshDatabase();
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);");
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);");
|
||||
@@ -153,8 +138,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CardTagDuplicateAssignmentIsRejected()
|
||||
{
|
||||
public void CardTagDuplicateAssignmentIsRejected() {
|
||||
using var db = new FreshDatabase();
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);");
|
||||
SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);");
|
||||
@@ -167,8 +151,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeletingColumnCascadesToCardsAndCardTags()
|
||||
{
|
||||
public void DeletingColumnCascadesToCardsAndCardTags() {
|
||||
using var db = new FreshDatabase();
|
||||
InsertBoardRow(db.Connection);
|
||||
|
||||
@@ -182,8 +165,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeletingCardCascadesToCardTagsOnly()
|
||||
{
|
||||
public void DeletingCardCascadesToCardTagsOnly() {
|
||||
using var db = new FreshDatabase();
|
||||
InsertBoardRow(db.Connection);
|
||||
|
||||
@@ -196,8 +178,7 @@ public class WorkspaceSchemaTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeletingTagRemovesAssignmentsButKeepsCards()
|
||||
{
|
||||
public void DeletingTagRemovesAssignmentsButKeepsCards() {
|
||||
using var db = new FreshDatabase();
|
||||
InsertBoardRow(db.Connection);
|
||||
|
||||
@@ -214,8 +195,7 @@ public class WorkspaceSchemaTests
|
||||
/// Inserts one column, one card, one tag and their assignment, ready for a cascade test.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open workspace-schema connection.</param>
|
||||
private static void InsertBoardRow(SqliteConnection connection)
|
||||
{
|
||||
private static void InsertBoardRow(SqliteConnection connection) {
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
|
||||
|
||||
@@ -10,11 +10,9 @@ namespace YKanBan.Tests.Storage.Workspace;
|
||||
/// the whole session and the repository is usable through it.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceSessionTests
|
||||
{
|
||||
public class WorkspaceSessionTests {
|
||||
[TestMethod]
|
||||
public void OpenHoldsLockUntilDisposed()
|
||||
{
|
||||
public void OpenHoldsLockUntilDisposed() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
@@ -25,8 +23,7 @@ public class WorkspaceSessionTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DisposeReleasesLockForReopen()
|
||||
{
|
||||
public void DisposeReleasesLockForReopen() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
@@ -37,8 +34,7 @@ public class WorkspaceSessionTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenUninitializedFolderThrowsNotInitialized()
|
||||
{
|
||||
public void OpenUninitializedFolderThrowsNotInitialized() {
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
Assert.ThrowsExactly<WorkspaceNotInitializedException>(
|
||||
@@ -46,8 +42,7 @@ public class WorkspaceSessionTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RepositoryIsUsableThroughSession()
|
||||
{
|
||||
public void RepositoryIsUsableThroughSession() {
|
||||
using var directory = new TempDirectory();
|
||||
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.Workspace;
|
||||
|
||||
@@ -8,8 +9,7 @@ namespace YKanBan.Tests.TestUtilities;
|
||||
/// Shared helpers for the storage tests: small SQL conveniences plus opening a
|
||||
/// workspace database with the real migration list.
|
||||
/// </summary>
|
||||
public static class SqliteTestHelper
|
||||
{
|
||||
public static class SqliteTestHelper {
|
||||
/// <summary>
|
||||
/// Executes a non-query SQL statement and returns the affected row count.
|
||||
/// </summary>
|
||||
@@ -17,12 +17,10 @@ public static class SqliteTestHelper
|
||||
/// <param name="sql">The statement to execute.</param>
|
||||
/// <param name="parameters">Name/value pairs bound as SQL parameters.</param>
|
||||
/// <returns>The number of affected rows.</returns>
|
||||
public static int Exec(SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
public static int Exec(SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
foreach ((string name, object? value) in parameters)
|
||||
{
|
||||
foreach ((string name, object? value) in parameters) {
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
return command.ExecuteNonQuery();
|
||||
@@ -34,8 +32,7 @@ public static class SqliteTestHelper
|
||||
/// <param name="connection">An open database connection.</param>
|
||||
/// <param name="sql">The query to execute.</param>
|
||||
/// <returns>The scalar result cast to a string.</returns>
|
||||
public static string ScalarString(SqliteConnection connection, string sql)
|
||||
{
|
||||
public static string ScalarString(SqliteConnection connection, string sql) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return (string)command.ExecuteScalar()!;
|
||||
@@ -47,8 +44,7 @@ public static class SqliteTestHelper
|
||||
/// <param name="connection">An open database connection.</param>
|
||||
/// <param name="sql">The query to execute.</param>
|
||||
/// <returns>The scalar result cast to a long.</returns>
|
||||
public static long ScalarLong(SqliteConnection connection, string sql)
|
||||
{
|
||||
public static long ScalarLong(SqliteConnection connection, string sql) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return (long)command.ExecuteScalar()!;
|
||||
@@ -60,8 +56,7 @@ public static class SqliteTestHelper
|
||||
/// <param name="connection">An open database connection.</param>
|
||||
/// <param name="tableName">The table name to look for.</param>
|
||||
/// <returns><see langword="true"/> when the table exists.</returns>
|
||||
public static bool TableExists(SqliteConnection connection, string tableName)
|
||||
{
|
||||
public static bool TableExists(SqliteConnection connection, string tableName) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name;";
|
||||
command.Parameters.AddWithValue("$name", tableName);
|
||||
@@ -73,15 +68,13 @@ public static class SqliteTestHelper
|
||||
/// </summary>
|
||||
/// <param name="connection">An open database connection.</param>
|
||||
/// <returns>The column titles in id order.</returns>
|
||||
public static List<string> GetColumnTitles(SqliteConnection connection)
|
||||
{
|
||||
public static List<string> GetColumnTitles(SqliteConnection connection) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT title FROM columns ORDER BY id ASC;";
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var titles = new List<string>();
|
||||
while (reader.Read())
|
||||
{
|
||||
while (reader.Read()) {
|
||||
titles.Add(reader.GetString(0));
|
||||
}
|
||||
return titles;
|
||||
@@ -92,15 +85,13 @@ public static class SqliteTestHelper
|
||||
/// </summary>
|
||||
/// <param name="connection">An open database connection.</param>
|
||||
/// <returns>The column descriptions in id order.</returns>
|
||||
public static List<string> GetColumnDescriptions(SqliteConnection connection)
|
||||
{
|
||||
public static List<string> GetColumnDescriptions(SqliteConnection connection) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT description FROM columns ORDER BY id ASC;";
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var descriptions = new List<string>();
|
||||
while (reader.Read())
|
||||
{
|
||||
while (reader.Read()) {
|
||||
descriptions.Add(reader.GetString(0));
|
||||
}
|
||||
return descriptions;
|
||||
@@ -111,15 +102,13 @@ public static class SqliteTestHelper
|
||||
/// </summary>
|
||||
/// <param name="connection">An open database connection.</param>
|
||||
/// <returns>The tags in id order.</returns>
|
||||
public static List<(string Name, string Color, string Description)> GetTags(SqliteConnection connection)
|
||||
{
|
||||
public static List<(string Name, string Color, string Description)> GetTags(SqliteConnection connection) {
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name, color, description FROM tags ORDER BY id ASC;";
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var tags = new List<(string, string, string)>();
|
||||
while (reader.Read())
|
||||
{
|
||||
while (reader.Read()) {
|
||||
tags.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||
}
|
||||
return tags;
|
||||
@@ -130,8 +119,7 @@ public static class SqliteTestHelper
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The workspace folder path.</param>
|
||||
/// <returns>An open, migrated connection.</returns>
|
||||
public static SqliteConnection OpenWorkspace(string folderPath)
|
||||
{
|
||||
public static SqliteConnection OpenWorkspace(string folderPath) {
|
||||
return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,13 @@ namespace YKanBan.Tests.TestUtilities;
|
||||
/// disposal. The removal is retried once because SQLite WAL sidecar files can
|
||||
/// linger for a moment after the connections close.
|
||||
/// </summary>
|
||||
public sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public sealed class TempDirectory : IDisposable {
|
||||
/// <summary>
|
||||
/// Gets the unique directory path managed by this instance.
|
||||
/// </summary>
|
||||
public string FullPath { get; }
|
||||
|
||||
public TempDirectory()
|
||||
{
|
||||
public TempDirectory() {
|
||||
FullPath = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(FullPath);
|
||||
}
|
||||
@@ -21,26 +19,19 @@ public sealed class TempDirectory : IDisposable
|
||||
/// <summary>
|
||||
/// Deletes the directory tree, retrying once after a short delay.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
public void Dispose() {
|
||||
try {
|
||||
// SAFETY: Recursive directory delete is safe: FullPath is the
|
||||
// read-only, GUID-named directory this instance created under the
|
||||
// OS temp folder, so it can never target user data or the temp root.
|
||||
Directory.Delete(FullPath, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
} catch (IOException) {
|
||||
Thread.Sleep(100);
|
||||
try
|
||||
{
|
||||
try {
|
||||
// SAFETY: Retry of the same recursive delete, still confined to
|
||||
// this instance's own temp directory.
|
||||
Directory.Delete(FullPath, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
} catch (IOException) {
|
||||
// Leftovers in the OS temp folder are harmless; never fail a test over cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user