feat: add models and workspace repository layer

This commit is contained in:
yyc12345 committed 2026-10-02 22:12:38 +08:00
1 parent c2ee02c209
commit 941e22c7ff
9 files changed
+1143

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
using YKanBan.Models;
namespace YKanBan.Tests.Models;
/// <summary>
/// Tests for the RGB color value type: component storage and the text
/// boundary in both directions.
/// </summary>
[TestClass]
public class RgbColorTests
{
[TestMethod]
public void ParseSplitsComponents()
{
RgbColor color = RgbColor.Parse("#102030");
Assert.AreEqual((byte)0x10, color.R);
Assert.AreEqual((byte)0x20, color.G);
Assert.AreEqual((byte)0x30, color.B);
}
[TestMethod]
[DataRow("#a1B2c3")]
[DataRow("#A1B2C3")]
[DataRow("#a1b2c3")]
public void ParseIsCaseInsensitive(string text)
{
Assert.AreEqual(new RgbColor(0xA1, 0xB2, 0xC3), RgbColor.Parse(text));
}
[TestMethod]
[DataRow("")]
[DataRow("112233")]
[DataRow("#11223")]
[DataRow("#1122334")]
[DataRow("#GGGGGG")]
[DataRow("#12345g")]
[DataRow(" #112233")]
[DataRow("#112233 ")]
public void ParseRejectsMalformedText(string text)
{
Assert.ThrowsExactly<FormatException>(() => RgbColor.Parse(text));
}
[TestMethod]
public void TryParseRejectsNull()
{
Assert.IsFalse(RgbColor.TryParse(null, out RgbColor color));
Assert.AreEqual(default, color);
}
[TestMethod]
public void TryParseAcceptsValidText()
{
Assert.IsTrue(RgbColor.TryParse("#010203", out RgbColor color));
Assert.AreEqual(new RgbColor(1, 2, 3), color);
}
[TestMethod]
public void ToStringUsesCanonicalLowercase()
{
Assert.AreEqual("#ff0a00", new RgbColor(0xFF, 0x0A, 0x00).ToString());
}
[TestMethod]
public void TextRoundTrips()
{
var color = new RgbColor(0x12, 0xAB, 0xCD);
Assert.AreEqual(color, RgbColor.Parse(color.ToString()));
}
[TestMethod]
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));
}
}
@@ -0,0 +1,276 @@
using Microsoft.Data.Sqlite;
using YKanBan.Models;
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 and the timestamp refresh rules.
/// </summary>
[TestClass]
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
{
public TempDirectory Directory { get; } = new();
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);
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);");
}
Repository = new WorkspaceRepository(Directory.FullPath);
}
public void Dispose()
{
Repository.Dispose();
Directory.Dispose();
}
}
[TestMethod]
public void GetColumnsReturnsTheSeededColumn()
{
using var fixture = new RepositoryFixture();
ColumnModel column = fixture.Repository.GetColumn(fixture.ColumnId)!;
Assert.AreEqual("Col", column.Title);
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
}
[TestMethod]
public void AddColumnSetsEqualTimestamps()
{
using var fixture = new RepositoryFixture();
ColumnModel column = fixture.Repository.AddColumn("New", "desc");
Assert.IsTrue(column.CreatedAt > 0L);
Assert.AreEqual(column.CreatedAt, column.UpdatedAt);
}
[TestMethod]
public void UpdateColumnWithoutChangeWritesNothing()
{
using var fixture = new RepositoryFixture();
ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", "");
// A no-op must not refresh the fixed fixture timestamp.
Assert.AreEqual(100L, result.UpdatedAt);
}
[TestMethod]
public void UpdateColumnChangeRefreshesTimestamp()
{
using var fixture = new RepositoryFixture();
ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc");
Assert.IsTrue(updated.UpdatedAt > 100L);
Assert.AreEqual(100L, updated.CreatedAt);
}
[TestMethod]
public void DeleteColumnCascadesToCardsAndTagLinks()
{
using var fixture = new RepositoryFixture();
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;"));
}
[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();
CardModel result = fixture.Repository.UpdateCard(
fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]);
Assert.AreEqual(100L, result.UpdatedAt);
}
[TestMethod]
public void UpdateCardTitleChangeRefreshesTimestamp()
{
using var fixture = new RepositoryFixture();
CardModel updated = fixture.Repository.UpdateCard(
fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]);
Assert.IsTrue(updated.UpdatedAt > 100L);
Assert.AreEqual(100L, updated.CreatedAt);
}
[TestMethod]
public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp()
{
using var fixture = new RepositoryFixture();
CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]);
Assert.IsTrue(updated.UpdatedAt > 100L);
Assert.AreEqual(1, updated.Tags.Count);
}
[TestMethod]
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.
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);
}
[TestMethod]
public void MoveCardUpdatesColumnWithoutRefreshingTimestamp()
{
using var fixture = new RepositoryFixture();
long target = fixture.Repository.AddColumn("Target", "").Id;
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);
}
[TestMethod]
public void MoveCardToCurrentColumnIsNoOp()
{
using var fixture = new RepositoryFixture();
Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId));
Assert.AreEqual(100L, fixture.Repository.GetCard(fixture.CardId)!.UpdatedAt);
}
[TestMethod]
public void AddCardCreatesWithEqualTimestampsAndLinkedTags()
{
using var fixture = new RepositoryFixture();
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);
}
[TestMethod]
public void UpdateTagWithoutChangeWritesNothing()
{
using var fixture = new RepositoryFixture();
TagModel result = fixture.Repository.UpdateTag(
fixture.BugTagId, "bug", RgbColor.Parse("#ff0000"), "");
Assert.AreEqual("bug", result.Name);
Assert.AreEqual(new RgbColor(0xFF, 0x00, 0x00), result.Color);
}
[TestMethod]
public void UpdateTagChangeWrites()
{
using var fixture = new RepositoryFixture();
TagModel updated = fixture.Repository.UpdateTag(
fixture.BugTagId, "defect", RgbColor.Parse("#ee0a0a"), "desc");
Assert.AreEqual("defect", updated.Name);
Assert.AreEqual(new RgbColor(0xEE, 0x0A, 0x0A), updated.Color);
Assert.AreEqual("desc", updated.Description);
Assert.AreEqual("defect", fixture.Repository.GetTags().Single(tag => tag.Id == fixture.BugTagId).Name);
}
[TestMethod]
public void AddTagWithDuplicateNameIsRejected()
{
using var fixture = new RepositoryFixture();
Assert.ThrowsExactly<SqliteException>(() =>
fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
}
[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.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);
}
}
@@ -0,0 +1,60 @@
using YKanBan.Models;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>
/// Tests for the workspace session lifecycle: the exclusive lock is held for
/// the whole session and the repository is usable through it.
/// </summary>
[TestClass]
public class WorkspaceSessionTests
{
[TestMethod]
public void OpenHoldsLockUntilDisposed()
{
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath);
// A second open while the session is alive must fail on the lock.
Assert.ThrowsExactly<WorkspaceLockException>(() => WorkspaceSession.Open(directory.FullPath));
}
[TestMethod]
public void DisposeReleasesLockForReopen()
{
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
WorkspaceSession.Open(directory.FullPath).Dispose();
using WorkspaceSession reopened = WorkspaceSession.Open(directory.FullPath);
Assert.AreEqual(directory.FullPath, reopened.FolderPath);
}
[TestMethod]
public void OpenUninitializedFolderThrowsNotInitialized()
{
using var directory = new TempDirectory();
Assert.ThrowsExactly<WorkspaceNotInitializedException>(
() => WorkspaceSession.Open(directory.FullPath));
}
[TestMethod]
public void RepositoryIsUsableThroughSession()
{
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath);
TagModel tag = session.Repository.AddTag("bug", RgbColor.Parse("#112233"), "");
Assert.AreEqual("bug", session.Repository.GetTags().Single().Name);
Assert.AreEqual(tag.Id, session.Repository.GetTags().Single().Id);
}
}