feat(m1): storage layer
- Shared SQLite plumbing: PRAGMAs (WAL/NORMAL/foreign_keys) and user_version migrations with typed SchemaVersionException - Workspace folder: .ykanban initialization, empty schema with DDL-level constraints, preset column seeding (ResX-driven), exclusive lock file with holder diagnostics - AppData folder: app.db workspace registry (injectable clock), app.json config store with kebab-case JSON names via explicit [JsonProperty]/[EnumMember], nested sections, corrupt-file backup - ResX infrastructure under Assets/Locales (en + zh-Hans) with MSBuild strongly-typed public Resources class - 54 unit tests: DDL constraints, migrations, PRAGMAs, lock exclusivity, config degradation, culture-aware presets
This commit is contained in:
1 parent
e934eb9629
commit
31fc58d9c7
25 files changed
+1737
-1
No files matched your search
@@ -0,0 +1,169 @@
|
|||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.AppData;
|
||||||
|
|
||||||
|
/// <summary>Tests for app.json load/save and its degradation rules.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class AppConfigStoreTests
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void MissingFileYieldsDefaults()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
var store = new AppConfigStore(Path.Combine(directory.FullPath, "app.json"));
|
||||||
|
|
||||||
|
AppConfig config = store.Load();
|
||||||
|
Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
|
||||||
|
Assert.AreEqual("en", config.Language);
|
||||||
|
Assert.AreEqual(ThemeOption.FollowSystem, config.Theme);
|
||||||
|
Assert.AreEqual(CardSortOption.Id, config.Sort.Card);
|
||||||
|
Assert.AreEqual(SidebarSortOption.NameAsc, config.Sort.Sidebar);
|
||||||
|
Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
|
||||||
|
Assert.AreEqual(320, config.ColumnWidth.CustomPixels);
|
||||||
|
Assert.IsTrue(config.Confirmations.DeleteCard);
|
||||||
|
Assert.IsTrue(config.Confirmations.DeleteColumn);
|
||||||
|
Assert.IsTrue(config.Confirmations.DeleteTag);
|
||||||
|
Assert.IsTrue(config.Confirmations.DiscardEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void CorruptJsonIsBackedUpAndDefaultsAreUsed()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = Path.Combine(directory.FullPath, "app.json");
|
||||||
|
File.WriteAllText(path, "{{{ not json");
|
||||||
|
var store = new AppConfigStore(path);
|
||||||
|
|
||||||
|
AppConfig config = store.Load();
|
||||||
|
|
||||||
|
Assert.AreEqual("en", config.Language);
|
||||||
|
Assert.IsTrue(File.Exists(path + ".bak"));
|
||||||
|
Assert.AreEqual("{{{ not json", File.ReadAllText(path + ".bak"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void WrongShapeJsonIsBackedUpAndDefaultsAreUsed()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = Path.Combine(directory.FullPath, "app.json");
|
||||||
|
File.WriteAllText(path, "[1, 2, 3]");
|
||||||
|
var store = new AppConfigStore(path);
|
||||||
|
|
||||||
|
AppConfig config = store.Load();
|
||||||
|
|
||||||
|
Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
|
||||||
|
Assert.IsTrue(File.Exists(path + ".bak"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SaveAndLoadRoundTripsAllSettings()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
var store = new AppConfigStore(Path.Combine(directory.FullPath, "app.json"));
|
||||||
|
|
||||||
|
store.Save(new AppConfig
|
||||||
|
{
|
||||||
|
Language = "zh-Hans",
|
||||||
|
Theme = ThemeOption.Dark,
|
||||||
|
Sort = new SortSettings
|
||||||
|
{
|
||||||
|
Card = CardSortOption.Title,
|
||||||
|
Sidebar = SidebarSortOption.RecentlyUpdated,
|
||||||
|
},
|
||||||
|
ColumnWidth = new ColumnWidthSettings
|
||||||
|
{
|
||||||
|
Preset = ColumnWidthPreset.Custom,
|
||||||
|
CustomPixels = 500,
|
||||||
|
},
|
||||||
|
Confirmations = new ConfirmationSettings
|
||||||
|
{
|
||||||
|
DeleteCard = false,
|
||||||
|
DeleteColumn = false,
|
||||||
|
DeleteTag = false,
|
||||||
|
DiscardEdit = false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
AppConfig loaded = store.Load();
|
||||||
|
|
||||||
|
Assert.AreEqual("zh-Hans", loaded.Language);
|
||||||
|
Assert.AreEqual(ThemeOption.Dark, loaded.Theme);
|
||||||
|
Assert.AreEqual(CardSortOption.Title, loaded.Sort.Card);
|
||||||
|
Assert.AreEqual(SidebarSortOption.RecentlyUpdated, loaded.Sort.Sidebar);
|
||||||
|
Assert.AreEqual(ColumnWidthPreset.Custom, loaded.ColumnWidth.Preset);
|
||||||
|
Assert.AreEqual(500, loaded.ColumnWidth.CustomPixels);
|
||||||
|
Assert.IsFalse(loaded.Confirmations.DeleteCard);
|
||||||
|
Assert.IsFalse(loaded.Confirmations.DeleteColumn);
|
||||||
|
Assert.IsFalse(loaded.Confirmations.DeleteTag);
|
||||||
|
Assert.IsFalse(loaded.Confirmations.DiscardEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SaveWritesKebabCaseNamesAndVersionField()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = Path.Combine(directory.FullPath, "app.json");
|
||||||
|
var store = new AppConfigStore(path);
|
||||||
|
|
||||||
|
store.Save(new AppConfig());
|
||||||
|
string json = File.ReadAllText(path);
|
||||||
|
|
||||||
|
// Keys and enum values are serialized in kebab-case; "version" is the migration hook.
|
||||||
|
Assert.IsTrue(json.Contains($"\"version\": {AppConfig.CurrentFormatVersion}"));
|
||||||
|
Assert.IsTrue(json.Contains("\"language\": \"en\""));
|
||||||
|
Assert.IsTrue(json.Contains("\"theme\": \"follow-system\""));
|
||||||
|
Assert.IsTrue(json.Contains("\"sort\":"));
|
||||||
|
Assert.IsTrue(json.Contains("\"card\": \"id\""));
|
||||||
|
Assert.IsTrue(json.Contains("\"sidebar\": \"name-asc\""));
|
||||||
|
Assert.IsTrue(json.Contains("\"column-width\":"));
|
||||||
|
Assert.IsTrue(json.Contains("\"preset\": \"standard\""));
|
||||||
|
Assert.IsTrue(json.Contains("\"custom-pixels\": 320"));
|
||||||
|
Assert.IsTrue(json.Contains("\"confirmations\":"));
|
||||||
|
Assert.IsTrue(json.Contains("\"delete-card\": true"));
|
||||||
|
Assert.IsTrue(json.Contains("\"delete-column\": true"));
|
||||||
|
Assert.IsTrue(json.Contains("\"delete-tag\": true"));
|
||||||
|
Assert.IsTrue(json.Contains("\"discard-edit\": true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void MissingSectionsFallBackToTheirDefaults()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = Path.Combine(directory.FullPath, "app.json");
|
||||||
|
File.WriteAllText(path, $$"""
|
||||||
|
{
|
||||||
|
"version": {{AppConfig.CurrentFormatVersion}},
|
||||||
|
"language": "zh-Hans"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
var store = new AppConfigStore(path);
|
||||||
|
|
||||||
|
AppConfig config = store.Load();
|
||||||
|
|
||||||
|
// The present field loads; absent sections each fall back to defaults.
|
||||||
|
Assert.AreEqual("zh-Hans", config.Language);
|
||||||
|
Assert.AreEqual(CardSortOption.Id, config.Sort.Card);
|
||||||
|
Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
|
||||||
|
Assert.IsTrue(config.Confirmations.DeleteCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void LoadPreservesUnknownFieldsTolerantly()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = Path.Combine(directory.FullPath, "app.json");
|
||||||
|
// A newer version writes an unknown field plus a known one.
|
||||||
|
File.WriteAllText(path, $$"""
|
||||||
|
{
|
||||||
|
"version": {{AppConfig.CurrentFormatVersion}},
|
||||||
|
"language": "zh-Hans",
|
||||||
|
"some-future-field": 42
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
var store = new AppConfigStore(path);
|
||||||
|
|
||||||
|
AppConfig config = store.Load();
|
||||||
|
|
||||||
|
Assert.AreEqual("zh-Hans", config.Language);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.AppData;
|
||||||
|
|
||||||
|
/// <summary>Tests for the global app.db workspace registry.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class AppDatabaseTests
|
||||||
|
{
|
||||||
|
private static string DatabasePath(TempDirectory directory)
|
||||||
|
=> Path.Combine(directory.FullPath, "app.db");
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void UpsertInsertsRowWithTimestamps()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
long now = 1000;
|
||||||
|
using var db = new AppDatabase(DatabasePath(directory), () => now);
|
||||||
|
|
||||||
|
db.UpsertWorkspace(@"C:\work\repo");
|
||||||
|
|
||||||
|
IReadOnlyList<WorkspaceRecord> records = db.GetWorkspaces();
|
||||||
|
Assert.AreEqual(1, records.Count);
|
||||||
|
Assert.AreEqual(@"C:\work\repo", records[0].Path);
|
||||||
|
Assert.AreEqual(1000L, records[0].AddedAt);
|
||||||
|
Assert.AreEqual(1000L, records[0].LastEditedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void RepeatedUpsertKeepsOriginalAddedAt()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
long now = 1000;
|
||||||
|
using var db = new AppDatabase(DatabasePath(directory), () => now);
|
||||||
|
|
||||||
|
db.UpsertWorkspace(@"C:\work\repo");
|
||||||
|
now = 2000;
|
||||||
|
db.UpsertWorkspace(@"C:\work\repo");
|
||||||
|
|
||||||
|
WorkspaceRecord record = db.GetWorkspaces().Single();
|
||||||
|
Assert.AreEqual(1000L, record.AddedAt);
|
||||||
|
Assert.AreEqual(2000L, record.LastEditedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TouchRefreshesLastEditedAt()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
long now = 1000;
|
||||||
|
using var db = new AppDatabase(DatabasePath(directory), () => now);
|
||||||
|
|
||||||
|
db.UpsertWorkspace(@"C:\work\repo");
|
||||||
|
now = 2500;
|
||||||
|
db.TouchLastEdited(@"C:\work\repo");
|
||||||
|
|
||||||
|
WorkspaceRecord record = db.GetWorkspaces().Single();
|
||||||
|
Assert.AreEqual(1000L, record.AddedAt);
|
||||||
|
Assert.AreEqual(2500L, record.LastEditedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TouchOnUnknownPathRegistersTheWorkspace()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
long now = 3000;
|
||||||
|
using var db = new AppDatabase(DatabasePath(directory), () => now);
|
||||||
|
|
||||||
|
// Self-healing: touching an unregistered workspace still leaves a valid row.
|
||||||
|
db.TouchLastEdited(@"C:\work\other");
|
||||||
|
|
||||||
|
WorkspaceRecord record = db.GetWorkspaces().Single();
|
||||||
|
Assert.AreEqual(3000L, record.AddedAt);
|
||||||
|
Assert.AreEqual(3000L, record.LastEditedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void RemoveDeletesTheRow()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
using var db = new AppDatabase(DatabasePath(directory));
|
||||||
|
|
||||||
|
db.UpsertWorkspace(@"C:\work\repo");
|
||||||
|
db.RemoveWorkspace(@"C:\work\repo");
|
||||||
|
|
||||||
|
Assert.AreEqual(0, db.GetWorkspaces().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void RowsPersistAcrossReopen()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = DatabasePath(directory);
|
||||||
|
|
||||||
|
using (var db = new AppDatabase(path, () => 1000))
|
||||||
|
{
|
||||||
|
db.UpsertWorkspace(@"C:\work\repo");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reopened = new AppDatabase(path, () => 9999);
|
||||||
|
WorkspaceRecord record = reopened.GetWorkspaces().Single();
|
||||||
|
Assert.AreEqual(@"C:\work\repo", record.Path);
|
||||||
|
Assert.AreEqual(1000L, record.AddedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OpenMigratesToCurrentSchemaVersion()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = DatabasePath(directory);
|
||||||
|
using (var db = new AppDatabase(path))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
using var raw = new SqliteConnection($"Data Source={path}");
|
||||||
|
raw.Open();
|
||||||
|
Assert.AreEqual(AppDatabase.CurrentSchemaVersion, SqliteDatabase.ReadUserVersion(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OpenAppliesWalJournalMode()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string path = DatabasePath(directory);
|
||||||
|
using (var db = new AppDatabase(path))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAL is a persistent property of the database file: a plain new
|
||||||
|
// connection reports it without any PRAGMA replay.
|
||||||
|
using var raw = new SqliteConnection($"Data Source={path}");
|
||||||
|
raw.Open();
|
||||||
|
Assert.AreEqual("wal", SqliteTestHelper.ScalarString(raw, "PRAGMA journal_mode;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage;
|
||||||
|
|
||||||
|
/// <summary>Tests for PRAGMA application and user_version migration machinery.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class SqliteDatabaseTests
|
||||||
|
{
|
||||||
|
private static readonly SchemaMigration[] V1Only =
|
||||||
|
[
|
||||||
|
new SchemaMigration(1, "v1", "CREATE TABLE t1 (id INTEGER PRIMARY KEY);"),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly SchemaMigration[] V1AndV2 =
|
||||||
|
[
|
||||||
|
new SchemaMigration(1, "v1", "CREATE TABLE t1 (id INTEGER PRIMARY KEY);"),
|
||||||
|
new SchemaMigration(2, "v2", "CREATE TABLE t2 (id INTEGER PRIMARY KEY);"),
|
||||||
|
];
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OpenAppliesMandatedPragmas()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string dbPath = Path.Combine(directory.FullPath, "test.db");
|
||||||
|
|
||||||
|
using var connection = SqliteDatabase.Open(dbPath, []);
|
||||||
|
Assert.AreEqual("wal", SqliteTestHelper.ScalarString(connection, "PRAGMA journal_mode;"));
|
||||||
|
Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(connection, "PRAGMA synchronous;"));
|
||||||
|
Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(connection, "PRAGMA foreign_keys;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OpenSetsUserVersionToLatestWorkspaceMigration()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string dbPath = Path.Combine(directory.FullPath, "test.db");
|
||||||
|
|
||||||
|
using var connection = SqliteDatabase.Open(dbPath, WorkspaceSchema.Migrations);
|
||||||
|
Assert.AreEqual(WorkspaceSchema.CurrentVersion, SqliteDatabase.ReadUserVersion(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void MigrationsApplyIncrementallyOnReopen()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string dbPath = Path.Combine(directory.FullPath, "test.db");
|
||||||
|
|
||||||
|
// First open with only v1 available: v1 applied, v2 table absent.
|
||||||
|
using (var connection = SqliteDatabase.Open(dbPath, V1Only))
|
||||||
|
{
|
||||||
|
Assert.AreEqual(1L, SqliteDatabase.ReadUserVersion(connection));
|
||||||
|
Assert.IsTrue(SqliteTestHelper.TableExists(connection, "t1"));
|
||||||
|
Assert.IsFalse(SqliteTestHelper.TableExists(connection, "t2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen with v1+v2: only v2 runs, v1 data untouched.
|
||||||
|
using (var connection = SqliteDatabase.Open(dbPath, V1AndV2))
|
||||||
|
{
|
||||||
|
Assert.AreEqual(2L, SqliteDatabase.ReadUserVersion(connection));
|
||||||
|
Assert.IsTrue(SqliteTestHelper.TableExists(connection, "t1"));
|
||||||
|
Assert.IsTrue(SqliteTestHelper.TableExists(connection, "t2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ReopenWithCurrentSchemaIsIdempotent()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string dbPath = Path.Combine(directory.FullPath, "test.db");
|
||||||
|
|
||||||
|
SqliteDatabase.Open(dbPath, V1AndV2).Dispose();
|
||||||
|
using (var connection = SqliteDatabase.Open(dbPath, V1AndV2))
|
||||||
|
{
|
||||||
|
// Still at version 2; re-running is a no-op.
|
||||||
|
Assert.AreEqual(2L, SqliteDatabase.ReadUserVersion(connection));
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO t1 (id) VALUES (42);");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var connection = SqliteDatabase.Open(dbPath, V1AndV2))
|
||||||
|
{
|
||||||
|
Assert.AreEqual(42L, SqliteTestHelper.ScalarLong(connection, "SELECT id FROM t1;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void NewerSchemaVersionIsRejected()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string dbPath = Path.Combine(directory.FullPath, "test.db");
|
||||||
|
|
||||||
|
// Simulate a database written by a newer build.
|
||||||
|
using (var connection = SqliteDatabase.Open(dbPath, []))
|
||||||
|
{
|
||||||
|
SqliteTestHelper.Exec(connection, "PRAGMA user_version=5;");
|
||||||
|
}
|
||||||
|
|
||||||
|
SchemaVersionException ex = Assert.ThrowsExactly<SchemaVersionException>(
|
||||||
|
() => SqliteDatabase.Open(dbPath, V1AndV2));
|
||||||
|
Assert.AreEqual(5L, ex.StoredVersion);
|
||||||
|
Assert.AreEqual(2, ex.SupportedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void NonContiguousMigrationListIsRejected()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
string dbPath = Path.Combine(directory.FullPath, "test.db");
|
||||||
|
var bad = new[] { new SchemaMigration(2, "gap", "CREATE TABLE t (id INTEGER);") };
|
||||||
|
|
||||||
|
Assert.ThrowsExactly<ArgumentException>(() => SqliteDatabase.Open(dbPath, bad));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that preset column titles follow the UI language active at
|
||||||
|
/// seeding time and then live as ordinary data. Mutates the global
|
||||||
|
/// Resources.Culture, therefore marked DoNotParallelize.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
[DoNotParallelize]
|
||||||
|
public class PresetColumnCultureTests
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void PresetTitlesAreEnglishUnderEnglishCulture()
|
||||||
|
{
|
||||||
|
CultureInfo? original = Resources.Culture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Resources.Culture = new CultureInfo("en");
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
WorkspacePreset.SeedColumns(directory.FullPath);
|
||||||
|
|
||||||
|
using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
|
||||||
|
CollectionAssert.AreEqual(
|
||||||
|
new[] { "To Do", "In Progress", "Done" },
|
||||||
|
SqliteTestHelper.GetColumnTitles(connection));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Resources.Culture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void PresetTitlesAreChineseUnderChineseCulture()
|
||||||
|
{
|
||||||
|
CultureInfo? original = Resources.Culture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Resources.Culture = new CultureInfo("zh-Hans");
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
WorkspacePreset.SeedColumns(directory.FullPath);
|
||||||
|
|
||||||
|
using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
|
||||||
|
CollectionAssert.AreEqual(
|
||||||
|
new[] { "待办", "进行中", "已完成" },
|
||||||
|
SqliteTestHelper.GetColumnTitles(connection));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Resources.Culture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>Tests for .ykanban folder and empty database creation.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class WorkspaceInitializerTests
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void InitializeCreatesYkanbanFolderAndDatabase()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
|
||||||
|
Assert.IsFalse(WorkspaceInitializer.IsWorkspace(directory.FullPath));
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
Assert.IsTrue(WorkspaceInitializer.IsWorkspace(directory.FullPath));
|
||||||
|
Assert.IsTrue(File.Exists(WorkspacePaths.Database(directory.FullPath)));
|
||||||
|
// The lock file only appears once a WorkspaceLock is acquired.
|
||||||
|
Assert.IsFalse(File.Exists(WorkspacePaths.LockFile(directory.FullPath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void InitializeLeavesDatabaseEmpty()
|
||||||
|
{
|
||||||
|
// Initialization creates structure only; preset content is seeded separately.
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
|
||||||
|
Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns;"));
|
||||||
|
Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM cards;"));
|
||||||
|
Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM tags;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void InitializeThrowsWhenDirectoryIsMissing()
|
||||||
|
{
|
||||||
|
string missing = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Assert.ThrowsExactly<WorkspaceDirectoryMissingException>(() => WorkspaceInitializer.Initialize(missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void InitializeThrowsWhenDatabaseAlreadyExists()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
Assert.ThrowsExactly<InvalidOperationException>(() => WorkspaceInitializer.Initialize(directory.FullPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>Tests for exclusive lock acquisition, diagnostics and release.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class WorkspaceLockTests
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void AcquireThenDisposeThenAcquireAgainSucceeds()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
WorkspaceLock.Acquire(directory.FullPath).Dispose();
|
||||||
|
// Re-acquiring after release works: no stale lock residue survives.
|
||||||
|
WorkspaceLock.Acquire(directory.FullPath).Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SecondAcquireWhileHeldThrows()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
using WorkspaceLock first = WorkspaceLock.Acquire(directory.FullPath);
|
||||||
|
WorkspaceLockException ex = Assert.ThrowsExactly<WorkspaceLockException>(
|
||||||
|
() => WorkspaceLock.Acquire(directory.FullPath));
|
||||||
|
Assert.AreEqual(WorkspacePaths.LockFile(directory.FullPath), ex.LockFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void AcquireWithoutYkanbanThrowsNotInitialized()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
Assert.ThrowsExactly<WorkspaceNotInitializedException>(
|
||||||
|
() => WorkspaceLock.Acquire(directory.FullPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void LockFileContainsHolderDiagnostics()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
using (WorkspaceLock.Acquire(directory.FullPath))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file survives release and carries PID / machine name / time for humans.
|
||||||
|
string content = File.ReadAllText(WorkspacePaths.LockFile(directory.FullPath));
|
||||||
|
StringAssert.Contains(content, "pid=");
|
||||||
|
StringAssert.Contains(content, $"machine={Environment.MachineName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>Tests for seeding preset workspace content.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class WorkspacePresetTests
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void SeedAddsThreeDistinctPresetColumns()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
WorkspacePreset.SeedColumns(directory.FullPath);
|
||||||
|
|
||||||
|
using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
|
||||||
|
List<string> titles = SqliteTestHelper.GetColumnTitles(connection);
|
||||||
|
|
||||||
|
Assert.AreEqual(3, titles.Count);
|
||||||
|
Assert.AreEqual(titles.Count, titles.Distinct().Count());
|
||||||
|
Assert.IsTrue(titles.All(title => title.Length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SeedPresetColumnsShareOneTimestamp()
|
||||||
|
{
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
WorkspacePreset.SeedColumns(directory.FullPath);
|
||||||
|
|
||||||
|
using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
|
||||||
|
|
||||||
|
// Creation writes created_at = updated_at; all three columns share one moment.
|
||||||
|
Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns WHERE created_at <> updated_at;"));
|
||||||
|
Assert.AreEqual(3L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns WHERE created_at > 0;"));
|
||||||
|
Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(DISTINCT created_at) FROM columns;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>Verifies that the DDL constraints reject invalid data at the door.</summary>
|
||||||
|
[TestClass]
|
||||||
|
public class WorkspaceSchemaTests
|
||||||
|
{
|
||||||
|
/// <summary>Owns a temp directory plus a fresh workspace-schema database for one test.</summary>
|
||||||
|
private sealed class FreshDatabase : IDisposable
|
||||||
|
{
|
||||||
|
public TempDirectory Directory { get; } = new();
|
||||||
|
|
||||||
|
public SqliteConnection Connection { get; }
|
||||||
|
|
||||||
|
public FreshDatabase()
|
||||||
|
{
|
||||||
|
Connection = SqliteDatabase.Open(
|
||||||
|
Path.Combine(Directory.FullPath, "ws.db"), WorkspaceSchema.Migrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Connection.Dispose();
|
||||||
|
Directory.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EmptyColumnTitleIsRejected()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('', 1, 1);"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void DuplicateColumnTitleIsRejected()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ColumnTitlesCompareCaseSensitively()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
// BINARY collation: 'Foo' and 'foo' are distinct titles.
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('foo', 1, 1);");
|
||||||
|
Assert.AreEqual(2L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EmptyTagNameIsRejected()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('', '#112233');"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagNamesAcceptSpacesEmojiAndReservedWords()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
// Tag names are fully open: no charset or length rules, no trimming.
|
||||||
|
foreach (string name in new[] { " ", "a b", "🏷️", "AND", "tag:with:colons" })
|
||||||
|
{
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ($n, '#112233');", ("$n", name));
|
||||||
|
}
|
||||||
|
Assert.AreEqual(5L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM tags;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void DuplicateTagNameIsRejected()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('bug', '#445566');"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("#000000")]
|
||||||
|
[DataRow("#FFFFFF")]
|
||||||
|
[DataRow("#a1B2c3")]
|
||||||
|
public void ValidTagColorsAreAccepted(string color)
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
int affected = SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color));
|
||||||
|
Assert.AreEqual(1, affected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow("112233")]
|
||||||
|
[DataRow("#11223")]
|
||||||
|
[DataRow("#1122334")]
|
||||||
|
[DataRow("#G1B2C3")]
|
||||||
|
[DataRow("#a1b2g3")]
|
||||||
|
[DataRow(" #112233")]
|
||||||
|
[DataRow("#112233 ")]
|
||||||
|
[DataRow("")]
|
||||||
|
public void InvalidTagColorsAreRejected(string color)
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void CardRequiresExistingColumn()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.Connection;
|
||||||
|
// foreign_keys=ON makes the FK enforceable; this is the DDL contract behind the cascades.
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (999, 1, 1);"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void CardDefaultsToEmptyTitleAndContent()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.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);");
|
||||||
|
|
||||||
|
// No title is represented by the empty string.
|
||||||
|
Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(connection, "SELECT title FROM cards WHERE id = 1;"));
|
||||||
|
Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(connection, "SELECT content FROM cards WHERE id = 1;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void CardTagDuplicateAssignmentIsRejected()
|
||||||
|
{
|
||||||
|
using var db = new FreshDatabase();
|
||||||
|
using var connection = db.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');");
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
|
||||||
|
|
||||||
|
// Composite primary key: the same (card, tag) pair cannot be inserted twice.
|
||||||
|
Assert.ThrowsExactly<SqliteException>(() =>
|
||||||
|
SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
/// <summary>Shared helpers for storage tests.</summary>
|
||||||
|
public static class SqliteTestHelper
|
||||||
|
{
|
||||||
|
/// <summary>Executes a non-query SQL statement and returns the affected row count.</summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue(name, value);
|
||||||
|
}
|
||||||
|
return command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the scalar result of a SQL query as a string.</summary>
|
||||||
|
public static string ScalarString(SqliteConnection connection, string sql)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
return (string)command.ExecuteScalar()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the scalar result of a SQL query as a long.</summary>
|
||||||
|
public static long ScalarLong(SqliteConnection connection, string sql)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
return (long)command.ExecuteScalar()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns true when a table with the given name exists.</summary>
|
||||||
|
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);
|
||||||
|
return (long)command.ExecuteScalar()! == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the titles of all columns, ordered by id.</summary>
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
titles.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens a workspace database with the real migration list.</summary>
|
||||||
|
public static SqliteConnection OpenWorkspace(string folderPath)
|
||||||
|
{
|
||||||
|
return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a unique temporary directory per test instance and removes it on
|
||||||
|
/// disposal. Retries the deletion once because SQLite WAL files can linger
|
||||||
|
/// for a moment after connections close.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TempDirectory : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Unique directory path managed by this instance.</summary>
|
||||||
|
public string FullPath { get; }
|
||||||
|
|
||||||
|
public TempDirectory()
|
||||||
|
{
|
||||||
|
FullPath = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(FullPath, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
Thread.Sleep(100);
|
||||||
|
try { Directory.Delete(FullPath, recursive: true); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="PresetColumn_Todo" xml:space="preserve">
|
||||||
|
<value>To Do</value>
|
||||||
|
</data>
|
||||||
|
<data name="PresetColumn_InProgress" xml:space="preserve">
|
||||||
|
<value>In Progress</value>
|
||||||
|
</data>
|
||||||
|
<data name="PresetColumn_Done" xml:space="preserve">
|
||||||
|
<value>Done</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="PresetColumn_Todo" xml:space="preserve">
|
||||||
|
<value>待办</value>
|
||||||
|
</data>
|
||||||
|
<data name="PresetColumn_InProgress" xml:space="preserve">
|
||||||
|
<value>进行中</value>
|
||||||
|
</data>
|
||||||
|
<data name="PresetColumn_Done" xml:space="preserve">
|
||||||
|
<value>已完成</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Runtime.Serialization;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage.AppData;
|
||||||
|
|
||||||
|
/// <summary>Card ordering options. All orders fall back to id ascending.</summary>
|
||||||
|
public enum CardSortOption
|
||||||
|
{
|
||||||
|
/// <summary>Ascending primary key (default).</summary>
|
||||||
|
[EnumMember(Value = "id")]
|
||||||
|
Id,
|
||||||
|
|
||||||
|
/// <summary>Creation time, oldest first.</summary>
|
||||||
|
[EnumMember(Value = "created-at")]
|
||||||
|
CreatedAt,
|
||||||
|
|
||||||
|
/// <summary>Last modification time, most recent first.</summary>
|
||||||
|
[EnumMember(Value = "updated-at")]
|
||||||
|
UpdatedAt,
|
||||||
|
|
||||||
|
/// <summary>Title by code points (BINARY collation); empty titles sort first.</summary>
|
||||||
|
[EnumMember(Value = "title")]
|
||||||
|
Title,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sidebar workspace list ordering.</summary>
|
||||||
|
public enum SidebarSortOption
|
||||||
|
{
|
||||||
|
/// <summary>Folder name A to Z.</summary>
|
||||||
|
[EnumMember(Value = "name-asc")]
|
||||||
|
NameAsc,
|
||||||
|
|
||||||
|
/// <summary>Folder name Z to A.</summary>
|
||||||
|
[EnumMember(Value = "name-desc")]
|
||||||
|
NameDesc,
|
||||||
|
|
||||||
|
/// <summary>By workspace last edit time, most recent first.</summary>
|
||||||
|
[EnumMember(Value = "recently-updated")]
|
||||||
|
RecentlyUpdated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Column width presets. Custom uses a user-defined pixel width.</summary>
|
||||||
|
public enum ColumnWidthPreset
|
||||||
|
{
|
||||||
|
/// <summary>Narrow preset.</summary>
|
||||||
|
[EnumMember(Value = "narrow")]
|
||||||
|
Narrow,
|
||||||
|
|
||||||
|
/// <summary>Standard preset (320 px, the default).</summary>
|
||||||
|
[EnumMember(Value = "standard")]
|
||||||
|
Standard,
|
||||||
|
|
||||||
|
/// <summary>Wide preset.</summary>
|
||||||
|
[EnumMember(Value = "wide")]
|
||||||
|
Wide,
|
||||||
|
|
||||||
|
/// <summary>User-defined pixel width (160–720).</summary>
|
||||||
|
[EnumMember(Value = "custom")]
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UI theme selection.</summary>
|
||||||
|
public enum ThemeOption
|
||||||
|
{
|
||||||
|
/// <summary>Always light.</summary>
|
||||||
|
[EnumMember(Value = "light")]
|
||||||
|
Light,
|
||||||
|
|
||||||
|
/// <summary>Always dark.</summary>
|
||||||
|
[EnumMember(Value = "dark")]
|
||||||
|
Dark,
|
||||||
|
|
||||||
|
/// <summary>Follow the operating system setting (default).</summary>
|
||||||
|
[EnumMember(Value = "follow-system")]
|
||||||
|
FollowSystem,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Card and sidebar list ordering settings.</summary>
|
||||||
|
public sealed class SortSettings
|
||||||
|
{
|
||||||
|
/// <summary>Card order used by all workspaces (global, not per workspace).</summary>
|
||||||
|
[JsonProperty("card")]
|
||||||
|
public CardSortOption Card { get; set; } = CardSortOption.Id;
|
||||||
|
|
||||||
|
/// <summary>Sidebar workspace list order.</summary>
|
||||||
|
[JsonProperty("sidebar")]
|
||||||
|
public SidebarSortOption Sidebar { get; set; } = SidebarSortOption.NameAsc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Board column width settings.</summary>
|
||||||
|
public sealed class ColumnWidthSettings
|
||||||
|
{
|
||||||
|
/// <summary>Width preset applied globally.</summary>
|
||||||
|
[JsonProperty("preset")]
|
||||||
|
public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard;
|
||||||
|
|
||||||
|
/// <summary>Manual width in pixels used when <see cref="Preset"/> is Custom (range 160–720).</summary>
|
||||||
|
[JsonProperty("custom-pixels")]
|
||||||
|
public int CustomPixels { get; set; } = 320;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Confirmation-dialog toggles for destructive actions.</summary>
|
||||||
|
public sealed class ConfirmationSettings
|
||||||
|
{
|
||||||
|
/// <summary>Whether deleting a card asks for confirmation.</summary>
|
||||||
|
[JsonProperty("delete-card")]
|
||||||
|
public bool DeleteCard { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Whether deleting a column asks for confirmation.</summary>
|
||||||
|
[JsonProperty("delete-column")]
|
||||||
|
public bool DeleteColumn { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Whether deleting a tag asks for confirmation.</summary>
|
||||||
|
[JsonProperty("delete-tag")]
|
||||||
|
public bool DeleteTag { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Whether discarding card edits asks for confirmation.</summary>
|
||||||
|
[JsonProperty("discard-edit")]
|
||||||
|
public bool DiscardEdit { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root object of app.json — the trivial KV configuration store. Serialized
|
||||||
|
/// as a whole with kebab-case names (declared via [JsonProperty] and
|
||||||
|
/// [EnumMember]); the "version" field is the migration hook for future
|
||||||
|
/// format changes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppConfig
|
||||||
|
{
|
||||||
|
/// <summary>Current configuration format version.</summary>
|
||||||
|
public const int CurrentFormatVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Configuration format version of this file.</summary>
|
||||||
|
[JsonProperty("version")]
|
||||||
|
public int Version { get; set; } = CurrentFormatVersion;
|
||||||
|
|
||||||
|
/// <summary>UI language code: "en" (default) or "zh-Hans".</summary>
|
||||||
|
[JsonProperty("language")]
|
||||||
|
public string Language { get; set; } = "en";
|
||||||
|
|
||||||
|
/// <summary>UI theme selection (default: follow the system).</summary>
|
||||||
|
[JsonProperty("theme")]
|
||||||
|
public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
|
||||||
|
|
||||||
|
/// <summary>Ordering settings.</summary>
|
||||||
|
[JsonProperty("sort")]
|
||||||
|
public SortSettings Sort { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Column width settings.</summary>
|
||||||
|
[JsonProperty("column-width")]
|
||||||
|
public ColumnWidthSettings ColumnWidth { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Confirmation-dialog settings.</summary>
|
||||||
|
[JsonProperty("confirmations")]
|
||||||
|
public ConfirmationSettings Confirmations { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage.AppData;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads and saves app.json with graceful degradation: a missing file yields
|
||||||
|
/// defaults; a corrupt file is backed up to app.json.bak and then defaults
|
||||||
|
/// are used. Saving writes the file directly (no atomic replace) — losing
|
||||||
|
/// the last session's settings to a crash is an accepted trade-off.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppConfigStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerSettings SerializerSettings = new()
|
||||||
|
{
|
||||||
|
Formatting = Formatting.Indented,
|
||||||
|
NullValueHandling = NullValueHandling.Ignore,
|
||||||
|
|
||||||
|
// Enums round-trip through the kebab-case strings declared via [EnumMember].
|
||||||
|
Converters = { new StringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Creates a store bound to a specific app.json path.</summary>
|
||||||
|
public AppConfigStore(string filePath) => FilePath = filePath;
|
||||||
|
|
||||||
|
/// <summary>Full path of the managed app.json file.</summary>
|
||||||
|
public string FilePath { get; }
|
||||||
|
|
||||||
|
/// <summary>Loads the configuration: missing file → defaults; corrupt file → backup then defaults.</summary>
|
||||||
|
/// <exception cref="IOException">The file exists but cannot be read.</exception>
|
||||||
|
public AppConfig Load()
|
||||||
|
{
|
||||||
|
// Missing file is a normal first-run state: run with defaults.
|
||||||
|
if (!File.Exists(FilePath))
|
||||||
|
{
|
||||||
|
return new AppConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
string json = File.ReadAllText(FilePath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A JSON null literal deserializes to null; treat it like defaults.
|
||||||
|
AppConfig? config = JsonConvert.DeserializeObject<AppConfig>(json, SerializerSettings);
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return new AppConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older or hand-edited files may miss whole sections; each falls back to its defaults.
|
||||||
|
config.Sort ??= new SortSettings();
|
||||||
|
config.ColumnWidth ??= new ColumnWidthSettings();
|
||||||
|
config.Confirmations ??= new ConfirmationSettings();
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Corrupt: keep a copy for inspection, then continue with defaults.
|
||||||
|
File.Copy(FilePath, FilePath + ".bak", overwrite: true);
|
||||||
|
return new AppConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the configuration directly to disk (no atomic replace).</summary>
|
||||||
|
public void Save(AppConfig config)
|
||||||
|
{
|
||||||
|
// Create the parent folder on demand; normally it exists already.
|
||||||
|
string? directory = Path.GetDirectoryName(FilePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage.AppData;
|
||||||
|
|
||||||
|
/// <summary>A known workspace row of the global app.db.</summary>
|
||||||
|
/// <param name="Path">Workspace folder path as registered.</param>
|
||||||
|
/// <param name="LastEditedAt">Unix seconds of the last successful data write to the workspace database.</param>
|
||||||
|
/// <param name="AddedAt">Unix seconds when the workspace was registered.</param>
|
||||||
|
public sealed record WorkspaceRecord(string Path, long LastEditedAt, long AddedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Global application database (app.db): stores the persistent workspace
|
||||||
|
/// list. Any successful data write into a workspace database must be
|
||||||
|
/// mirrored here by refreshing last_edited_at (the write-through is wired
|
||||||
|
/// by the repository layer). Uses the same PRAGMA configuration as the
|
||||||
|
/// workspace databases.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppDatabase : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Latest app.db schema version this build understands.</summary>
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<SchemaMigration> Migrations = new SchemaMigration[]
|
||||||
|
{
|
||||||
|
new SchemaMigration(CurrentSchemaVersion, "initial schema", """
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
last_edited_at INTEGER NOT NULL,
|
||||||
|
added_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly Func<long> _unixNow;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
/// <summary>Opens (creating if needed) the app.db file and applies pending migrations.</summary>
|
||||||
|
/// <param name="databasePath">Path of the app.db file.</param>
|
||||||
|
/// <param name="unixNow">Clock override for tests; defaults to real Unix time.</param>
|
||||||
|
public AppDatabase(string databasePath, Func<long>? unixNow = null)
|
||||||
|
{
|
||||||
|
// Ensure the application data folder exists before creating the database file.
|
||||||
|
string? directory = Path.GetDirectoryName(databasePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
_unixNow = unixNow ?? (() => UnixTime.Now);
|
||||||
|
_connection = SqliteDatabase.Open(databasePath, Migrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns all known workspaces ordered by added_at ascending.</summary>
|
||||||
|
public IReadOnlyList<WorkspaceRecord> GetWorkspaces()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var command = _connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT path, last_edited_at, added_at FROM workspaces ORDER BY added_at ASC, path ASC;";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
|
||||||
|
var records = new List<WorkspaceRecord>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
records.Add(new WorkspaceRecord(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2)));
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registers a workspace, or refreshes last_edited_at if it is already known.</summary>
|
||||||
|
public void UpsertWorkspace(string path)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
long now = _unixNow();
|
||||||
|
using var command = _connection.CreateCommand();
|
||||||
|
// On conflict only last_edited_at moves; the original added_at is preserved.
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO workspaces (path, last_edited_at, added_at)
|
||||||
|
VALUES ($path, $now, $now)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET last_edited_at = $now;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$path", path);
|
||||||
|
command.Parameters.AddWithValue("$now", now);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes last_edited_at for a known workspace; registers it if the row is absent.</summary>
|
||||||
|
public void TouchLastEdited(string path)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var command = _connection.CreateCommand();
|
||||||
|
command.CommandText = "UPDATE workspaces SET last_edited_at = $now WHERE path = $path;";
|
||||||
|
command.Parameters.AddWithValue("$path", path);
|
||||||
|
command.Parameters.AddWithValue("$now", _unixNow());
|
||||||
|
if (command.ExecuteNonQuery() == 0)
|
||||||
|
{
|
||||||
|
// Self-healing path: a workspace opened before registration still gets a row.
|
||||||
|
UpsertWorkspace(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets a workspace entry; the .ykanban folder itself is untouched.</summary>
|
||||||
|
public void RemoveWorkspace(string path)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var command = _connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM workspaces WHERE path = $path;";
|
||||||
|
command.Parameters.AddWithValue("$path", path);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the underlying connection.</summary>
|
||||||
|
public void Dispose() => _connection.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace YKanBan.Storage;
|
||||||
|
|
||||||
|
/// <summary>A single incremental schema step applied when the stored user_version is one below <see cref="Version"/>.</summary>
|
||||||
|
/// <param name="Version">Target user_version after applying this migration (contiguous, starting at 1).</param>
|
||||||
|
/// <param name="Name">Short human-readable identifier used for diagnostics.</param>
|
||||||
|
/// <param name="Sql">SQL script executed inside one transaction together with the user_version bump.</param>
|
||||||
|
public sealed record SchemaMigration(int Version, string Name, string Sql);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared SQLite plumbing for both the per-workspace databases and the
|
||||||
|
/// global app.db: opens connections with the mandated PRAGMA configuration
|
||||||
|
/// (WAL journal, NORMAL synchronous mode, foreign keys on) and applies
|
||||||
|
/// incremental user_version migrations.
|
||||||
|
/// </summary>
|
||||||
|
public static class SqliteDatabase
|
||||||
|
{
|
||||||
|
/// <summary>Opens (creating if needed) a database file, applies PRAGMAs and pending migrations.</summary>
|
||||||
|
/// <param name="databasePath">Path of the SQLite database file.</param>
|
||||||
|
/// <param name="migrations">Contiguous migration list, ordered by version starting at 1.</param>
|
||||||
|
/// <returns>An open connection with all PRAGMAs applied and the schema migrated.</returns>
|
||||||
|
public static SqliteConnection Open(string databasePath, IReadOnlyList<SchemaMigration> migrations)
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = databasePath,
|
||||||
|
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||||
|
Pooling = false,
|
||||||
|
}.ToString());
|
||||||
|
connection.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ApplyPragmas(connection);
|
||||||
|
ApplyMigrations(connection, migrations);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
connection.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the stored PRAGMA user_version value.</summary>
|
||||||
|
public static long ReadUserVersion(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "PRAGMA user_version;";
|
||||||
|
return (long)command.ExecuteScalar()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies per-connection PRAGMAs. WAL itself is a persistent database property.</summary>
|
||||||
|
internal static void ApplyPragmas(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
// WAL plus NORMAL synchronous is the chosen durability/performance trade-off;
|
||||||
|
// foreign_keys must be enabled on every connection or DDL-level cascades never fire.
|
||||||
|
Execute(connection, "PRAGMA journal_mode=WAL;");
|
||||||
|
Execute(connection, "PRAGMA synchronous=NORMAL;");
|
||||||
|
Execute(connection, "PRAGMA foreign_keys=ON;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs every migration newer than the stored user_version, each in its own transaction.</summary>
|
||||||
|
/// <exception cref="SchemaVersionException">The database was written by a newer build.</exception>
|
||||||
|
/// <exception cref="ArgumentException">The migration list is not contiguous from version 1.</exception>
|
||||||
|
internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList<SchemaMigration> migrations)
|
||||||
|
{
|
||||||
|
// Contract check: the list must be contiguous and ordered, starting at version 1.
|
||||||
|
for (int index = 0; index < migrations.Count; index++)
|
||||||
|
{
|
||||||
|
if (migrations[index].Version != index + 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Migrations must be contiguous and start at version 1.", nameof(migrations));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long current = ReadUserVersion(connection);
|
||||||
|
int latest = migrations.Count;
|
||||||
|
|
||||||
|
// A database written by a newer build cannot be managed by this one; refuse loudly.
|
||||||
|
if (current > latest)
|
||||||
|
{
|
||||||
|
throw new SchemaVersionException(current, latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply each pending migration atomically so a crash never leaves a half-migrated database.
|
||||||
|
foreach (SchemaMigration migration in migrations)
|
||||||
|
{
|
||||||
|
if (migration.Version <= current)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
Execute(connection, migration.Sql, transaction);
|
||||||
|
Execute(connection, $"PRAGMA user_version={migration.Version};", transaction);
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Executes a non-query SQL script, optionally inside an explicit transaction.</summary>
|
||||||
|
private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = commandText;
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
namespace YKanBan.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed exceptions thrown by the YKanBan storage and application layers.
|
||||||
|
/// Exception messages are always English and never localized; the UI maps
|
||||||
|
/// exception types to localized resource keys instead.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class YKanBanException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>Creates an exception with an English diagnostic message.</summary>
|
||||||
|
protected YKanBanException(string message, Exception? innerException = null)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signals that the workspace folder does not exist on disk.</summary>
|
||||||
|
public sealed class WorkspaceDirectoryMissingException : YKanBanException
|
||||||
|
{
|
||||||
|
/// <summary>The folder path that was expected to exist.</summary>
|
||||||
|
public string FolderPath { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates an exception for a folder that is absent on disk.</summary>
|
||||||
|
public WorkspaceDirectoryMissingException(string folderPath)
|
||||||
|
: base($"The folder does not exist: {folderPath}")
|
||||||
|
{
|
||||||
|
FolderPath = folderPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signals that the folder has no .ykanban structure yet.</summary>
|
||||||
|
public sealed class WorkspaceNotInitializedException : YKanBanException
|
||||||
|
{
|
||||||
|
/// <summary>The folder path that lacks the .ykanban structure.</summary>
|
||||||
|
public string FolderPath { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates an exception for a folder without .ykanban.</summary>
|
||||||
|
public WorkspaceNotInitializedException(string folderPath)
|
||||||
|
: base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}")
|
||||||
|
{
|
||||||
|
FolderPath = folderPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signals that another instance holds the workspace lock.</summary>
|
||||||
|
public sealed class WorkspaceLockException : YKanBanException
|
||||||
|
{
|
||||||
|
/// <summary>The lock file that could not be acquired.</summary>
|
||||||
|
public string LockFilePath { get; }
|
||||||
|
|
||||||
|
/// <summary>Best-effort diagnostics (PID/machine/time) read from the lock file, if readable.</summary>
|
||||||
|
public string? HolderDiagnostics { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates an exception for a failed lock acquisition.</summary>
|
||||||
|
public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException)
|
||||||
|
: base(BuildMessage(lockFilePath, holderDiagnostics), innerException)
|
||||||
|
{
|
||||||
|
LockFilePath = lockFilePath;
|
||||||
|
HolderDiagnostics = holderDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Composes the message, including holder details when they were readable.</summary>
|
||||||
|
private static string BuildMessage(string lockFilePath, string? holderDiagnostics)
|
||||||
|
{
|
||||||
|
string message = $"The workspace is locked by another instance: {lockFilePath}";
|
||||||
|
if (!string.IsNullOrEmpty(holderDiagnostics))
|
||||||
|
{
|
||||||
|
message += $" Lock holder: {holderDiagnostics}";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signals that the stored schema version is not manageable by this build.</summary>
|
||||||
|
public sealed class SchemaVersionException : YKanBanException
|
||||||
|
{
|
||||||
|
/// <summary>The user_version value found in the database.</summary>
|
||||||
|
public long StoredVersion { get; }
|
||||||
|
|
||||||
|
/// <summary>The latest schema version this build understands.</summary>
|
||||||
|
public int SupportedVersion { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates an exception for an unmanageable schema version.</summary>
|
||||||
|
public SchemaVersionException(long storedVersion, int supportedVersion)
|
||||||
|
: base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.")
|
||||||
|
{
|
||||||
|
StoredVersion = storedVersion;
|
||||||
|
SupportedVersion = supportedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace YKanBan.Storage;
|
||||||
|
|
||||||
|
/// <summary>Provides the current Unix time; all persisted timestamps are INTEGER Unix seconds.</summary>
|
||||||
|
public static class UnixTime
|
||||||
|
{
|
||||||
|
/// <summary>Current UTC time as Unix seconds.</summary>
|
||||||
|
public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the .ykanban structure for a folder: the folder itself and an
|
||||||
|
/// empty, fully migrated database. Seeding preset content is a separate
|
||||||
|
/// concern handled by <see cref="WorkspacePreset"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkspaceInitializer
|
||||||
|
{
|
||||||
|
/// <summary>Returns true when the folder contains a .ykanban structure (no parent lookup).</summary>
|
||||||
|
public static bool IsWorkspace(string folderPath) => Directory.Exists(WorkspacePaths.Root(folderPath));
|
||||||
|
|
||||||
|
/// <summary>Initializes a fresh workspace: creates .ykanban and an empty migrated database.</summary>
|
||||||
|
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
|
||||||
|
public static void Initialize(string folderPath)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(folderPath))
|
||||||
|
{
|
||||||
|
throw new WorkspaceDirectoryMissingException(folderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string databasePath = WorkspacePaths.Database(folderPath);
|
||||||
|
if (File.Exists(databasePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"The workspace database already exists: {databasePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the .ykanban folder and the migrated, still empty database.
|
||||||
|
Directory.CreateDirectory(WorkspacePaths.Root(folderPath));
|
||||||
|
using SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle
|
||||||
|
/// is held until disposal; crashes and power loss release it automatically.
|
||||||
|
/// The lock file carries PID / machine name / time for human diagnostics
|
||||||
|
/// only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkspaceLock : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FileStream _stream;
|
||||||
|
|
||||||
|
private WorkspaceLock(FileStream stream, string lockFilePath)
|
||||||
|
{
|
||||||
|
_stream = stream;
|
||||||
|
LockFilePath = lockFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Path of the lock file being held.</summary>
|
||||||
|
public string LockFilePath { get; }
|
||||||
|
|
||||||
|
/// <summary>Acquires the workspace lock or throws <see cref="WorkspaceLockException"/>.</summary>
|
||||||
|
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
|
||||||
|
/// <exception cref="WorkspaceLockException">Another instance holds the lock.</exception>
|
||||||
|
public static WorkspaceLock Acquire(string folderPath)
|
||||||
|
{
|
||||||
|
string lockFilePath = WorkspacePaths.LockFile(folderPath);
|
||||||
|
FileStream stream;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// FileShare.None gives the exclusive semantics; FileMode.Create truncates any stale content.
|
||||||
|
stream = new FileStream(lockFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
throw new WorkspaceNotInitializedException(folderPath);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
throw new WorkspaceLockException(lockFilePath, ReadDiagnosticsBestEffort(lockFilePath), ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write holder diagnostics so humans can identify the owning instance from the file alone.
|
||||||
|
string diagnostics = $"pid={Environment.ProcessId};machine={Environment.MachineName};time={DateTimeOffset.UtcNow:O}";
|
||||||
|
byte[] payload = Encoding.UTF8.GetBytes(diagnostics);
|
||||||
|
stream.Write(payload, 0, payload.Length);
|
||||||
|
stream.Flush();
|
||||||
|
return new WorkspaceLock(stream, lockFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Attempts to read holder diagnostics from a lock file we failed to acquire.</summary>
|
||||||
|
private static string? ReadDiagnosticsBestEffort(string lockFilePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.ReadAllText(lockFilePath);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the exclusive handle. The lock file itself is intentionally left on disk.</summary>
|
||||||
|
public void Dispose() => _stream.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>Well-known file and folder names inside a workspace's .ykanban structure.</summary>
|
||||||
|
public static class WorkspacePaths
|
||||||
|
{
|
||||||
|
/// <summary>Name of the workspace root folder created inside a managed project.</summary>
|
||||||
|
public const string RootFolderName = ".ykanban";
|
||||||
|
|
||||||
|
/// <summary>Name of the SQLite database inside .ykanban.</summary>
|
||||||
|
public const string DatabaseFileName = "ykanban.db";
|
||||||
|
|
||||||
|
/// <summary>Name of the exclusive lock file inside .ykanban.</summary>
|
||||||
|
public const string LockFileName = "ykanban.lock";
|
||||||
|
|
||||||
|
/// <summary>Returns the .ykanban root path for a workspace folder.</summary>
|
||||||
|
public static string Root(string folderPath) => Path.Combine(folderPath, RootFolderName);
|
||||||
|
|
||||||
|
/// <summary>Returns the workspace database path for a workspace folder.</summary>
|
||||||
|
public static string Database(string folderPath) => Path.Combine(folderPath, RootFolderName, DatabaseFileName);
|
||||||
|
|
||||||
|
/// <summary>Returns the workspace lock file path for a workspace folder.</summary>
|
||||||
|
public static string LockFile(string folderPath) => Path.Combine(folderPath, RootFolderName, LockFileName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds initial workspace content. The three preset column titles are read
|
||||||
|
/// straight from the ResX resources in the UI language active at seeding
|
||||||
|
/// time, then stored as ordinary data that no longer follows language
|
||||||
|
/// switches.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkspacePreset
|
||||||
|
{
|
||||||
|
/// <summary>Inserts the three preset columns (To Do / In Progress / Done equivalents).</summary>
|
||||||
|
/// <exception cref="SqliteException">A title collides with an existing column.</exception>
|
||||||
|
public static void SeedColumns(string folderPath)
|
||||||
|
{
|
||||||
|
using SqliteConnection connection = SqliteDatabase.Open(
|
||||||
|
WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
|
||||||
|
|
||||||
|
long now = UnixTime.Now;
|
||||||
|
|
||||||
|
// Preset titles are data at creation time: whatever the current language says gets stored.
|
||||||
|
string[] titles =
|
||||||
|
[
|
||||||
|
Resources.PresetColumn_Todo,
|
||||||
|
Resources.PresetColumn_InProgress,
|
||||||
|
Resources.PresetColumn_Done,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insert all three in one transaction so a half-seeded workspace can never exist.
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
foreach (string title in titles)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO columns (title, description, created_at, updated_at)
|
||||||
|
VALUES ($title, '', $now, $now);
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$title", title);
|
||||||
|
command.Parameters.AddWithValue("$now", now);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schema definitions for the per-workspace database. The board is a single
|
||||||
|
/// board with a columns → cards hierarchy. All data validation is expressed
|
||||||
|
/// in DDL (CHECK / UNIQUE / FOREIGN KEY) so that any row entering the
|
||||||
|
/// database is valid by construction; cascading deletes are covered by the
|
||||||
|
/// same DDL.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkspaceSchema
|
||||||
|
{
|
||||||
|
/// <summary>Latest workspace schema version this build understands.</summary>
|
||||||
|
public const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Ordered, contiguous migration list for the workspace database.</summary>
|
||||||
|
public static readonly IReadOnlyList<SchemaMigration> Migrations = new SchemaMigration[]
|
||||||
|
{
|
||||||
|
new SchemaMigration(1, "initial schema", """
|
||||||
|
CREATE TABLE columns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL UNIQUE CHECK (length(title) > 0),
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
column_id INTEGER NOT NULL REFERENCES columns (id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE CHECK (length(name) > 0),
|
||||||
|
color TEXT NOT NULL CHECK (color GLOB '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'),
|
||||||
|
description TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE card_tags (
|
||||||
|
card_id INTEGER NOT NULL REFERENCES cards (id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (card_id, tag_id)
|
||||||
|
);
|
||||||
|
"""),
|
||||||
|
};
|
||||||
|
}
|
||||||
+19
-1
@@ -2,6 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
@@ -9,7 +10,24 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="Assets\**" Exclude="Assets\Locales\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--Strongly-typed ResX resources generated by the cross-platform MSBuild generator.
|
||||||
|
LogicalName is pinned so the manifest names stay stable despite the Assets/Locales location.-->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Update="Assets/Locales/Resources.resx">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<StronglyTypedFileName>$(IntermediateOutputPath)Resources.Designer.cs</StronglyTypedFileName>
|
||||||
|
<StronglyTypedNamespace>YKanBan</StronglyTypedNamespace>
|
||||||
|
<StronglyTypedClassName>Resources</StronglyTypedClassName>
|
||||||
|
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
|
||||||
|
<PublicClass>true</PublicClass>
|
||||||
|
<LogicalName>YKanBan.Resources.resources</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Update="Assets/Locales/Resources.zh-Hans.resx">
|
||||||
|
<LogicalName>YKanBan.Resources.zh-Hans.resources</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in new issue
Block a user