Files
YKanBan/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs
T
yyc12345 01367edaa9 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
2026-10-01 20:39:20 +08:00

192 lines
6.7 KiB
C#

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"));
}
}