feat: implement tags tab, board search execution and markdown export wiring

This commit is contained in:
doyaGu committed 2026-10-03 07:34:34 -04:00
1 parent e3f58ce150
commit 6ea0df43e3
22 files changed
+1300 -89

No files matched your search

@@ -16,42 +16,6 @@ namespace YKanBan.Tests.ViewModels;
[TestClass]
public class BoardViewModelTests
{
/// <summary>
/// Records every dialog shown and lets a test script answer it. The result
/// is whatever the dialog closed with; a dialog the script leaves open
/// counts as dismissed.
/// </summary>
private sealed class ScriptedDialogService : IDialogService
{
public List<DialogViewModelBase> Shown { get; } = [];
public Func<DialogViewModelBase, Task> Script { get; set; } = _ => Task.CompletedTask;
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
{
Shown.Add(dialog);
bool? result = null;
dialog.CloseRequested += (_, closed) => result ??= closed;
await Script(dialog);
return result ?? false;
}
}
/// <summary>Answers every confirmation dialog with the given choice.</summary>
private static Func<DialogViewModelBase, Task> AnswerConfirmations(bool confirm) => dialog =>
{
if (dialog is ConfirmDialogViewModel confirmation)
{
if (confirm)
{
confirmation.ConfirmCommand.Execute(null);
return Task.CompletedTask;
}
return confirmation.CancelCommand.ExecuteAsync(null);
}
return Task.CompletedTask;
};
private sealed class Fixture : IDisposable
{
public TempDirectory Directory { get; } = new();
@@ -144,7 +108,7 @@ public class BoardViewModelTests
fixture.Repository.AddCard(column.Id, "", "", []);
fixture.Repository.AddCard(column.Id, "", "", []);
BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = AnswerConfirmations(false);
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
@@ -174,7 +138,7 @@ public class BoardViewModelTests
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "gone", "", []);
BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = AnswerConfirmations(true);
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null);
@@ -271,11 +235,11 @@ public class BoardViewModelTests
editor.CloseRequested += (_, result) => closedWith = result;
editor.Content = "draft";
fixture.Dialogs.Script = AnswerConfirmations(false);
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
await editor.RequestCancelAsync();
Assert.IsNull(closedWith, "Declining the discard keeps the editor open.");
fixture.Dialogs.Script = AnswerConfirmations(true);
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
await editor.RequestCancelAsync();
Assert.IsFalse(closedWith);
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
@@ -0,0 +1,400 @@
using YKanBan.Models;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
using YKanBan.ViewModels;
using YKanBan.ViewModels.Board;
using YKanBan.ViewModels.Dialogs;
using YKanBan.ViewModels.Tags;
namespace YKanBan.Tests.ViewModels;
/// <summary>
/// View-model tests of the search bar, the tags tab, the cross-tab refresh and
/// the export wiring, against a real temporary workspace with a scripted
/// dialog service.
/// </summary>
[TestClass]
public class TagsAndSearchViewModelTests
{
private sealed class Fixture : IDisposable
{
public TempDirectory Directory { get; } = new();
public WorkspaceSession Session { get; }
public WorkspaceRepository Repository => Session.Repository;
public AppConfig Config { get; } = new();
public ScriptedDialogService Dialogs { get; } = new();
public Fixture()
{
WorkspaceInitializer.Initialize(Directory.FullPath);
Session = WorkspaceSession.Open(Directory.FullPath);
}
public BoardViewModel CreateBoard() => new(Repository, Config, Dialogs);
public TagsViewModel CreateTags() => new(Repository, Config, Dialogs);
public WorkspaceViewModel CreateWorkspace() =>
new(Session, new Interaction<Unit>(), new Interaction<Unit>(), Config, Dialogs);
public void Dispose()
{
Session.Dispose();
Directory.Dispose();
}
}
private static string[] VisibleTitles(BoardViewModel board) =>
board.Columns.SelectMany(column => column.Cards).Select(card => card.Title).Order(StringComparer.Ordinal).ToArray();
/// <summary>Moves a visible card through its menu command.</summary>
private static Task MoveAsync(BoardViewModel board, long cardId, long columnId) =>
board.Columns.SelectMany(column => column.Cards).Single(card => card.Card.Id == cardId)
.MoveToCommand.ExecuteAsync(columnId);
#region Search
[TestMethod]
public async Task SearchFiltersCardsAndKeepsEmptyColumns()
{
using var fixture = new Fixture();
ColumnModel first = fixture.Repository.AddColumn("A", "");
ColumnModel second = fixture.Repository.AddColumn("B", "");
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
fixture.Repository.AddCard(first.Id, "crash", "", [bug.Id]);
fixture.Repository.AddCard(second.Id, "idea", "", []);
BoardViewModel board = fixture.CreateBoard();
board.SearchText = "tag:BUG";
await board.SearchCommand.ExecuteAsync(null);
CollectionAssert.AreEqual(new[] { "crash" }, VisibleTitles(board));
Assert.AreEqual(2, board.Columns.Count);
Assert.AreEqual(0, board.Columns[1].CardCount);
Assert.IsNotNull(board.ActiveFilter);
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
}
[TestMethod]
public async Task InvalidSearchShowsTheErrorAndKeepsTextAndPreviousResult()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "alpha", "", []);
fixture.Repository.AddCard(column.Id, "beta", "", []);
BoardViewModel board = fixture.CreateBoard();
board.SearchText = "alpha";
await board.SearchCommand.ExecuteAsync(null);
board.SearchText = "alpha AND";
await board.SearchCommand.ExecuteAsync(null);
var error = (MessageDialogViewModel)fixture.Dialogs.Shown.Single();
Assert.AreEqual(Resources.Search_Error_Title, error.Title);
Assert.AreEqual("alpha AND", board.SearchText);
CollectionAssert.AreEqual(new[] { "alpha" }, VisibleTitles(board));
}
[TestMethod]
public async Task WritesRefreshWithTheLastSuccessfulExpressionNotTheBoxText()
{
using var fixture = new Fixture();
ColumnModel first = fixture.Repository.AddColumn("A", "");
ColumnModel second = fixture.Repository.AddColumn("B", "");
CardModel alpha = fixture.Repository.AddCard(first.Id, "alpha", "", []);
fixture.Repository.AddCard(first.Id, "beta", "", []);
BoardViewModel board = fixture.CreateBoard();
board.SearchText = "alpha";
await board.SearchCommand.ExecuteAsync(null);
board.SearchText = "beta";
await MoveAsync(board, alpha.Id, second.Id);
CollectionAssert.AreEqual(new[] { "alpha" }, VisibleTitles(board));
Assert.AreEqual(1, board.Columns[1].CardCount);
}
[TestMethod]
public async Task BlankSearchAndClearShowEveryCard()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "alpha", "", []);
fixture.Repository.AddCard(column.Id, "beta", "", []);
BoardViewModel board = fixture.CreateBoard();
board.SearchText = "alpha";
await board.SearchCommand.ExecuteAsync(null);
board.SearchText = " ";
await board.SearchCommand.ExecuteAsync(null);
Assert.IsNull(board.ActiveFilter);
CollectionAssert.AreEqual(new[] { "alpha", "beta" }, VisibleTitles(board));
board.SearchText = "alpha";
await board.SearchCommand.ExecuteAsync(null);
Assert.IsTrue(board.HasSearchText);
board.ClearSearchCommand.Execute(null);
Assert.AreEqual("", board.SearchText);
Assert.IsFalse(board.HasSearchText);
Assert.IsNull(board.ActiveFilter);
CollectionAssert.AreEqual(new[] { "alpha", "beta" }, VisibleTitles(board));
}
[TestMethod]
public async Task DataWrittenIsRaisedForWritesButNotForSearchOrSort()
{
using var fixture = new Fixture();
ColumnModel first = fixture.Repository.AddColumn("A", "");
ColumnModel second = fixture.Repository.AddColumn("B", "");
CardModel card = fixture.Repository.AddCard(first.Id, "x", "", []);
BoardViewModel board = fixture.CreateBoard();
int raised = 0;
board.DataWritten += (_, _) => raised++;
board.SearchText = "x";
await board.SearchCommand.ExecuteAsync(null);
board.SelectedSort = board.SortOptions[1];
Assert.AreEqual(0, raised);
await MoveAsync(board, card.Id, second.Id);
Assert.AreEqual(1, raised);
}
#endregion
#region Tags tab
[TestMethod]
public void TagsListInIdOrderWithUsageCounts()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "Broken");
TagModel unused = fixture.Repository.AddTag("unused", new RgbColor(4, 5, 6), "");
fixture.Repository.AddCard(column.Id, "a", "", [bug.Id]);
fixture.Repository.AddCard(column.Id, "b", "", [bug.Id]);
TagsViewModel tags = fixture.CreateTags();
CollectionAssert.AreEqual(new[] { bug.Id, unused.Id }, tags.Tags.Select(row => row.Tag.Id).ToArray());
CollectionAssert.AreEqual(new long[] { 2, 0 }, tags.Tags.Select(row => row.UsageCount).ToArray());
Assert.IsTrue(tags.Tags[0].HasDescription);
Assert.AreEqual("#010203", tags.Tags[0].ColorText);
Assert.IsFalse(tags.IsEmpty);
}
[TestMethod]
public async Task NewTagIsWrittenAndAnnounced()
{
using var fixture = new Fixture();
TagsViewModel tags = fixture.CreateTags();
int changed = 0;
tags.TagsChanged += (_, _) => changed++;
fixture.Dialogs.Script = dialog =>
{
var editor = (TagEditorDialogViewModel)dialog;
editor.Name = "ui";
editor.PickSwatchCommand.Execute(TagColors.Palette[2].ToString());
editor.Description = "Looks";
editor.ConfirmCommand.Execute(null);
return Task.CompletedTask;
};
await tags.AddTagCommand.ExecuteAsync(null);
TagModel created = fixture.Repository.GetTags().Single();
Assert.AreEqual("ui", created.Name);
Assert.AreEqual(TagColors.Palette[2], created.Color);
Assert.AreEqual("Looks", created.Description);
Assert.AreEqual(1, tags.Tags.Count);
Assert.AreEqual(1, changed);
}
[TestMethod]
public async Task TagEditorKeepsTheDialogOpenOnDuplicateNameOrInvalidColor()
{
using var fixture = new Fixture();
fixture.Repository.AddTag("Bug", new RgbColor(1, 2, 3), "");
TagsViewModel tags = fixture.CreateTags();
TagEditorDialogViewModel? editor = null;
bool closed = false;
fixture.Dialogs.Script = dialog =>
{
editor = (TagEditorDialogViewModel)dialog;
editor.CloseRequested += (_, _) => closed = true;
editor.Name = "bug";
editor.ConfirmCommand.Execute(null);
return Task.CompletedTask;
};
await tags.AddTagCommand.ExecuteAsync(null);
Assert.IsNotNull(editor);
Assert.IsTrue(editor.HasError);
Assert.IsFalse(closed);
Assert.AreEqual("bug", editor.Name);
editor.Name = "other";
editor.Color = "red";
editor.ConfirmCommand.Execute(null);
Assert.AreEqual(Resources.Error_Tag_InvalidColor, editor.ErrorMessage);
Assert.IsFalse(closed);
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
}
[TestMethod]
public async Task EditingATagRenamesRecolorsAndDescribesIt()
{
using var fixture = new Fixture();
TagModel tag = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
TagsViewModel tags = fixture.CreateTags();
fixture.Dialogs.Script = dialog =>
{
var editor = (TagEditorDialogViewModel)dialog;
Assert.AreEqual("bug", editor.Name);
Assert.AreEqual("#010203", editor.Color);
editor.Name = "defect";
editor.Color = "#ABCDEF";
editor.Description = "Broken";
editor.ConfirmCommand.Execute(null);
return Task.CompletedTask;
};
await tags.Tags[0].EditCommand.ExecuteAsync(null);
TagModel updated = fixture.Repository.GetTags().Single();
Assert.AreEqual(tag.Id, updated.Id);
Assert.AreEqual("defect", updated.Name);
Assert.AreEqual(new RgbColor(0xab, 0xcd, 0xef), updated.Color);
Assert.AreEqual("Broken", updated.Description);
Assert.AreEqual("defect", tags.Tags[0].Badge.Name);
}
[TestMethod]
public async Task DeletingATagAsksFirstAndKeepsTheCards()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
CardModel card = fixture.Repository.AddCard(column.Id, "a", "", [bug.Id]);
TagsViewModel tags = fixture.CreateTags();
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
await tags.Tags[0].DeleteCommand.ExecuteAsync(null);
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
await tags.Tags[0].DeleteCommand.ExecuteAsync(null);
Assert.AreEqual(0, fixture.Repository.GetTags().Count);
Assert.IsTrue(tags.IsEmpty);
CardModel kept = fixture.Repository.GetCard(card.Id)!;
Assert.AreEqual(0, kept.Tags.Count);
}
[TestMethod]
public async Task DeletingATagWithoutConfirmationShowsNoDialog()
{
using var fixture = new Fixture();
fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
fixture.Config.Confirmations.DeleteTag = false;
TagsViewModel tags = fixture.CreateTags();
await tags.Tags[0].DeleteCommand.ExecuteAsync(null);
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
Assert.AreEqual(0, fixture.Repository.GetTags().Count);
}
#endregion
#region Workspace wiring and export
[TestMethod]
public async Task TagWritesRefreshTheBoardAndBoardWritesRefreshTheTags()
{
using var fixture = new Fixture();
ColumnModel first = fixture.Repository.AddColumn("A", "");
ColumnModel second = fixture.Repository.AddColumn("B", "");
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
CardModel card = fixture.Repository.AddCard(first.Id, "a", "", [bug.Id]);
WorkspaceViewModel workspace = fixture.CreateWorkspace();
// A filtered board drops the card once the tag is gone.
workspace.Board.SearchText = "tag:bug";
await workspace.Board.SearchCommand.ExecuteAsync(null);
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
await workspace.Tags.Tags[0].DeleteCommand.ExecuteAsync(null);
Assert.AreEqual(0, workspace.Board.Columns.Sum(column => column.CardCount));
// Re-tag through the repository, then a board write updates the usage count.
workspace.Board.ClearSearchCommand.Execute(null);
TagModel ui = fixture.Repository.AddTag("ui", new RgbColor(4, 5, 6), "");
fixture.Repository.UpdateCard(card.Id, "a", "", [ui.Id]);
Assert.AreEqual(0, workspace.Tags.Tags.Count);
await MoveAsync(workspace.Board, card.Id, second.Id);
Assert.AreEqual(1, workspace.Tags.Tags.Single().UsageCount);
}
[TestMethod]
public async Task ExportWritesTheWholeWorkspaceIgnoringTheSearch()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "alpha", "", []);
fixture.Repository.AddCard(column.Id, "beta", "", []);
WorkspaceViewModel workspace = fixture.CreateWorkspace();
workspace.Board.SearchText = "alpha";
await workspace.Board.SearchCommand.ExecuteAsync(null);
string path = Path.Combine(fixture.Directory.FullPath, "out.md");
fixture.Dialogs.SaveFilePath = path;
await workspace.ExportCommand.ExecuteAsync(null);
string suggested = fixture.Dialogs.SuggestedFileNames.Single();
StringAssert.StartsWith(suggested, workspace.WorkspaceName);
StringAssert.EndsWith(suggested, ".md");
string markdown = File.ReadAllText(path);
StringAssert.Contains(markdown, "alpha");
StringAssert.Contains(markdown, "beta");
byte[] bytes = File.ReadAllBytes(path);
Assert.AreNotEqual(0xEF, bytes[0], "The export must not start with a UTF-8 BOM.");
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
}
[TestMethod]
public async Task CancelledExportWritesNothing()
{
using var fixture = new Fixture();
WorkspaceViewModel workspace = fixture.CreateWorkspace();
fixture.Dialogs.SaveFilePath = null;
await workspace.ExportCommand.ExecuteAsync(null);
Assert.AreEqual(1, fixture.Dialogs.SuggestedFileNames.Count);
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
Assert.AreEqual(0, System.IO.Directory.GetFiles(fixture.Directory.FullPath, "*.md").Length);
}
[TestMethod]
public async Task ExportWriteFailureIsReportedInAMessageDialog()
{
using var fixture = new Fixture();
WorkspaceViewModel workspace = fixture.CreateWorkspace();
fixture.Dialogs.SaveFilePath = Path.Combine(fixture.Directory.FullPath, "missing-folder", "out.md");
await workspace.ExportCommand.ExecuteAsync(null);
var message = (MessageDialogViewModel)fixture.Dialogs.Shown.Single();
Assert.AreEqual(Resources.Export_Failed_Title, message.Title);
Assert.IsTrue(message.HasDetails);
}
#endregion
}