feat(m2): repository layer
- Data models: ColumnModel, CardModel (tags loaded), TagModel - PathComparer: platform-parameterized path normalization (case rule injected, trailing separator trimmed, roots kept) - WorkspaceRepository: column/card/tag CRUD, card moves, tag assignment; single-transaction edit confirmation with atomic rollback; no-change confirmations write nothing; move refreshes last_edited_at but not card updated_at; write-through to app.db after every successful data write - WorkspaceConnectionPool: fixed-capacity LRU session pool (lock + repository); eviction releases resources only; typed folder state errors; sidebar registry registration on open - 35 new unit tests: cascade chains, atomicity, timestamp rules, LRU behavior, lock conflicts, path equivalence
This commit is contained in:
1 parent
31fc58d9c7
commit
01367edaa9
10 files changed
+1308
No files matched your search
@@ -0,0 +1,93 @@
|
||||
using YKanBan.Platform;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Platform;
|
||||
|
||||
/// <summary>Tests for path normalization and platform-parameterized comparison.</summary>
|
||||
[TestClass]
|
||||
public class PathComparerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void OrdinalComparerTreatsCaseAsSignificant()
|
||||
{
|
||||
// Unix-style rule, injected so it is testable on any host.
|
||||
var comparer = new PathComparer(ignoreCase: false);
|
||||
Assert.IsFalse(comparer.Equals(@"C:\A", @"C:\a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IgnoreCaseComparerTreatsCaseAsInsignificant()
|
||||
{
|
||||
// Windows-style rule.
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
Assert.IsTrue(comparer.Equals(@"C:\A", @"c:\a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EqualPathsProduceEqualHashCodes()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
Assert.AreEqual(
|
||||
comparer.GetHashCode(@"C:\Users\Data\MyRepo"),
|
||||
comparer.GetHashCode(@"c:\users\data\myrepo"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RelativeSegmentsCollapse()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
string viaParent = Path.Combine(directory.FullPath, "x", "..", "a");
|
||||
string direct = Path.Combine(directory.FullPath, "a");
|
||||
|
||||
Assert.IsTrue(comparer.Equals(viaParent, direct));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SeparatorStylesUnify()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
|
||||
Assert.IsTrue(comparer.Equals(@"C:\a/b\c", @"C:\a\b\c"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TrailingSeparatorIsTrimmedForNonRoots()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
string withTrailing = directory.FullPath + Path.DirectorySeparatorChar;
|
||||
Assert.IsTrue(comparer.Equals(withTrailing, directory.FullPath));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NormalizeReturnsCanonicalAbsoluteForm()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: false);
|
||||
using var directory = new TempDirectory();
|
||||
|
||||
string withRelativeSegment = Path.Combine(directory.FullPath, "sub", "..");
|
||||
Assert.AreEqual(directory.FullPath, comparer.Normalize(withRelativeSegment));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RootPathKeepsItsTrailingSeparator()
|
||||
{
|
||||
var comparer = new PathComparer(ignoreCase: true);
|
||||
|
||||
Assert.AreEqual(@"C:\", comparer.Normalize(@"C:\"));
|
||||
Assert.AreEqual(@"C:\x", comparer.Normalize(@"C:\x\"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ForCurrentPlatformMatchesHostRule()
|
||||
{
|
||||
PathComparer comparer = PathComparer.ForCurrentPlatform();
|
||||
|
||||
Assert.AreEqual(
|
||||
OperatingSystem.IsWindows(),
|
||||
comparer.Equals(@"C:\Same", @"c:\same"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using YKanBan.Platform;
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.Storage.AppData;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Storage.Workspace;
|
||||
|
||||
/// <summary>Tests for the fixed-capacity LRU workspace session pool.</summary>
|
||||
[TestClass]
|
||||
public class WorkspaceConnectionPoolTests
|
||||
{
|
||||
/// <summary>Owns three initialized workspaces (a, b, c), a registry and the pool under test.</summary>
|
||||
private sealed class PoolFixture : IDisposable
|
||||
{
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public AppDatabase AppDb { get; }
|
||||
|
||||
public WorkspaceConnectionPool Pool { get; }
|
||||
|
||||
public PoolFixture(int capacity = 2)
|
||||
{
|
||||
foreach (string name in new[] { "a", "b", "c" })
|
||||
{
|
||||
string path = Path.Combine(Directory.FullPath, name);
|
||||
System.IO.Directory.CreateDirectory(path);
|
||||
WorkspaceInitializer.Initialize(path);
|
||||
}
|
||||
AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"));
|
||||
Pool = new WorkspaceConnectionPool(capacity, AppDb, new PathComparer(ignoreCase: true));
|
||||
}
|
||||
|
||||
/// <summary>Workspace folder path by name.</summary>
|
||||
public string Workspace(string name) => Path.Combine(Directory.FullPath, name);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Pool.Dispose();
|
||||
AppDb.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenBeyondCapacityEvictsLeastRecentlyUsed()
|
||||
{
|
||||
using var fixture = new PoolFixture(capacity: 2);
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Open(fixture.Workspace("c"));
|
||||
|
||||
Assert.AreEqual(2, fixture.Pool.OpenCount);
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a")));
|
||||
Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("b")));
|
||||
Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("c")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenTwiceReturnsSameSession()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
WorkspaceSession first = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
WorkspaceSession second = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
|
||||
Assert.AreSame(first, second);
|
||||
Assert.AreEqual(1, fixture.Pool.OpenCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TouchPromotesRecency()
|
||||
{
|
||||
using var fixture = new PoolFixture(capacity: 2);
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Touch(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("c"));
|
||||
|
||||
// "a" was touched after "b" opened, so "b" is the LRU victim now.
|
||||
Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("a")));
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EvictedWorkspaceCanBeReopened()
|
||||
{
|
||||
using var fixture = new PoolFixture(capacity: 2);
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Open(fixture.Workspace("c"));
|
||||
|
||||
// "a" was evicted; reopening it works and now evicts the LRU "b".
|
||||
WorkspaceSession reopened = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
Assert.AreEqual("a", Path.GetFileName(reopened.FolderPath));
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenRegistersWorkspaceInSidebarRegistry()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
|
||||
IReadOnlyList<string> registered = fixture.AppDb.GetWorkspaces().Select(record => record.Path).ToList();
|
||||
Assert.AreEqual(2, registered.Count);
|
||||
Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("a"))));
|
||||
Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("b"))));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenMissingDirectoryThrows()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
string missing = Path.Combine(fixture.Directory.FullPath, "nowhere");
|
||||
|
||||
Assert.ThrowsExactly<WorkspaceDirectoryMissingException>(() => fixture.Pool.Open(missing));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenFolderWithoutYkanbanThrows()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
string plain = Path.Combine(fixture.Directory.FullPath, "plain");
|
||||
System.IO.Directory.CreateDirectory(plain);
|
||||
|
||||
Assert.ThrowsExactly<WorkspaceNotInitializedException>(() => fixture.Pool.Open(plain));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenWithLockHeldElsewhereThrowsThenSucceedsAfterRelease()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
using WorkspaceLock holder = WorkspaceLock.Acquire(fixture.Workspace("a"));
|
||||
Assert.ThrowsExactly<WorkspaceLockException>(() => fixture.Pool.Open(fixture.Workspace("a")));
|
||||
|
||||
holder.Dispose();
|
||||
WorkspaceSession session = fixture.Pool.Open(fixture.Workspace("a"));
|
||||
Assert.AreEqual("a", Path.GetFileName(session.FolderPath));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveReleasesResourcesAndForgetsRegistryEntry()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
Assert.IsTrue(fixture.Pool.Remove(fixture.Workspace("a")));
|
||||
|
||||
Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a")));
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
|
||||
// Resources are truly released: the lock can be taken again.
|
||||
using WorkspaceLock reacquired = WorkspaceLock.Acquire(fixture.Workspace("a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PathEquivalentsShareOneSession()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
string path = fixture.Workspace("a");
|
||||
string differentlyCased = Path.Combine(
|
||||
Path.GetDirectoryName(path)!,
|
||||
Path.GetFileName(path).ToUpperInvariant());
|
||||
|
||||
WorkspaceSession session = fixture.Pool.Open(path);
|
||||
WorkspaceSession same = fixture.Pool.Open(differentlyCased);
|
||||
|
||||
Assert.AreSame(session, same);
|
||||
Assert.AreEqual(1, fixture.Pool.OpenCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DisposeReleasesAllLocks()
|
||||
{
|
||||
using var fixture = new PoolFixture();
|
||||
|
||||
fixture.Pool.Open(fixture.Workspace("a"));
|
||||
fixture.Pool.Open(fixture.Workspace("b"));
|
||||
fixture.Pool.Dispose();
|
||||
|
||||
using WorkspaceLock lockA = WorkspaceLock.Acquire(fixture.Workspace("a"));
|
||||
using WorkspaceLock lockB = WorkspaceLock.Acquire(fixture.Workspace("b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage.AppData;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
namespace YKanBan.Tests.Storage.Workspace;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for workspace CRUD, the three cascade chains, edit-confirm
|
||||
/// transaction atomicity, timestamp refresh rules and the last_edited_at
|
||||
/// write-through.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WorkspaceRepositoryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns a workspace with fixed-timestamp fixture data (one column, one
|
||||
/// card, two tags linked to the card), a fake-clock app.db and the
|
||||
/// repository under test.
|
||||
/// </summary>
|
||||
private sealed class RepositoryFixture : IDisposable
|
||||
{
|
||||
public TempDirectory Directory { get; } = new();
|
||||
|
||||
public long Clock { get; set; } = 1000;
|
||||
|
||||
public AppDatabase AppDb { get; }
|
||||
|
||||
public WorkspaceRepository Repository { get; }
|
||||
|
||||
public long ColumnId { get; } = 1;
|
||||
|
||||
public long CardId { get; } = 1;
|
||||
|
||||
public long BugTagId { get; } = 1;
|
||||
|
||||
public long UiTagId { get; } = 2;
|
||||
|
||||
public RepositoryFixture()
|
||||
{
|
||||
WorkspaceInitializer.Initialize(Directory.FullPath);
|
||||
|
||||
// Fixture rows carry literal timestamps so refresh vs. no-refresh stays observable.
|
||||
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', '');");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('ui', '#00ff00', '');");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
|
||||
SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 2);");
|
||||
|
||||
AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"), () => Clock);
|
||||
Repository = new WorkspaceRepository(Directory.FullPath, AppDb);
|
||||
}
|
||||
|
||||
/// <summary>The last_edited_at currently mirrored into the registry.</summary>
|
||||
public long LastEditedAt => AppDb.GetWorkspaces().Single().LastEditedAt;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Repository.Dispose();
|
||||
AppDb.Dispose();
|
||||
Directory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateColumnWithoutChangeWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
ColumnModel before = fixture.Repository.GetColumn(fixture.ColumnId)!;
|
||||
|
||||
ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", "");
|
||||
|
||||
Assert.AreEqual(before.UpdatedAt, result.UpdatedAt);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateColumnChangeRefreshesColumnTimestampAndLastEdited()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc");
|
||||
|
||||
Assert.IsTrue(updated.UpdatedAt > 100L);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteColumnCascadesToCardsAndTagLinks()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId));
|
||||
|
||||
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
|
||||
// The tags themselves survive the column deletion.
|
||||
Assert.AreEqual(2, fixture.Repository.GetTags().Count);
|
||||
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath);
|
||||
Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;"));
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteCardRemovesTagLinksButKeepsTags()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId));
|
||||
|
||||
Assert.IsNull(fixture.Repository.GetCard(fixture.CardId));
|
||||
Assert.AreEqual(2, fixture.Repository.GetTags().Count);
|
||||
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath);
|
||||
Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteTagDetachesFromCardsButKeepsCards()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId));
|
||||
|
||||
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
|
||||
CardModel card = fixture.Repository.GetCard(fixture.CardId)!;
|
||||
Assert.AreEqual(1, card.Tags.Count);
|
||||
Assert.AreEqual("ui", card.Tags[0].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardWithoutEffectiveChangeWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
CardModel before = fixture.Repository.GetCard(fixture.CardId)!;
|
||||
|
||||
CardModel result = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]);
|
||||
|
||||
Assert.AreEqual(before.UpdatedAt, result.UpdatedAt);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardTitleChangeRefreshesTimestampsAndLastEdited()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]);
|
||||
|
||||
Assert.IsTrue(updated.UpdatedAt > 100L);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardTagSetOnlyChangeStillRefreshesCardTimestamp()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]);
|
||||
|
||||
Assert.IsTrue(updated.UpdatedAt > 100L);
|
||||
Assert.AreEqual(1, updated.Tags.Count);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateCardWithUnknownTagRollsBackAtomically()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
// Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back.
|
||||
Assert.ThrowsExactly<SqliteException>(() =>
|
||||
fixture.Repository.UpdateCard(fixture.CardId, "TX", "CX", [fixture.BugTagId, 999]));
|
||||
|
||||
CardModel after = fixture.Repository.GetCard(fixture.CardId)!;
|
||||
Assert.AreEqual("T", after.Title);
|
||||
Assert.AreEqual("C", after.Content);
|
||||
Assert.AreEqual(100L, after.UpdatedAt);
|
||||
Assert.AreEqual(2, after.Tags.Count);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveCardRefreshesLastEditedButNotCardTimestamp()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
long target = fixture.Repository.AddColumn("Target", "").Id;
|
||||
fixture.Clock = 2000;
|
||||
|
||||
Assert.IsTrue(fixture.Repository.MoveCard(fixture.CardId, target));
|
||||
|
||||
CardModel moved = fixture.Repository.GetCard(fixture.CardId)!;
|
||||
Assert.AreEqual(target, moved.ColumnId);
|
||||
// The move is a write but deliberately leaves the card's updated_at alone.
|
||||
Assert.AreEqual(100L, moved.UpdatedAt);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveCardToCurrentColumnWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId));
|
||||
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddCardCreatesWithEqualTimestampsAndLinkedTags()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]);
|
||||
|
||||
Assert.IsTrue(card.CreatedAt > 0L);
|
||||
Assert.AreEqual(card.CreatedAt, card.UpdatedAt);
|
||||
Assert.AreEqual(1, card.Tags.Count);
|
||||
Assert.AreEqual("ui", card.Tags[0].Name);
|
||||
Assert.AreEqual(2000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateTagWithoutChangeWritesNothing()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 2000;
|
||||
|
||||
TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", "#ff0000", "");
|
||||
|
||||
Assert.AreEqual("bug", result.Name);
|
||||
Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateTagChangeWritesAndTouchesLastEdited()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
fixture.Clock = 3000;
|
||||
|
||||
TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", "#ee0a0a", "desc");
|
||||
|
||||
Assert.AreEqual("defect", updated.Name);
|
||||
Assert.AreEqual("#ee0a0a", updated.Color);
|
||||
Assert.AreEqual("desc", updated.Description);
|
||||
Assert.AreEqual(3000L, fixture.LastEditedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetTagsWithUsageCountsAssignments()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
|
||||
IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage();
|
||||
Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "bug").UsageCount);
|
||||
Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "ui").UsageCount);
|
||||
|
||||
// Detaching one tag via an edit confirmation drops its usage to zero but keeps the tag.
|
||||
fixture.Clock = 2000;
|
||||
fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.UiTagId]);
|
||||
|
||||
IReadOnlyList<(TagModel Tag, long UsageCount)> after = fixture.Repository.GetTagsWithUsage();
|
||||
Assert.AreEqual(0L, after.Single(entry => entry.Tag.Name == "bug").UsageCount);
|
||||
Assert.AreEqual(1L, after.Single(entry => entry.Tag.Name == "ui").UsageCount);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user