style: expand one-line summaries and run dotnet format solution-wide

This commit is contained in:
yyc12345 committed 2026-10-04 10:22:02 +08:00
1 parent 9ea551b600
commit 8b7e50a955
95 files changed
+970 -1424

No files matched your search

+16 -33
View File
@@ -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);
+9 -18
View File
@@ -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));
}
+2 -4
View File
@@ -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 -14
View File
@@ -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));
+39 -78
View File
@@ -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));
+18 -34
View File
@@ -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 },
+9 -16
View File
@@ -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);
+14 -26
View File
@@ -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);
}
}
+7 -16
View File
@@ -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.
}
}