diff --git a/YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs b/YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs new file mode 100644 index 0000000..11c28ae --- /dev/null +++ b/YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs @@ -0,0 +1,82 @@ +using System.Globalization; + +using Microsoft.Data.Sqlite; + +using YKanBan.Storage; +using YKanBan.ViewModels.ErrorPages; + +namespace YKanBan.Tests.ViewModels; + +/// +/// Tests for the open-failed page's exception-type → localized message mapping +/// and its English details. Mutates the global , +/// therefore marked do-not-parallelize. +/// +[TestClass] +[DoNotParallelize] +public class OpenFailedPageViewModelTests { + private const string Folder = "/work/repo"; + + private static void WithCulture(string language, Action action) { + CultureInfo? original = Resources.Culture; + try { + Resources.Culture = new CultureInfo(language); + action(); + } finally { + Resources.Culture = original; + } + } + + private static string Expected(string pattern, params object[] arguments) => + string.Format(Resources.Culture, pattern, [Folder, .. arguments]); + + [TestMethod] + [DataRow("en")] + [DataRow("zh-Hans")] + public void EachRecognizedExceptionTypeHasItsOwnMessage(string language) { + WithCulture(language, () => { + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_SchemaTooNew, 3L, 1), + OpenFailedPageViewModel.BuildMessage(Folder, new SchemaVersionException(3, 1))); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_DatabaseMissing), + OpenFailedPageViewModel.BuildMessage(Folder, new WorkspaceDatabaseMissingException("/work/repo/.ykanban/ykanban.db"))); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Database), + OpenFailedPageViewModel.BuildMessage(Folder, new SqliteException("file is not a database", 26))); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_AccessDenied), + OpenFailedPageViewModel.BuildMessage(Folder, new UnauthorizedAccessException())); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Io), + OpenFailedPageViewModel.BuildMessage(Folder, new IOException())); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Io), + OpenFailedPageViewModel.BuildMessage(Folder, new DirectoryNotFoundException())); + }); + } + + [TestMethod] + public void UnrecognizedExceptionGetsTheGenericMessage() { + WithCulture("en", () => { + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Generic), + OpenFailedPageViewModel.BuildMessage(Folder, new InvalidOperationException())); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Generic), + OpenFailedPageViewModel.BuildMessage(Folder, new WorkspaceDirectoryMissingException(Folder))); + }); + } + + [TestMethod] + public void DetailsCarryTheEnglishExceptionTextInAnyLanguage() { + WithCulture("zh-Hans", () => { + var exception = new SchemaVersionException(3, 1); + var page = new OpenFailedPageViewModel(Folder, exception); + + Assert.AreEqual(Folder, page.FolderPath); + StringAssert.Contains(page.Details, typeof(SchemaVersionException).FullName!); + StringAssert.Contains(page.Details, exception.Message); + }); + } +} diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index da721b8..c2c72d5 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -445,4 +445,28 @@ Exported to: {0} + + Cannot open workspace + + + The workspace database was created by a newer version of YKanBan (schema version {1}; this version supports up to {2}): {0} + + + The workspace database cannot be opened or is damaged: {0} + + + The workspace database (.ykanban/ykanban.db) is missing. Restore it from a backup together with its -wal and -shm files, or delete the .ykanban folder to initialize the workspace again: {0} + + + Access to the workspace was denied: {0} + + + A file system error occurred while opening the workspace: {0} + + + An unexpected error occurred while opening the workspace: {0} + + + Details + diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index a0fa746..aeee3f3 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -445,4 +445,28 @@ 已导出到:{0} + + 无法打开工作区 + + + 工作区数据库由更新版本的 YKanBan 创建(schema 版本 {1},当前版本最高支持 {2}):{0} + + + 工作区数据库无法打开或已损坏:{0} + + + 工作区数据库(.ykanban/ykanban.db)不存在。请从备份恢复(连同 -wal、-shm 文件),或删除 .ykanban 文件夹后重新初始化:{0} + + + 没有访问该工作区的权限:{0} + + + 打开工作区时发生文件系统错误:{0} + + + 打开工作区时发生意外错误:{0} + + + 详细信息 + diff --git a/YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs new file mode 100644 index 0000000..ddb7b45 --- /dev/null +++ b/YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs @@ -0,0 +1,63 @@ +using System.Globalization; + +using Microsoft.Data.Sqlite; + +using YKanBan.Storage; + +namespace YKanBan.ViewModels.ErrorPages; + +/// +/// The full-window page shown when a workspace cannot be opened or initialized: +/// a schema newer than this build, a missing, unopenable or damaged database, an +/// I/O or permission error, or any unrecognized failure. It shows a localized +/// message chosen by exception type plus the read-only, copyable English +/// exception details, and has no operations. +/// +public sealed class OpenFailedPageViewModel : ViewModelBase { + /// + /// Initializes the page for a failed open or initialization. + /// + /// The workspace folder. + /// The failure. + public OpenFailedPageViewModel(string folderPath, Exception exception) { + FolderPath = folderPath; + Message = BuildMessage(folderPath, exception); + + // Exception texts are English: ours by convention, .NET and SQLite ones by design. + Details = exception.ToString(); + } + + /// + /// Gets the workspace folder. + /// + public string FolderPath { get; } + + /// + /// Gets the localized explanatory message. + /// + public string Message { get; } + + /// + /// Gets the English exception details for diagnosis. + /// + public string Details { get; } + + /// + /// Maps the exception type to its localized message; unrecognized types get the generic one. + /// + /// The workspace folder. + /// The failure. + /// The localized message. + public static string BuildMessage(string folderPath, Exception exception) { + CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentCulture; + return exception switch { + SchemaVersionException schema => string.Format( + culture, Resources.Error_OpenFailed_SchemaTooNew, folderPath, schema.StoredVersion, schema.SupportedVersion), + WorkspaceDatabaseMissingException => string.Format(culture, Resources.Error_OpenFailed_DatabaseMissing, folderPath), + SqliteException => string.Format(culture, Resources.Error_OpenFailed_Database, folderPath), + UnauthorizedAccessException => string.Format(culture, Resources.Error_OpenFailed_AccessDenied, folderPath), + IOException => string.Format(culture, Resources.Error_OpenFailed_Io, folderPath), + _ => string.Format(culture, Resources.Error_OpenFailed_Generic, folderPath), + }; + } +} diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs index 1d352c4..ad4f142 100644 --- a/YKanBan/ViewModels/MainWindowViewModel.cs +++ b/YKanBan/ViewModels/MainWindowViewModel.cs @@ -80,6 +80,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase { Content = new NotInitializedPageViewModel(folderPath, InitializeWorkspace); } catch (WorkspaceLockException) { Content = new LockConflictPageViewModel(folderPath); + } catch (Exception exception) { + // Newer schema, damaged database, I/O or permission errors, and anything unrecognized. + Debug.WriteLine(exception); + Content = new OpenFailedPageViewModel(folderPath, exception); } } @@ -95,7 +99,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase { /// The workspace folder. /// The opened workspace session. private void OpenWorkspace(string folderPath, WorkspaceSession session) { - Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout); + try { + Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout); + } catch { + // The first board read failed: the caller shows an error page, which holds no workspace. + _services.CloseWorkspace(); + throw; + } + Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}"; } diff --git a/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml new file mode 100644 index 0000000..ec14b6a --- /dev/null +++ b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs new file mode 100644 index 0000000..593abe2 --- /dev/null +++ b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace YKanBan.Views.ErrorPages; + +public partial class OpenFailedPageView : UserControl { + public OpenFailedPageView() { + InitializeComponent(); + } +}