diff --git a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs new file mode 100644 index 0000000..3413b64 --- /dev/null +++ b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs @@ -0,0 +1,40 @@ +using YKanBan.Launching; + +namespace YKanBan.Tests.Launching; + +/// +/// Tests for the ykanban <path> command-line contract across its +/// three branches: no argument, one valid path, and invalid input. +/// +[TestClass] +public class LaunchArgumentsTests +{ + [TestMethod] + public void NoArgumentsIsRejected() + { + Assert.IsFalse(LaunchArguments.TryParse([], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void BlankArgumentIsRejected() + { + Assert.IsFalse(LaunchArguments.TryParse([" "], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void MoreThanOneArgumentIsRejected() + { + Assert.IsFalse(LaunchArguments.TryParse(["a", "b"], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void SinglePathIsAccepted() + { + bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path); + Assert.IsTrue(parsed); + Assert.AreEqual(@"C:\Repo\MyProject", path); + } +} diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs new file mode 100644 index 0000000..55b1915 --- /dev/null +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -0,0 +1,161 @@ +using YKanBan.Storage.AppData; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.AppData; + +/// +/// Tests for app.json round-tripping, kebab-case naming and graceful +/// degradation on a missing or corrupt file. +/// +[TestClass] +public class AppConfigStoreTests +{ + [TestMethod] + public void LoadMissingFileReturnsDefaults() + { + 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(AppConfig.DefaultLanguage, config.Language); + Assert.AreEqual(ThemeOption.FollowSystem, config.Theme); + Assert.AreEqual(CardSortOption.Id, config.Sort.Card); + 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 LoadCorruptFileBacksUpAndReturnsDefaults() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, "{ this is not valid json"); + var store = new AppConfigStore(path); + + AppConfig config = store.Load(); + + Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version); + Assert.IsTrue(File.Exists(path + ".bak")); + Assert.AreEqual("{ this is not valid json", File.ReadAllText(path + ".bak")); + } + + [TestMethod] + public void LoadJsonNullReturnsDefaults() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, "null"); + var store = new AppConfigStore(path); + + Assert.AreEqual(AppConfig.CurrentFormatVersion, store.Load().Version); + } + + [TestMethod] + public void LoadReadsKebabCaseKeysAndEnumValues() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, $$""" + { + "version": {{AppConfig.CurrentFormatVersion}}, + "language": "zh-Hans", + "theme": "dark", + "sort": { "card": "title" }, + "column-width": { "preset": "custom", "custom-pixels": 400 }, + "confirmations": { + "delete-card": false, + "delete-column": false, + "delete-tag": false, + "discard-edit": false + } + } + """); + var store = new AppConfigStore(path); + + AppConfig config = store.Load(); + + Assert.AreEqual("zh-Hans", config.Language); + Assert.AreEqual(ThemeOption.Dark, config.Theme); + Assert.AreEqual(CardSortOption.Title, config.Sort.Card); + Assert.AreEqual(ColumnWidthPreset.Custom, config.ColumnWidth.Preset); + Assert.AreEqual(400, config.ColumnWidth.CustomPixels); + Assert.IsFalse(config.Confirmations.DeleteCard); + Assert.IsFalse(config.Confirmations.DeleteColumn); + Assert.IsFalse(config.Confirmations.DeleteTag); + Assert.IsFalse(config.Confirmations.DiscardEdit); + } + + [TestMethod] + public void LoadMissingSectionsFillsDefaults() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, $$"""{ "version": {{AppConfig.CurrentFormatVersion}} }"""); + var store = new AppConfigStore(path); + + AppConfig config = store.Load(); + + Assert.IsNotNull(config.Sort); + Assert.IsNotNull(config.ColumnWidth); + Assert.IsNotNull(config.Confirmations); + Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset); + } + + [TestMethod] + public void SaveWritesKebabCaseKeysAndEnumValues() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + + var config = new AppConfig + { + Theme = ThemeOption.FollowSystem, + Sort = new SortSettings { Card = CardSortOption.UpdatedAt }, + ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 }, + Confirmations = new ConfirmationSettings { DeleteColumn = false }, + }; + store.Save(config); + + string json = File.ReadAllText(path); + StringAssert.Contains(json, "\"delete-column\""); + StringAssert.Contains(json, "\"discard-edit\""); + StringAssert.Contains(json, "\"custom-pixels\""); + StringAssert.Contains(json, "\"follow-system\""); + StringAssert.Contains(json, "\"updated-at\""); + // No member-name CamelCase must leak into the file. + Assert.IsFalse(json.Contains("deleteColumn", StringComparison.Ordinal)); + Assert.IsFalse(json.Contains("CustomPixels", StringComparison.Ordinal)); + } + + [TestMethod] + public void SaveThenLoadRoundTrips() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + + var config = new AppConfig + { + Language = "zh-Hans", + Theme = ThemeOption.Light, + Sort = new SortSettings { Card = CardSortOption.CreatedAt }, + ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide }, + Confirmations = new ConfirmationSettings { DiscardEdit = false }, + }; + store.Save(config); + + AppConfig reloaded = store.Load(); + Assert.AreEqual(config.Language, reloaded.Language); + Assert.AreEqual(config.Theme, reloaded.Theme); + Assert.AreEqual(config.Sort.Card, reloaded.Sort.Card); + Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset); + Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit); + } +} diff --git a/YKanBan.Tests/Storage/SqliteDatabaseTests.cs b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs new file mode 100644 index 0000000..1bf3be8 --- /dev/null +++ b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs @@ -0,0 +1,83 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage; + +/// +/// Tests for the shared SQLite plumbing: mandatory PRAGMAs and the +/// user_version-driven migration runner. +/// +[TestClass] +public class SqliteDatabaseTests +{ + [TestMethod] + public void OpenAppliesMandatoryPragmas() + { + using var directory = new TempDirectory(); + string databasePath = Path.Combine(directory.FullPath, "pragmas.db"); + + using SqliteConnection connection = SqliteDatabase.Open(databasePath, []); + 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 OpenSetsUserVersionToLatestMigration() + { + using var directory = new TempDirectory(); + string databasePath = Path.Combine(directory.FullPath, "version.db"); + + using SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations); + Assert.AreEqual(WorkspaceSchema.CurrentVersion, SqliteDatabase.ReadUserVersion(connection)); + } + + [TestMethod] + public void ReopenKeepsWalJournalMode() + { + using var directory = new TempDirectory(); + string databasePath = Path.Combine(directory.FullPath, "wal.db"); + + using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) + { + } + + // WAL mode is stored in the database header, so a fresh connection keeps it. + using SqliteConnection reopened = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations); + Assert.AreEqual("wal", SqliteTestHelper.ScalarString(reopened, "PRAGMA journal_mode;")); + } + + [TestMethod] + public void OpenRejectsNewerStoredSchemaVersion() + { + using var directory = new TempDirectory(); + string databasePath = Path.Combine(directory.FullPath, "newer.db"); + + long newerVersion = WorkspaceSchema.CurrentVersion + 1; + using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) + { + SqliteTestHelper.Exec(connection, $"PRAGMA user_version={newerVersion};"); + } + + SchemaVersionException exception = Assert.ThrowsExactly( + () => SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)); + Assert.AreEqual(newerVersion, exception.StoredVersion); + Assert.AreEqual(WorkspaceSchema.CurrentVersion, exception.SupportedVersion); + } + + [TestMethod] + public void OpenRejectsNonContiguousMigrationList() + { + using var directory = new TempDirectory(); + string databasePath = Path.Combine(directory.FullPath, "gap.db"); + + SchemaMigration[] broken = + [ + new SchemaMigration(2, "starts too high", "SELECT 1;"), + ]; + + Assert.ThrowsExactly(() => SqliteDatabase.Open(databasePath, broken)); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs new file mode 100644 index 0000000..515cd7b --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Tests for workspace detection and initialization. Initialization creates +/// the structure and an empty migrated database, but deliberately adds no +/// content. +/// +[TestClass] +public class WorkspaceInitializerTests +{ + [TestMethod] + public void IsWorkspaceReflectsInitializationState() + { + using var directory = new TempDirectory(); + Assert.IsFalse(WorkspaceInitializer.IsWorkspace(directory.FullPath)); + + WorkspaceInitializer.Initialize(directory.FullPath); + Assert.IsTrue(WorkspaceInitializer.IsWorkspace(directory.FullPath)); + } + + [TestMethod] + public void InitializeCreatesStructureAndMigratedEmptyDatabase() + { + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + + Assert.IsTrue(Directory.Exists(WorkspacePaths.Root(directory.FullPath))); + Assert.IsTrue(File.Exists(WorkspacePaths.Database(directory.FullPath))); + + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); + Assert.AreEqual(WorkspaceSchema.CurrentVersion, SqliteDatabase.ReadUserVersion(connection)); + Assert.IsTrue(SqliteTestHelper.TableExists(connection, "columns")); + Assert.IsTrue(SqliteTestHelper.TableExists(connection, "cards")); + Assert.IsTrue(SqliteTestHelper.TableExists(connection, "tags")); + Assert.IsTrue(SqliteTestHelper.TableExists(connection, "card_tags")); + // Initialization does not add preset content. + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns;")); + } + + [TestMethod] + public void InitializeMissingFolderThrows() + { + using var directory = new TempDirectory(); + string missing = Path.Combine(directory.FullPath, "not-created"); + + WorkspaceDirectoryMissingException exception = Assert.ThrowsExactly( + () => WorkspaceInitializer.Initialize(missing)); + Assert.AreEqual(missing, exception.FolderPath); + } + + [TestMethod] + public void InitializeTwiceThrows() + { + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + + Assert.ThrowsExactly(() => WorkspaceInitializer.Initialize(directory.FullPath)); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs new file mode 100644 index 0000000..5806c23 --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs @@ -0,0 +1,59 @@ +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Tests for exclusive lock acquisition, diagnostics and release. +/// +[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 exception = Assert.ThrowsExactly( + () => WorkspaceLock.Acquire(directory.FullPath)); + Assert.AreEqual(WorkspacePaths.LockFile(directory.FullPath), exception.LockFilePath); + } + + [TestMethod] + public void AcquireWithoutYkanbanThrowsNotInitialized() + { + using var directory = new TempDirectory(); + Assert.ThrowsExactly( + () => WorkspaceLock.Acquire(directory.FullPath)); + } + + [TestMethod] + public void LockFileContainsHolderDiagnosticsAfterRelease() + { + 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}"); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs new file mode 100644 index 0000000..813ddc7 --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs @@ -0,0 +1,99 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Verifies that the preset column titles follow the UI language active when +/// they are added and then live as ordinary data. These tests mutate the +/// global , so they are marked do-not-parallelize. +/// +[TestClass] +[DoNotParallelize] +public class WorkspacePresetTests +{ + [TestMethod] + public void AddPresetColumnsStoresEnglishTitlesUnderEnglishCulture() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + string[] expected = + [ + Resources.PresetColumn_Todo, + Resources.PresetColumn_InProgress, + Resources.PresetColumn_Done, + ]; + + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + WorkspacePreset.AddPresetColumns(directory.FullPath); + + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); + CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection)); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + public void AddPresetColumnsStoresChineseTitlesUnderChineseCulture() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("zh-Hans"); + string[] expected = + [ + Resources.PresetColumn_Todo, + Resources.PresetColumn_InProgress, + Resources.PresetColumn_Done, + ]; + + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + WorkspacePreset.AddPresetColumns(directory.FullPath); + + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); + CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection)); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + public void PresetTitlesAreDataAndIgnoreLaterCultureChanges() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + string[] expected = + [ + Resources.PresetColumn_Todo, + Resources.PresetColumn_InProgress, + Resources.PresetColumn_Done, + ]; + + using var directory = new TempDirectory(); + WorkspaceInitializer.Initialize(directory.FullPath); + WorkspacePreset.AddPresetColumns(directory.FullPath); + + // Switching the language after adding the presets must not rewrite stored data. + Resources.Culture = new CultureInfo("zh-Hans"); + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); + CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection)); + } + finally + { + Resources.Culture = original; + } + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs new file mode 100644 index 0000000..7e2d5b1 --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs @@ -0,0 +1,224 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Verifies that the workspace DDL constraints reject invalid data at the door +/// and that the cascading delete chains work as specified. +/// +[TestClass] +public class WorkspaceSchemaTests +{ + /// + /// Owns a temp directory plus a fresh workspace-schema database for a single test. + /// + 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); + } + + /// + /// Closes the connection before removing the temporary directory. + /// + public void Dispose() + { + Connection.Dispose(); + Directory.Dispose(); + } + } + + [TestMethod] + public void EmptyColumnTitleIsRejected() + { + using var db = new FreshDatabase(); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('', 1, 1);")); + } + + [TestMethod] + public void WhitespaceColumnTitleIsAccepted() + { + using var db = new FreshDatabase(); + // No trimming: a single space is a non-empty title as far as the DDL is concerned. + int affected = SqliteTestHelper.Exec( + db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES (' ', 1, 1);"); + Assert.AreEqual(1, affected); + } + + [TestMethod] + public void DuplicateColumnTitleIsRejected() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);")); + } + + [TestMethod] + public void ColumnTitlesCompareCaseSensitively() + { + using var db = new FreshDatabase(); + // BINARY collation: 'Foo' and 'foo' are distinct titles. + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('foo', 1, 1);"); + Assert.AreEqual(2L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;")); + } + + [TestMethod] + public void EmptyTagNameIsRejected() + { + using var db = new FreshDatabase(); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('', '#112233');")); + } + + [TestMethod] + public void TagNamesAcceptSpacesEmojiAndReservedWords() + { + using var db = new FreshDatabase(); + // 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(db.Connection, "INSERT INTO tags (name, color) VALUES ($n, '#112233');", ("$n", name)); + } + Assert.AreEqual(5L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;")); + } + + [TestMethod] + public void DuplicateTagNameIsRejected() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');"); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.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(); + int affected = SqliteTestHelper.Exec( + db.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(); + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color))); + } + + [TestMethod] + public void CardRequiresExistingColumn() + { + using var db = new FreshDatabase(); + // foreign_keys=ON makes the FK enforceable; this is the DDL contract behind the cascades. + Assert.ThrowsExactly(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (999, 1, 1);")); + } + + [TestMethod] + public void CardDefaultsToEmptyTitleAndContent() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);"); + SqliteTestHelper.Exec(db.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(db.Connection, "SELECT title FROM cards WHERE id = 1;")); + Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(db.Connection, "SELECT content FROM cards WHERE id = 1;")); + } + + [TestMethod] + public void CardTagDuplicateAssignmentIsRejected() + { + using var db = new FreshDatabase(); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);"); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);"); + SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');"); + SqliteTestHelper.Exec(db.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(() => + SqliteTestHelper.Exec(db.Connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);")); + } + + [TestMethod] + public void DeletingColumnCascadesToCardsAndCardTags() + { + using var db = new FreshDatabase(); + InsertBoardRow(db.Connection); + + SqliteTestHelper.Exec(db.Connection, "DELETE FROM columns WHERE id = 1;"); + + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;")); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM cards;")); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM card_tags;")); + // Tags are never cleaned up automatically. + Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;")); + } + + [TestMethod] + public void DeletingCardCascadesToCardTagsOnly() + { + using var db = new FreshDatabase(); + InsertBoardRow(db.Connection); + + SqliteTestHelper.Exec(db.Connection, "DELETE FROM cards WHERE id = 1;"); + + Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;")); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM cards;")); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM card_tags;")); + Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;")); + } + + [TestMethod] + public void DeletingTagRemovesAssignmentsButKeepsCards() + { + using var db = new FreshDatabase(); + InsertBoardRow(db.Connection); + + SqliteTestHelper.Exec(db.Connection, "DELETE FROM tags WHERE id = 1;"); + + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;")); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM card_tags;")); + // The card merely loses the tag. + Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM cards;")); + Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;")); + } + + /// + /// Inserts one column, one card, one tag and their assignment, ready for a cascade test. + /// + /// An open workspace-schema connection. + private static void InsertBoardRow(SqliteConnection 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);"); + } +} diff --git a/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs new file mode 100644 index 0000000..f7ad51f --- /dev/null +++ b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs @@ -0,0 +1,99 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; + +namespace YKanBan.Tests.TestUtilities; + +/// +/// Shared helpers for the storage tests: small SQL conveniences plus opening a +/// workspace database with the real migration list. +/// +public static class SqliteTestHelper +{ + /// + /// Executes a non-query SQL statement and returns the affected row count. + /// + /// An open database connection. + /// The statement to execute. + /// Name/value pairs bound as SQL parameters. + /// The number of affected rows. + 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(); + } + + /// + /// Returns the scalar result of a SQL query as a string. + /// + /// An open database connection. + /// The query to execute. + /// The scalar result cast to a string. + public static string ScalarString(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return (string)command.ExecuteScalar()!; + } + + /// + /// Returns the scalar result of a SQL query as a long. + /// + /// An open database connection. + /// The query to execute. + /// The scalar result cast to a long. + public static long ScalarLong(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return (long)command.ExecuteScalar()!; + } + + /// + /// Returns whether a table with the given name exists in the database. + /// + /// An open database connection. + /// The table name to look for. + /// when the table exists. + 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; + } + + /// + /// Returns the titles of all columns, ordered by id ascending. + /// + /// An open database connection. + /// The column titles in id order. + public static List 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(); + while (reader.Read()) + { + titles.Add(reader.GetString(0)); + } + return titles; + } + + /// + /// Opens a workspace database with the real workspace migration list. + /// + /// The workspace folder path. + /// An open, migrated connection. + public static SqliteConnection OpenWorkspace(string folderPath) + { + return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); + } +} diff --git a/YKanBan.Tests/TestUtilities/TempDirectory.cs b/YKanBan.Tests/TestUtilities/TempDirectory.cs new file mode 100644 index 0000000..e8017a2 --- /dev/null +++ b/YKanBan.Tests/TestUtilities/TempDirectory.cs @@ -0,0 +1,43 @@ +namespace YKanBan.Tests.TestUtilities; + +/// +/// Creates a unique temporary directory per test instance and removes it on +/// disposal. The removal is retried once because SQLite WAL sidecar files can +/// linger for a moment after the connections close. +/// +public sealed class TempDirectory : IDisposable +{ + /// + /// Gets the unique directory path managed by this instance. + /// + public string FullPath { get; } + + public TempDirectory() + { + FullPath = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(FullPath); + } + + /// + /// Deletes the directory tree, retrying once after a short delay. + /// + public void Dispose() + { + try + { + Directory.Delete(FullPath, recursive: true); + } + catch (IOException) + { + Thread.Sleep(100); + try + { + Directory.Delete(FullPath, recursive: true); + } + catch (IOException) + { + // Leftovers in the OS temp folder are harmless; never fail a test over cleanup. + } + } + } +} diff --git a/YKanBan.Tests/YKanBan.Tests.csproj b/YKanBan.Tests/YKanBan.Tests.csproj index 8e7c5a6..14553d9 100644 --- a/YKanBan.Tests/YKanBan.Tests.csproj +++ b/YKanBan.Tests/YKanBan.Tests.csproj @@ -2,6 +2,7 @@ net9.0 + enable enable false true diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx new file mode 100644 index 0000000..8e09503 --- /dev/null +++ b/YKanBan/Assets/Locales/Resources.resx @@ -0,0 +1,28 @@ + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + To Do + + + In Progress + + + Done + + diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx new file mode 100644 index 0000000..6eba378 --- /dev/null +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -0,0 +1,28 @@ + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 待办 + + + 进行中 + + + 已完成 + + diff --git a/YKanBan/Launching/LaunchArguments.cs b/YKanBan/Launching/LaunchArguments.cs new file mode 100644 index 0000000..e96d3e6 --- /dev/null +++ b/YKanBan/Launching/LaunchArguments.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +namespace YKanBan.Launching; + +/// +/// Parses raw command-line arguments. The only accepted form is a single, +/// non-blank workspace path (ykanban <path>); zero arguments, +/// more than one argument, or a blank argument is rejected as an argument +/// error. The path itself is not checked for existence here. +/// +public static class LaunchArguments +{ + /// + /// Tries to parse command-line arguments following the + /// ykanban <path> contract. + /// + /// The raw arguments, excluding the executable name. + /// The parsed workspace path on success, otherwise . + /// when exactly one non-blank path was supplied. + public static bool TryParse(IReadOnlyList arguments, [NotNullWhen(true)] out string? folderPath) + { + if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) + { + folderPath = arguments[0]; + return true; + } + + folderPath = null; + return false; + } +} diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs new file mode 100644 index 0000000..4b1ed52 --- /dev/null +++ b/YKanBan/Storage/AppData/AppConfig.cs @@ -0,0 +1,183 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json; + +namespace YKanBan.Storage.AppData; + +/// +/// Card ordering options. Every order falls back to id ascending as the final +/// tie-breaker. +/// +public enum CardSortOption +{ + /// Primary key ascending (default). + [EnumMember(Value = "id")] + Id, + + /// Creation time, oldest first. + [EnumMember(Value = "created-at")] + CreatedAt, + + /// Last modification time, most recently modified first. + [EnumMember(Value = "updated-at")] + UpdatedAt, + + /// Title by code points (BINARY collation); empty titles sort first. + [EnumMember(Value = "title")] + Title, +} + +/// +/// Column width presets. uses a user-defined pixel width. +/// +public enum ColumnWidthPreset +{ + /// Narrow preset. + [EnumMember(Value = "narrow")] + Narrow, + + /// Standard preset (320 px, the default). + [EnumMember(Value = "standard")] + Standard, + + /// Wide preset. + [EnumMember(Value = "wide")] + Wide, + + /// User-defined pixel width (160–720). + [EnumMember(Value = "custom")] + Custom, +} + +/// +/// UI theme selection. +/// +public enum ThemeOption +{ + /// Always light. + [EnumMember(Value = "light")] + Light, + + /// Always dark. + [EnumMember(Value = "dark")] + Dark, + + /// Follow the operating system setting (default). + [EnumMember(Value = "follow-system")] + FollowSystem, +} + +/// +/// Card ordering settings. The chosen order is global, not per workspace. +/// +public sealed class SortSettings +{ + /// + /// Gets or sets the order used by the board. + /// + [JsonProperty("card")] + public CardSortOption Card { get; set; } = CardSortOption.Id; +} + +/// +/// Board column width settings, applied globally. +/// +public sealed class ColumnWidthSettings +{ + /// + /// Gets or sets the active width preset. + /// + [JsonProperty("preset")] + public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard; + + /// + /// Gets or sets the manual width in pixels, used when + /// is (range 160–720). + /// + [JsonProperty("custom-pixels")] + public int CustomPixels { get; set; } = 320; +} + +/// +/// Confirmation-dialog toggles for the destructive actions. +/// +public sealed class ConfirmationSettings +{ + /// + /// Gets or sets whether deleting a card asks for confirmation. + /// + [JsonProperty("delete-card")] + public bool DeleteCard { get; set; } = true; + + /// + /// Gets or sets whether deleting a column asks for confirmation. + /// + [JsonProperty("delete-column")] + public bool DeleteColumn { get; set; } = true; + + /// + /// Gets or sets whether deleting a tag asks for confirmation. + /// + [JsonProperty("delete-tag")] + public bool DeleteTag { get; set; } = true; + + /// + /// Gets or sets whether discarding card edits asks for confirmation. + /// + [JsonProperty("discard-edit")] + public bool DiscardEdit { get; set; } = true; +} + +/// +/// Root object of app.json, the whole-file key/value configuration store. +/// Keys and enum values are serialized in kebab-case (declared explicitly via +/// and ). +/// The version field is the migration hook for future format changes. +/// +public sealed class AppConfig +{ + /// + /// Current configuration format version. + /// + public const int CurrentFormatVersion = 1; + + /// + /// Default UI language code. + /// + public const string DefaultLanguage = "en"; + + /// + /// Gets or sets the configuration format version of this file. + /// + [JsonProperty("version")] + public int Version { get; set; } = CurrentFormatVersion; + + /// + /// Gets or sets the UI language code ("en" default or "zh-Hans"). + /// + [JsonProperty("language")] + public string Language { get; set; } = DefaultLanguage; + + /// + /// Gets or sets the UI theme selection (default: follow the system). + /// + [JsonProperty("theme")] + public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem; + + /// + /// Gets or sets the card ordering settings. + /// + [JsonProperty("sort")] + public SortSettings Sort { get; set; } = new(); + + /// + /// Gets or sets the board column width settings. + /// + [JsonProperty("column-width")] + public ColumnWidthSettings ColumnWidth { get; set; } = new(); + + /// + /// Gets or sets the confirmation-dialog settings. + /// + [JsonProperty("confirmations")] + public ConfirmationSettings Confirmations { get; set; } = new(); +} diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs new file mode 100644 index 0000000..97e10ab --- /dev/null +++ b/YKanBan/Storage/AppData/AppConfigStore.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace YKanBan.Storage.AppData; + +/// +/// 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. +/// +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() }, + }; + + /// + /// Initializes a store bound to a specific app.json path. + /// + /// Full path of the app.json file to manage. + public AppConfigStore(string filePath) => FilePath = filePath; + + /// + /// Gets the full path of the managed app.json file. + /// + public string FilePath { get; } + + /// + /// Loads the configuration. A missing file yields defaults; a corrupt file + /// is backed up to app.json.bak and then defaults are used. + /// + /// The loaded configuration, or defaults when none could be read. + /// The file exists but cannot be read. + 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(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(); + } + } + + /// + /// Writes the configuration directly to disk (no atomic replace). + /// + /// The configuration to serialize. + public void Save(AppConfig config) + { + // Create the parent folder on demand; normally it already exists. + string? directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings)); + } +} diff --git a/YKanBan/Storage/AppData/AppDataPaths.cs b/YKanBan/Storage/AppData/AppDataPaths.cs new file mode 100644 index 0000000..40cdd1e --- /dev/null +++ b/YKanBan/Storage/AppData/AppDataPaths.cs @@ -0,0 +1,20 @@ +namespace YKanBan.Storage.AppData; + +/// +/// Well-known locations under the per-user application data folder +/// (%APPDATA%/YKanBan on Windows and the equivalent directory on other +/// platforms). +/// +public static class AppDataPaths +{ + /// + /// Gets the per-user YKanBan application data folder. + /// + public static string Directory => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "YKanBan"); + + /// + /// Gets the full path of app.json. + /// + public static string AppConfigJsonPath => Path.Combine(Directory, "app.json"); +} diff --git a/YKanBan/Storage/SchemaMigration.cs b/YKanBan/Storage/SchemaMigration.cs new file mode 100644 index 0000000..f9bc9cc --- /dev/null +++ b/YKanBan/Storage/SchemaMigration.cs @@ -0,0 +1,11 @@ +namespace YKanBan.Storage; + +/// +/// A single incremental schema step. Migrations form a contiguous list +/// starting at version 1; each step is applied when the stored +/// user_version is exactly one below . +/// +/// Target user_version after this migration is applied. +/// Short human-readable identifier used for diagnostics. +/// SQL script executed atomically together with the version bump. +public sealed record SchemaMigration(int Version, string Name, string Sql); diff --git a/YKanBan/Storage/SqliteDatabase.cs b/YKanBan/Storage/SqliteDatabase.cs new file mode 100644 index 0000000..0b5d973 --- /dev/null +++ b/YKanBan/Storage/SqliteDatabase.cs @@ -0,0 +1,126 @@ +using Microsoft.Data.Sqlite; + +namespace YKanBan.Storage; + +/// +/// Shared SQLite plumbing for workspace databases: opens a connection with +/// the mandated PRAGMA configuration (WAL journal, NORMAL synchronous mode, +/// foreign keys on) and applies incremental user_version migrations. +/// +public static class SqliteDatabase +{ + /// + /// Opens (creating if needed) a database file, applies the mandatory + /// PRAGMAs and runs any pending migrations. + /// + /// Path of the SQLite database file. + /// Contiguous migration list ordered by version, starting at 1. + /// An open connection with all PRAGMAs applied and the schema migrated. + public static SqliteConnection Open(string databasePath, IReadOnlyList migrations) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = databasePath, + Mode = SqliteOpenMode.ReadWriteCreate, + + // Each process holds its own single connection; a pool would only add noise. + Pooling = false, + }.ToString()); + + connection.Open(); + try + { + ApplyPragmas(connection); + ApplyMigrations(connection, migrations); + return connection; + } + catch + { + connection.Dispose(); + throw; + } + } + + /// + /// Reads the stored PRAGMA user_version value. + /// + /// An open database connection. + /// The stored schema version. + public static long ReadUserVersion(SqliteConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA user_version;"; + return (long)command.ExecuteScalar()!; + } + + /// + /// Applies the per-connection PRAGMAs. WAL is a persistent database + /// property, but the statement is harmless to repeat; foreign keys must be + /// enabled on every connection or the DDL cascades never fire. + /// + /// An open database connection. + internal static void ApplyPragmas(SqliteConnection connection) + { + // WAL plus NORMAL synchronous is the chosen durability/performance trade-off. + Execute(connection, "PRAGMA journal_mode=WAL;"); + Execute(connection, "PRAGMA synchronous=NORMAL;"); + Execute(connection, "PRAGMA foreign_keys=ON;"); + } + + /// + /// Runs every migration newer than the stored user_version, each in + /// its own transaction together with its version bump. + /// + /// An open database connection. + /// Contiguous migration list ordered by version, starting at 1. + /// The migration list is not contiguous from version 1. + /// The database was written by a newer build. + internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList migrations) + { + // Contract check: the list must be ordered, contiguous and start 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(); + } + } + + /// + /// Executes a non-query SQL script, optionally inside an explicit transaction. + /// + /// An open database connection. + /// The SQL script to execute. + /// The transaction to enlist in, when any. + private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null) + { + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = commandText; + command.ExecuteNonQuery(); + } +} diff --git a/YKanBan/Storage/StorageExceptions.cs b/YKanBan/Storage/StorageExceptions.cs new file mode 100644 index 0000000..4e6b69d --- /dev/null +++ b/YKanBan/Storage/StorageExceptions.cs @@ -0,0 +1,138 @@ +namespace YKanBan.Storage; + +/// +/// Base class for the typed exceptions raised by the storage layer. +/// Exception messages are always English and never localized; the UI maps the +/// exception type to a localized resource key instead. +/// +public abstract class YKanBanException : Exception +{ + /// + /// Initializes the exception with an English diagnostic message. + /// + /// The English diagnostic message. + /// The underlying cause, when any. + protected YKanBanException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} + +/// +/// Signals that the folder passed on the command line does not exist on disk. +/// +public sealed class WorkspaceDirectoryMissingException : YKanBanException +{ + /// + /// Gets the folder path that was expected to exist. + /// + public string FolderPath { get; } + + /// + /// Initializes the exception for a folder that is absent on disk. + /// + /// The missing folder path. + public WorkspaceDirectoryMissingException(string folderPath) + : base($"The folder does not exist: {folderPath}") + { + FolderPath = folderPath; + } +} + +/// +/// Signals that a folder exists but has no .ykanban structure, so it is not +/// (yet) a workspace. +/// +public sealed class WorkspaceNotInitializedException : YKanBanException +{ + /// + /// Gets the folder path that lacks the .ykanban structure. + /// + public string FolderPath { get; } + + /// + /// Initializes the exception for a folder without a .ykanban structure. + /// + /// The uninitialized folder path. + public WorkspaceNotInitializedException(string folderPath) + : base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}") + { + FolderPath = folderPath; + } +} + +/// +/// Signals that another instance already holds the workspace lock, so this +/// process must not touch the workspace. +/// +public sealed class WorkspaceLockException : YKanBanException +{ + /// + /// Gets the lock file that could not be acquired. + /// + public string LockFilePath { get; } + + /// + /// Gets the best-effort holder diagnostics read from the lock file, or + /// when they could not be read. + /// + public string? HolderDiagnostics { get; } + + /// + /// Initializes the exception for a failed lock acquisition. + /// + /// The lock file that is already held. + /// Diagnostics of the current holder, when readable. + /// The underlying I/O error. + public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException) + : base(BuildMessage(lockFilePath, holderDiagnostics), innerException) + { + LockFilePath = lockFilePath; + HolderDiagnostics = holderDiagnostics; + } + + /// + /// Composes the diagnostic message, appending holder details when they were readable. + /// + /// The lock file that is already held. + /// Diagnostics of the current holder, when readable. + /// The English diagnostic message. + 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; + } +} + +/// +/// Signals that the database was written by a newer build than the one +/// currently running, so its schema cannot be safely managed. +/// +public sealed class SchemaVersionException : YKanBanException +{ + /// + /// Gets the user_version value found in the database. + /// + public long StoredVersion { get; } + + /// + /// Gets the newest schema version this build understands. + /// + public int SupportedVersion { get; } + + /// + /// Initializes the exception for an unmanageable schema version. + /// + /// The user_version read from the database. + /// The newest schema version this build supports. + public SchemaVersionException(long storedVersion, int supportedVersion) + : base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.") + { + StoredVersion = storedVersion; + SupportedVersion = supportedVersion; + } +} diff --git a/YKanBan/Storage/UnixTime.cs b/YKanBan/Storage/UnixTime.cs new file mode 100644 index 0000000..da0e3f6 --- /dev/null +++ b/YKanBan/Storage/UnixTime.cs @@ -0,0 +1,13 @@ +namespace YKanBan.Storage; + +/// +/// Supplies the current Unix timestamp. Every timestamp persisted by YKanBan +/// is an INTEGER count of Unix seconds. +/// +public static class UnixTime +{ + /// + /// Gets the current UTC time expressed as Unix seconds. + /// + public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); +} diff --git a/YKanBan/Storage/Workspace/WorkspaceInitializer.cs b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs new file mode 100644 index 0000000..25a5307 --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs @@ -0,0 +1,45 @@ +using Microsoft.Data.Sqlite; + +namespace YKanBan.Storage.Workspace; + +/// +/// Creates the .ykanban structure for a folder: the folder itself and an +/// empty, fully migrated database. Adding preset content is deliberately a +/// separate concern handled by , so the +/// database can be created without any rows. +/// +public static class WorkspaceInitializer +{ + /// + /// Returns whether the folder contains a .ykanban structure. Only the + /// passed folder is checked; parent folders are never searched. + /// + /// The folder to inspect. + /// when .ykanban exists in the folder. + public static bool IsWorkspace(string folderPath) => Directory.Exists(WorkspacePaths.Root(folderPath)); + + /// + /// Initializes a fresh workspace: creates .ykanban and an empty migrated + /// database, without adding any preset content. + /// + /// The existing folder to initialize. + /// The folder does not exist on disk. + /// The workspace database already exists. + 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, then the migrated but still empty database. + Directory.CreateDirectory(WorkspacePaths.Root(folderPath)); + using SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations); + } +} diff --git a/YKanBan/Storage/Workspace/WorkspaceLock.cs b/YKanBan/Storage/Workspace/WorkspaceLock.cs new file mode 100644 index 0000000..b421770 --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceLock.cs @@ -0,0 +1,91 @@ +using System.Text; + +namespace YKanBan.Storage.Workspace; + +/// +/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle is +/// held until disposal, so crashes and power loss release it automatically. +/// The lock file also carries PID / machine name / time for human diagnostics. +/// +public sealed class WorkspaceLock : IDisposable +{ + private readonly FileStream _stream; + + /// + /// Initializes the lock around an already-acquired handle. + /// + /// The exclusively opened lock file stream. + /// Path of the lock file being held. + private WorkspaceLock(FileStream stream, string lockFilePath) + { + _stream = stream; + LockFilePath = lockFilePath; + } + + /// + /// Gets the path of the lock file being held. + /// + public string LockFilePath { get; } + + /// + /// Acquires the workspace lock, writing this process's diagnostics into it. + /// + /// The workspace folder to lock. + /// The held lock; dispose it to release. + /// The folder has no .ykanban structure. + /// Another instance already holds the lock. + public static WorkspaceLock Acquire(string folderPath) + { + string lockFilePath = WorkspacePaths.LockFile(folderPath); + FileStream stream; + try + { + // FileShare.None gives the exclusive semantics; FileMode.Create truncates 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); + } + + /// + /// Attempts to read holder diagnostics from a lock file we failed to + /// acquire. The read is best-effort: a live FileShare.None holder makes the + /// file unreadable, in which case is returned. + /// + /// The lock file to read. + /// The diagnostics text, or when unreadable. + private static string? ReadDiagnosticsBestEffort(string lockFilePath) + { + try + { + return File.ReadAllText(lockFilePath); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Releases the exclusive handle. The lock file itself is intentionally + /// left on disk; the OS releases the handle even on a crash. + /// + public void Dispose() => _stream.Dispose(); +} diff --git a/YKanBan/Storage/Workspace/WorkspacePaths.cs b/YKanBan/Storage/Workspace/WorkspacePaths.cs new file mode 100644 index 0000000..97e364e --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspacePaths.cs @@ -0,0 +1,44 @@ +namespace YKanBan.Storage.Workspace; + +/// +/// Well-known file and folder names inside a workspace's .ykanban structure, +/// plus helpers that combine them with a workspace folder path. +/// +public static class WorkspacePaths +{ + /// + /// Name of the workspace root folder created inside a managed project. + /// + public const string RootFolderName = ".ykanban"; + + /// + /// Name of the SQLite database inside .ykanban. + /// + public const string DatabaseFileName = "ykanban.db"; + + /// + /// Name of the exclusive lock file inside .ykanban. + /// + public const string LockFileName = "ykanban.lock"; + + /// + /// Returns the .ykanban root path for a workspace folder. + /// + /// The workspace folder path. + /// The path of the .ykanban folder. + public static string Root(string folderPath) => Path.Combine(folderPath, RootFolderName); + + /// + /// Returns the workspace database path for a workspace folder. + /// + /// The workspace folder path. + /// The path of ykanban.db. + public static string Database(string folderPath) => Path.Combine(Root(folderPath), DatabaseFileName); + + /// + /// Returns the workspace lock file path for a workspace folder. + /// + /// The workspace folder path. + /// The path of ykanban.lock. + public static string LockFile(string folderPath) => Path.Combine(Root(folderPath), LockFileName); +} diff --git a/YKanBan/Storage/Workspace/WorkspacePreset.cs b/YKanBan/Storage/Workspace/WorkspacePreset.cs new file mode 100644 index 0000000..db27551 --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspacePreset.cs @@ -0,0 +1,50 @@ +using Microsoft.Data.Sqlite; + +namespace YKanBan.Storage.Workspace; + +/// +/// Adds the initial workspace content. The three preset column titles are read +/// straight from the ResX resources in the UI language active when they are +/// added; they then become ordinary data that no longer follows language +/// switches. +/// +public static class WorkspacePreset +{ + /// + /// Inserts the three preset columns (the current language's equivalents of + /// To Do / In Progress / Done) in a single transaction. + /// + /// The initialized workspace folder. + /// The workspace database does not exist or a title collides with an existing column. + public static void AddPresetColumns(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 atomically so a partially populated 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(); + } +} diff --git a/YKanBan/Storage/Workspace/WorkspaceSchema.cs b/YKanBan/Storage/Workspace/WorkspaceSchema.cs new file mode 100644 index 0000000..4e91e22 --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceSchema.cs @@ -0,0 +1,54 @@ +namespace YKanBan.Storage.Workspace; + +/// +/// Schema definitions for the per-workspace database. A workspace holds a +/// single board with a columns → cards hierarchy plus tags and their +/// assignments. Data validity is expressed in DDL (CHECK / UNIQUE / FOREIGN +/// KEY) so every stored row is valid by construction, and cascading deletes +/// are covered by the same DDL. +/// +public static class WorkspaceSchema +{ + /// + /// Newest workspace schema version this build understands. + /// + public const int CurrentVersion = 1; + + /// + /// Ordered, contiguous migration list for the workspace database. + /// + public static readonly IReadOnlyList Migrations = + [ + new SchemaMigration(CurrentVersion, "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) + ); + """), + ]; +} diff --git a/YKanBan/YKanBan.csproj b/YKanBan/YKanBan.csproj index 1ba36d1..72cd205 100644 --- a/YKanBan/YKanBan.csproj +++ b/YKanBan/YKanBan.csproj @@ -2,6 +2,7 @@ WinExe net9.0 + enable enable true app.manifest @@ -9,7 +10,29 @@ - + + + + + + + + MSBuild:Compile + $(IntermediateOutputPath)Resources.Designer.cs + CSharp + YKanBan + Resources + true + YKanBan.Resources.resources + + + YKanBan.Resources.zh-Hans.resources +