mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-05-08 23:54:10 -04:00
#1776. better messages to users. improve all exception display
This commit is contained in:
@@ -76,11 +76,8 @@ public class LibationContextFactory
|
||||
{
|
||||
// Match LibationFileManager.Configuration.IsLinux (OperatingSystem.IsLinux); avoid referencing that project from DataLayer.
|
||||
var linuxSection = OperatingSystem.IsLinux()
|
||||
? "\n\nOn Linux: check ownership and permissions on that folder (for example chmod/chown). Snap installs often store data under ~/snap/libation/<revision>/.local/share/Libation — that entire tree must be writable."
|
||||
: "";
|
||||
var snapHint = OperatingSystem.IsLinux()
|
||||
? ", or use the non-Snap build if Snap confinement is blocking writes"
|
||||
: "";
|
||||
? "\n\nOn Linux: check ownership and permissions on that folder (chmod/chown). Include LibationContext.db-wal and LibationContext.db-shm if they exist. Snap data is often under ~/snap/libation/<revision>/.local/share/Libation — that entire tree must be writable.\n\nIf Libation will not start, set environment variable LIBATION_FILES_DIR to an existing writable directory you own, then launch again. After Libation starts, you can also change the Libation Files folder in Settings.\n\nIf this persists on Snap and permissions look correct, try the non-Snap build to rule out confinement blocking writes."
|
||||
: "\n\nAfter Libation starts, you can change the Libation Files folder in Settings. If Libation will not start, set environment variable LIBATION_FILES_DIR to an existing writable directory you own, then launch again.";
|
||||
|
||||
return new InvalidOperationException(
|
||||
$"""
|
||||
@@ -89,9 +86,9 @@ public class LibationContextFactory
|
||||
Database path:
|
||||
{sqliteDatabaseFilePath}
|
||||
|
||||
This usually means the folder or the database file is not writable by your user (wrong owner or permissions), or the location is on a read-only or restricted filesystem.{linuxSection}
|
||||
This path is the library database (metadata and local settings), not your audiobook storage folder.
|
||||
|
||||
If the problem continues, try moving the Libation Files location (Settings) to a folder you know is writable{snapHint}.
|
||||
This usually means the folder or database file is not writable by your user (wrong owner or permissions), or the location is on a read-only or restricted filesystem.{linuxSection}
|
||||
""",
|
||||
ex);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
@@ -24,7 +25,7 @@ public partial class MessageBoxAlertAdminDialog : DialogWindow
|
||||
{
|
||||
ErrorDescription = text;
|
||||
this.Title = caption;
|
||||
ExceptionMessage = $"{exception.Message}\r\n\r\n{exception.StackTrace}";
|
||||
ExceptionMessage = ExceptionDisplay.FormatMessageAndStackTrace(exception);
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
|
||||
68
Source/LibationUiBase/ExceptionDisplay.cs
Normal file
68
Source/LibationUiBase/ExceptionDisplay.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>Formats exceptions for read-only UI (crash dialog, etc.).</summary>
|
||||
public static class ExceptionDisplay
|
||||
{
|
||||
const int FirstInnerCount = 10;
|
||||
const int TailInnerCount = 2;
|
||||
/// <summary>Cap when walking <see cref="Exception.InnerException"/> (pathological chains).</summary>
|
||||
const int MaxInnerCollect = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Primary message, nested inner messages (first 10, then optional omission count, then deepest 2 when the chain is longer than 10), then the outer stack trace.
|
||||
/// </summary>
|
||||
public static string FormatMessageAndStackTrace(Exception exception)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(exception.Message);
|
||||
|
||||
var inners = new List<Exception>();
|
||||
for (var inner = exception.InnerException; inner is not null && inners.Count < MaxInnerCollect; inner = inner.InnerException)
|
||||
inners.Add(inner);
|
||||
|
||||
var n = inners.Count;
|
||||
if (n > 0)
|
||||
{
|
||||
if (n <= FirstInnerCount)
|
||||
{
|
||||
foreach (var ex in inners)
|
||||
AppendInner(sb, ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < FirstInnerCount; i++)
|
||||
AppendInner(sb, inners[i]);
|
||||
|
||||
var omitted = n - FirstInnerCount - TailInnerCount;
|
||||
if (omitted > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.Append(omitted);
|
||||
sb.Append(" inner exception");
|
||||
if (omitted != 1)
|
||||
sb.Append('s');
|
||||
sb.AppendLine(" omitted.");
|
||||
}
|
||||
|
||||
var startTail = Math.Max(FirstInnerCount, n - TailInnerCount);
|
||||
for (var i = startTail; i < n; i++)
|
||||
AppendInner(sb, inners[i]);
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.Append(exception.StackTrace);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendInner(StringBuilder sb, Exception ex)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.Append("Inner exception: ");
|
||||
sb.AppendLine(ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
@@ -20,7 +21,7 @@ public partial class MessageBoxAlertAdminDialog : Form
|
||||
{
|
||||
this.descriptionLbl.Text = text;
|
||||
this.Text = caption;
|
||||
this.exceptionTb.Text = $"{exception.Message}\r\n\r\n{exception.StackTrace}";
|
||||
this.exceptionTb.Text = ExceptionDisplay.FormatMessageAndStackTrace(exception);
|
||||
}
|
||||
|
||||
private void MessageBoxAlertAdminDialog_Load(object sender, EventArgs e)
|
||||
|
||||
183
Source/_Tests/LibationUiBase.Tests/ExceptionDisplayTests.cs
Normal file
183
Source/_Tests/LibationUiBase.Tests/ExceptionDisplayTests.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
namespace LibationUiBase.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ExceptionDisplayTests
|
||||
{
|
||||
/// <summary>Outer exception with a fixed <see cref="Exception.StackTrace"/> so golden-string tests are stable.</summary>
|
||||
private sealed class ExceptionWithFixedStack : Exception
|
||||
{
|
||||
private string _fixedStack { get; }
|
||||
public ExceptionWithFixedStack(string message, Exception? innerException, string fixedStack)
|
||||
: base(message, innerException) => _fixedStack = fixedStack;
|
||||
public override string? StackTrace => _fixedStack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Golden output for <see cref="ExceptionDisplay.FormatMessageAndStackTrace"/> when there are fourteen
|
||||
/// <see cref="Exception.InnerException"/> links (fifteen messages: outer L0 … deepest L14): first ten inners,
|
||||
/// two omitted (L11–L12), then the two deepest (L13–L14), then the outer stack trace.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void _FullText_14Levels()
|
||||
{
|
||||
const string stack = "<<GOLDEN-TEST-STACK-TRACE>>";
|
||||
var messages = Enumerable.Range(0, 15).Select(i => $"L{i}").ToArray();
|
||||
var ex = CreateChainWithFixedStack(stack, messages);
|
||||
|
||||
const string expected = """
|
||||
L0
|
||||
|
||||
Inner exception: L1
|
||||
|
||||
Inner exception: L2
|
||||
|
||||
Inner exception: L3
|
||||
|
||||
Inner exception: L4
|
||||
|
||||
Inner exception: L5
|
||||
|
||||
Inner exception: L6
|
||||
|
||||
Inner exception: L7
|
||||
|
||||
Inner exception: L8
|
||||
|
||||
Inner exception: L9
|
||||
|
||||
Inner exception: L10
|
||||
|
||||
2 inner exceptions omitted.
|
||||
|
||||
Inner exception: L13
|
||||
|
||||
Inner exception: L14
|
||||
|
||||
<<GOLDEN-TEST-STACK-TRACE>>
|
||||
""";
|
||||
Assert.AreEqual(expected.ReplaceLineEndings("\n"), Format(ex));
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CreateChain"/>
|
||||
/// <remarks>Same chain shape as <see cref="CreateChain"/>, but the outer exception reports <paramref name="stackTrace"/> from <see cref="Exception.StackTrace"/>.</remarks>
|
||||
private static Exception CreateChainWithFixedStack(string stackTrace, params string[] messages)
|
||||
{
|
||||
if (messages.Length == 0)
|
||||
throw new ArgumentException("At least one message is required.", nameof(messages));
|
||||
|
||||
var innermost = new Exception(messages[^1]);
|
||||
for (var i = messages.Length - 2; i >= 1; i--)
|
||||
innermost = new Exception(messages[i], innermost);
|
||||
return new ExceptionWithFixedStack(messages[0], innermost, stackTrace);
|
||||
}
|
||||
|
||||
/// <summary>messages[0] = outer; each following string is the next <see cref="Exception.InnerException"/> message.</summary>
|
||||
private static Exception CreateChain(params string[] messages)
|
||||
{
|
||||
if (messages.Length == 0)
|
||||
throw new ArgumentException("At least one message is required.", nameof(messages));
|
||||
|
||||
var innermost = new Exception(messages[^1]);
|
||||
for (var i = messages.Length - 2; i >= 0; i--)
|
||||
innermost = new Exception(messages[i], innermost);
|
||||
return innermost;
|
||||
}
|
||||
|
||||
private static string Format(Exception ex) => ExceptionDisplay.FormatMessageAndStackTrace(ex).ReplaceLineEndings("\n");
|
||||
|
||||
[TestMethod]
|
||||
public void NoInnerException_ContainsOuterMessageAndStack()
|
||||
{
|
||||
var ex = new Exception("outer only");
|
||||
var text = Format(ex);
|
||||
|
||||
Assert.StartsWith("outer only\n", text);
|
||||
Assert.IsFalse(text.Contains("Inner exception:", StringComparison.Ordinal));
|
||||
if (ex.StackTrace is not null)
|
||||
Assert.IsTrue(text.Contains(ex.StackTrace, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OneInner_ShowsInnerLine()
|
||||
{
|
||||
var ex = CreateChain("outer", "inner-a");
|
||||
var text = Format(ex);
|
||||
|
||||
var expectedHead = """
|
||||
outer
|
||||
|
||||
Inner exception: inner-a
|
||||
|
||||
|
||||
""".ReplaceLineEndings("\n");
|
||||
Assert.AreEqual(expectedHead + ex.StackTrace, text);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TenInners_ShowsAllTen()
|
||||
{
|
||||
var messages = new[] { "m0", "m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "m10" };
|
||||
var ex = CreateChain(messages);
|
||||
var text = Format(ex);
|
||||
|
||||
for (var i = 1; i <= 10; i++)
|
||||
Assert.IsTrue(text.Contains($"Inner exception: m{i}\n", StringComparison.Ordinal), $"missing inner m{i}");
|
||||
Assert.IsFalse(text.Contains("omitted", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ElevenInners_FirstTenThenDeepestOnly_NoOmitLine()
|
||||
{
|
||||
var messages = Enumerable.Range(0, 12).Select(i => $"n{i}").ToArray();
|
||||
var ex = CreateChain(messages);
|
||||
var text = Format(ex);
|
||||
|
||||
for (var i = 1; i <= 10; i++)
|
||||
Assert.IsTrue(text.Contains($"Inner exception: n{i}\n", StringComparison.Ordinal));
|
||||
Assert.IsFalse(text.Contains("omitted", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(text.Contains("Inner exception: n11\n", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TwelveInners_FirstTenThenLastTwo_NoOmitLine()
|
||||
{
|
||||
var messages = Enumerable.Range(0, 13).Select(i => $"p{i}").ToArray();
|
||||
var ex = CreateChain(messages);
|
||||
var text = Format(ex);
|
||||
|
||||
for (var i = 1; i <= 10; i++)
|
||||
Assert.IsTrue(text.Contains($"Inner exception: p{i}\n", StringComparison.Ordinal));
|
||||
Assert.IsFalse(text.Contains("omitted", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(text.Contains("Inner exception: p11\n", StringComparison.Ordinal));
|
||||
Assert.IsTrue(text.Contains("Inner exception: p12\n", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ThirteenInners_FirstTen_OmitOne_ThenLastTwo()
|
||||
{
|
||||
var messages = Enumerable.Range(0, 14).Select(i => $"q{i}").ToArray();
|
||||
var ex = CreateChain(messages);
|
||||
var text = Format(ex);
|
||||
|
||||
for (var i = 1; i <= 10; i++)
|
||||
Assert.IsTrue(text.Contains($"Inner exception: q{i}\n", StringComparison.Ordinal));
|
||||
Assert.IsTrue(text.Contains("1 inner exception omitted.\n", StringComparison.Ordinal));
|
||||
Assert.IsFalse(text.Contains("Inner exception: q11\n", StringComparison.Ordinal), "omitted inner should not appear");
|
||||
Assert.IsTrue(text.Contains("Inner exception: q12\n", StringComparison.Ordinal));
|
||||
Assert.IsTrue(text.Contains("Inner exception: q13\n", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ManyInners_OmitCountPlural()
|
||||
{
|
||||
var depth = 20;
|
||||
var messages = Enumerable.Range(0, depth + 1).Select(i => $"x{i}").ToArray();
|
||||
var ex = CreateChain(messages);
|
||||
var text = Format(ex);
|
||||
|
||||
var omitted = depth - 10 - 2;
|
||||
Assert.IsTrue(text.Contains($"{omitted} inner exceptions omitted.\n", StringComparison.Ordinal));
|
||||
Assert.IsTrue(text.Contains($"Inner exception: x{depth - 1}\n", StringComparison.Ordinal));
|
||||
Assert.IsTrue(text.Contains($"Inner exception: x{depth}\n", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user