diff --git a/build-installer.ps1 b/build-installer.ps1
index 10f565c..c607264 100644
--- a/build-installer.ps1
+++ b/build-installer.ps1
@@ -24,8 +24,12 @@ if ($LASTEXITCODE -ne 0) { throw 'publish failed' }
$license = (Get-Content installer\license-notice.md -Raw) + "`n`n---`n`n" + (Get-Content LICENSE -Raw)
Set-Content artifacts\license.md $license -Encoding utf8
+# The release notes go into the update feed: the app shows them before it installs the update.
+$notes = "release-notes\v$Version.md"
+$notesArgs = if (Test-Path $notes) { @('--releaseNotes', $notes) } else { Write-Host "No $notes; the update has no notes"; @() }
+
# --framework: the installer installs the .NET desktop runtime first when the PC does not have it.
-dotnet vpk pack `
+dotnet vpk pack @notesArgs `
--packId OrthoStreamXP.App `
--packVersion $Version `
--packTitle OrthoStreamXP `
diff --git a/release-notes/README.md b/release-notes/README.md
index 3589514..9bdc8bc 100644
--- a/release-notes/README.md
+++ b/release-notes/README.md
@@ -4,6 +4,9 @@ One Markdown file per release, named after its tag: `v1.2.0.md` for the tag `v1.
When the tag is pushed, the [release workflow](../.github/workflows/release.yml) puts the file's text at the top of
the GitHub Release. GitHub's generated list of changes (merged pull requests, new contributors) follows it.
+`build-installer.ps1` also packs the file into the update feed: the app shows it in its update window before
+installing (headings, bullets, paragraphs and **bold**; links show their text only). For an update packed without
+notes, the app reads them from the GitHub Release instead, up to GitHub's generated "What's Changed" list.
## Releasing
diff --git a/src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs b/src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs
new file mode 100644
index 0000000..1c68bbb
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Home/ReleaseNotes.cs
@@ -0,0 +1,111 @@
+using System.Text;
+
+namespace OrthoStreamXP.Gui;
+
+/// What a line of the release notes is.
+internal enum NoteKind
+{
+ Heading,
+ Bullet,
+ Paragraph,
+}
+
+/// A piece of a line's text, bold or not.
+internal sealed record NoteSpan(string Text, bool Bold);
+
+/// One heading, bullet or paragraph of the release notes.
+internal sealed record NoteBlock(NoteKind Kind, IReadOnlyList Spans)
+{
+ public string Text => string.Concat(Spans.Select(s => s.Text));
+}
+
+///
+/// Turns the release notes' Markdown (release-notes/vX.Y.Z.md: headings, bullets, paragraphs, bold) into blocks the update
+/// window shows. Links keep their text, inline code its content; anything else is shown as written.
+///
+internal static class ReleaseNotes
+{
+ public static IReadOnlyList Parse(string? markdown)
+ {
+ var blocks = new List();
+ if (string.IsNullOrWhiteSpace(markdown)) return blocks;
+
+ var paragraph = new StringBuilder();
+ void EndParagraph()
+ {
+ if (paragraph.Length == 0) return;
+ blocks.Add(new NoteBlock(NoteKind.Paragraph, Inline(paragraph.ToString())));
+ paragraph.Clear();
+ }
+
+ foreach (string raw in markdown.ReplaceLineEndings("\n").Split('\n'))
+ {
+ string line = raw.Trim();
+ if (line.Length == 0 || IsRule(line))
+ {
+ EndParagraph();
+ }
+ else if (line.StartsWith('#'))
+ {
+ EndParagraph();
+ blocks.Add(new NoteBlock(NoteKind.Heading, Inline(line.TrimStart('#').Trim())));
+ }
+ else if (line.Length > 1 && line[0] is '-' or '*' or '+' && line[1] == ' ')
+ {
+ EndParagraph();
+ blocks.Add(new NoteBlock(NoteKind.Bullet, Inline(line[2..].Trim())));
+ }
+ else if (blocks.Count > 0 && blocks[^1].Kind == NoteKind.Bullet && paragraph.Length == 0 && char.IsWhiteSpace(raw[0]))
+ {
+ // An indented line goes on with the bullet above it.
+ var bullet = blocks[^1];
+ blocks[^1] = bullet with { Spans = [.. bullet.Spans, .. Inline(" " + line)] };
+ }
+ else
+ {
+ if (paragraph.Length > 0) paragraph.Append(' ');
+ paragraph.Append(line);
+ }
+ }
+ EndParagraph();
+ return blocks;
+ }
+
+ /// A horizontal rule: ---, *** or ___.
+ private static bool IsRule(string line) => line.Length >= 3 && line[0] is '-' or '*' or '_' && line.All(c => c == line[0]);
+
+ /// Splits a line at its **bold** markers, and drops the link and code markup.
+ private static List Inline(string text)
+ {
+ var spans = new List();
+ string[] parts = Plain(text).Split("**");
+ for (int i = 0; i < parts.Length; i++)
+ {
+ // An unclosed ** stays as written.
+ bool bold = i % 2 == 1 && i < parts.Length - 1;
+ string part = i % 2 == 1 && !bold ? "**" + parts[i] : parts[i];
+ if (part.Length > 0) spans.Add(new NoteSpan(part, bold));
+ }
+ return spans;
+ }
+
+ /// [text](url) becomes text, `code` becomes code.
+ private static string Plain(string text)
+ {
+ var plain = new StringBuilder(text.Length);
+ for (int i = 0; i < text.Length; i++)
+ {
+ if (text[i] == '[' && text.IndexOf(']', i) is var close and > 0 && close + 1 < text.Length && text[close + 1] == '('
+ && text.IndexOf(')', close) is var end and > 0)
+ {
+ plain.Append(text, i + 1, close - i - 1);
+ i = end;
+ }
+ else if (text[i] != '`')
+ {
+ plain.Append(text[i]);
+ }
+ }
+ return plain.ToString();
+ }
+}
diff --git a/src/OrthoStreamXP/Gui/Home/UpdateOffer.cs b/src/OrthoStreamXP/Gui/Home/UpdateOffer.cs
new file mode 100644
index 0000000..bae0dc6
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Home/UpdateOffer.cs
@@ -0,0 +1,4 @@
+namespace OrthoStreamXP.Gui;
+
+/// A downloaded new version, as the update window shows it before the person installs it.
+internal sealed record UpdateOffer(string Version, string? CurrentVersion, IReadOnlyList Notes, string ReleasePage);
diff --git a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs
index dfcc866..d4b82b3 100644
--- a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs
+++ b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs
@@ -13,6 +13,11 @@ public MainWindow(MainViewModel main)
_main = main;
DataContext = main;
InitializeComponent();
+ // The Home page's Update button: the release notes first, installed only when confirmed.
+ _main.Home.UpdateOffered += (_, offer) =>
+ {
+ if (new UpdateDialog(offer) { Owner = this }.ShowDialog() == true) _main.Home.InstallConfirmedUpdate();
+ };
}
/// Closing while the scenery is mounted asks first, then unmounts before the window goes.
diff --git a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
index bbcb12c..a8726b8 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
@@ -45,8 +45,8 @@ public HomeViewModel(MainViewModel main)
ShowReviewOf(null);
});
_timer.Tick += (_, _) => Update();
- // Installed only when chosen, and only while the scenery is not streaming: OrthoStreamXP restarts to do it.
- InstallUpdate = new RelayCommand(_updates.ApplyAndRestart, () => _updates.Ready is not null && Mount.State == MountState.Stopped);
+ // Installed only when chosen (after the release notes), and only while the scenery is not streaming: OrthoStreamXP restarts to do it.
+ InstallUpdate = new RelayCommand(OfferUpdate, () => _updates.Ready is not null && Mount.State == MountState.Stopped);
_updateTimer.Tick += async (_, _) =>
{
_updateTimer.Interval = TimeSpan.FromHours(6);
@@ -97,8 +97,23 @@ public HomeViewModel(MainViewModel main)
public ICommand OpenLog { get; }
public ICommand RestartMount { get; }
- /// Installs the downloaded version and restarts (only while the streaming is stopped).
+ /// Shows the downloaded version's release notes, to install it from there (only while the streaming is stopped).
public ICommand InstallUpdate { get; }
+
+ /// The person asked to install the downloaded version: the window shows its notes and asks to confirm.
+ public event EventHandler? UpdateOffered;
+
+ private void OfferUpdate()
+ {
+ if (_updates.ReadyVersion is not { } version) return;
+ UpdateOffered?.Invoke(this, new UpdateOffer(version, _updates.CurrentVersion, ReleaseNotes.Parse(_updates.ReadyNotes), AppUpdates.ReleasePage(version)));
+ }
+
+ /// Installs the downloaded version and restarts, once confirmed; not while the scenery streams.
+ public void InstallConfirmedUpdate()
+ {
+ if (Mount.State == MountState.Stopped) _updates.ApplyAndRestart();
+ }
public ICommand ApplyReview { get; }
public ICommand DismissReview { get; }
@@ -535,8 +550,8 @@ private List BuildNotices()
if (_updates.ReadyVersion is { } version)
notices.Add(Mount.State == MountState.Stopped
? new Notice(Severity.Info, $"OrthoStreamXP {version} is ready to install",
- $"A new version was downloaded (you have {_updates.CurrentVersion}). OrthoStreamXP restarts to install it; your settings, cache and flight history stay.",
- "Update and restart", InstallUpdate)
+ $"A new version was downloaded (you have {_updates.CurrentVersion}). See what's new and install it when you choose; your settings, cache and flight history stay.",
+ "Update…", InstallUpdate)
: new Notice(Severity.Info, $"OrthoStreamXP {version} is ready to install",
$"A new version was downloaded (you have {_updates.CurrentVersion}). Stop the streaming when you have finished flying, and install it from here."));
if (_xplaneProblem is not null) notices.Add(_xplaneProblem);
diff --git a/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml b/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml
new file mode 100644
index 0000000..07de185
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml.cs b/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml.cs
new file mode 100644
index 0000000..3c80ddf
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Views/UpdateDialog.xaml.cs
@@ -0,0 +1,53 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+
+namespace OrthoStreamXP.Gui;
+
+/// Asks before installing a downloaded version, with its release notes: true when the person chose to update.
+internal sealed partial class UpdateDialog
+{
+ private readonly UpdateOffer _offer;
+
+ public UpdateDialog(UpdateOffer offer)
+ {
+ _offer = offer;
+ InitializeComponent();
+ Heading.Text = $"OrthoStreamXP {offer.Version} is ready to install";
+ Intro.Text = (offer.CurrentVersion is null ? "" : $"You have {offer.CurrentVersion}. ")
+ + "OrthoStreamXP closes, installs the update and starts again. Your settings, cache and flight history stay.";
+ ReleasePageLink.ToolTip = offer.ReleasePage;
+ if (offer.Notes.Count == 0) NoNotes.Visibility = Visibility.Visible;
+ for (int i = 0; i < offer.Notes.Count; i++) Notes.Children.Add(Show(offer.Notes[i], first: i == 0));
+ }
+
+ private static FrameworkElement Show(NoteBlock block, bool first)
+ {
+ var text = new TextBlock { TextWrapping = TextWrapping.Wrap };
+ foreach (NoteSpan span in block.Spans)
+ text.Inlines.Add(new Run(span.Text) { FontWeight = span.Bold ? FontWeights.SemiBold : FontWeights.Normal });
+
+ switch (block.Kind)
+ {
+ case NoteKind.Heading:
+ text.FontSize = 16;
+ text.FontWeight = FontWeights.SemiBold;
+ text.Margin = new Thickness(0, first ? 0 : 16, 0, 2);
+ return text;
+ case NoteKind.Bullet:
+ var row = new DockPanel { Margin = new Thickness(4, first ? 0 : 6, 0, 0) };
+ var dot = new TextBlock { Text = "•", Margin = new Thickness(0, 0, 10, 0) };
+ DockPanel.SetDock(dot, Dock.Left);
+ row.Children.Add(dot);
+ row.Children.Add(text);
+ return row;
+ default:
+ text.Margin = new Thickness(0, first ? 0 : 8, 0, 0);
+ return text;
+ }
+ }
+
+ private void Confirm(object sender, RoutedEventArgs e) => DialogResult = true;
+
+ private void OpenReleasePage(object sender, RoutedEventArgs e) => Shell.Open(_offer.ReleasePage);
+}
diff --git a/src/OrthoStreamXP/Program.cs b/src/OrthoStreamXP/Program.cs
index 780d9b9..da212ac 100644
--- a/src/OrthoStreamXP/Program.cs
+++ b/src/OrthoStreamXP/Program.cs
@@ -2,7 +2,11 @@
using OrthoStreamXP.Gui;
// The installer's hooks first: on install, update or uninstall Velopack runs them and ends the process here.
-Velopack.VelopackApp.Build().OnBeforeUninstallFastCallback(_ => OrthoStreamXP.Setup.Installation.BeforeUninstall()).Run();
+// A downloaded update is not installed at start (Velopack's default): only when the person chooses it on the Home page.
+Velopack.VelopackApp.Build()
+ .SetAutoApplyOnStartup(false)
+ .OnBeforeUninstallFastCallback(_ => OrthoStreamXP.Setup.Installation.BeforeUninstall())
+ .Run();
// The log, the settings file and the window's English text all use one number and date format, whatever
// the PC's regional settings (WPF needs real culture data, so invariant globalization is not an option).
diff --git a/src/OrthoStreamXP/Setup/Installation/AppUpdates.cs b/src/OrthoStreamXP/Setup/Installation/AppUpdates.cs
index fa2c1b5..5ab6517 100644
--- a/src/OrthoStreamXP/Setup/Installation/AppUpdates.cs
+++ b/src/OrthoStreamXP/Setup/Installation/AppUpdates.cs
@@ -14,7 +14,11 @@ internal sealed class AppUpdates
/// Where the releases are: the project's GitHub page (betas included, as the first releases are).
public const string Repository = "https://github.com/flysimdev/OrthoStreamXP";
+ /// For the release notes of versions whose update carries none; GitHub asks for a user agent.
+ private static readonly HttpClient Http = CreateHttpClient();
+
private readonly UpdateManager? _manager;
+ private readonly GithubReleaseNotes _githubNotes = new(Http, Repository["https://github.com/".Length..]);
private bool _checking;
public AppUpdates()
@@ -38,6 +42,15 @@ public AppUpdates()
public string? ReadyVersion => Ready?.TargetFullRelease.Version.ToString();
+ ///
+ /// The downloaded version's release notes (Markdown, from release-notes/vX.Y.Z.md): from its update, else from its GitHub
+ /// Release; null when neither has any.
+ ///
+ public string? ReadyNotes { get; private set; }
+
+ /// The GitHub Release page of a version.
+ public static string ReleasePage(string version) => $"{Repository}/releases/tag/v{version}";
+
/// Looks for a newer version and downloads it; true when one became ready. Problems (offline, GitHub busy) are only logged.
public async Task CheckAsync()
{
@@ -48,6 +61,10 @@ public async Task CheckAsync()
if (await _manager.CheckForUpdatesAsync().ConfigureAwait(true) is not { } update) return false;
Log.Info($"OrthoStreamXP {update.TargetFullRelease.Version} is available (this is {CurrentVersion}); downloading it");
await _manager.DownloadUpdatesAsync(update).ConfigureAwait(true);
+ string? notes = update.TargetFullRelease.NotesMarkdown;
+ ReadyNotes = string.IsNullOrWhiteSpace(notes)
+ ? await _githubNotes.FetchAsync(update.TargetFullRelease.Version.ToString(), CancellationToken.None).ConfigureAwait(true)
+ : notes;
Ready = update;
Log.Step($"OrthoStreamXP {ReadyVersion} is downloaded and ready to install");
return true;
@@ -63,6 +80,14 @@ public async Task CheckAsync()
}
}
+ private static HttpClient CreateHttpClient()
+ {
+ var http = new HttpClient();
+ string version = typeof(AppUpdates).Assembly.GetName().Version?.ToString(3) ?? "0";
+ http.DefaultRequestHeaders.UserAgent.ParseAdd($"orthostreamxp/{version}");
+ return http;
+ }
+
/// Installs the downloaded version and starts OrthoStreamXP again (this process ends).
public void ApplyAndRestart()
{
diff --git a/src/OrthoStreamXP/Setup/Installation/GithubReleaseNotes.cs b/src/OrthoStreamXP/Setup/Installation/GithubReleaseNotes.cs
new file mode 100644
index 0000000..3707f51
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Installation/GithubReleaseNotes.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+
+namespace OrthoStreamXP.Setup;
+
+///
+/// A version's release notes from its GitHub Release, for updates whose feed carries none (versions packed before the
+/// notes went into the feed). Only the notes written for pilots (release-notes/vX.Y.Z.md): GitHub's generated list of
+/// pull requests after them is left out; the release page has it.
+///
+internal sealed class GithubReleaseNotes(HttpClient http, string repository)
+{
+ /// Where GitHub's generated list of changes begins in a release's text.
+ private const string GeneratedHeading = "## What's Changed";
+ private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15);
+
+ /// The notes of the release tagged v; null when it has none or GitHub does not answer (logged).
+ public async Task FetchAsync(string version, CancellationToken cancel)
+ {
+ var url = new Uri($"https://api.github.com/repos/{repository}/releases/tags/v{Uri.EscapeDataString(version)}");
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancel);
+ timeout.CancelAfter(RequestTimeout);
+ using HttpResponseMessage response = await http.GetAsync(url, timeout.Token).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ return Parse(await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false));
+ }
+ catch (Exception ex) when (ex is HttpRequestException or JsonException or IOException or OperationCanceledException && !cancel.IsCancellationRequested)
+ {
+ Log.Info($"The release notes of OrthoStreamXP {version} could not be read from GitHub: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// The written notes in GitHub's JSON for one release, without the generated list; null when there are none.
+ internal static string? Parse(string releaseJson)
+ {
+ using JsonDocument doc = JsonDocument.Parse(releaseJson);
+ if (!doc.RootElement.TryGetProperty("body", out JsonElement body) || body.ValueKind != JsonValueKind.String) return null;
+ string notes = body.GetString()!.ReplaceLineEndings("\n");
+ int generated = notes.StartsWith(GeneratedHeading, StringComparison.Ordinal) ? 0 : notes.IndexOf("\n" + GeneratedHeading, StringComparison.Ordinal);
+ if (generated >= 0) notes = notes[..generated];
+ return string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
+ }
+}
diff --git a/tests/OrthoStreamXP.Tests/GithubReleaseNotesTests.cs b/tests/OrthoStreamXP.Tests/GithubReleaseNotesTests.cs
new file mode 100644
index 0000000..40f5f38
--- /dev/null
+++ b/tests/OrthoStreamXP.Tests/GithubReleaseNotesTests.cs
@@ -0,0 +1,30 @@
+using System.Text.Json;
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP.Tests;
+
+/// Release notes read from a GitHub Release, for updates that carry none.
+public sealed class GithubReleaseNotesTests
+{
+ private static string Release(string? body) => JsonSerializer.Serialize(new { tag_name = "v1.2.2", body });
+
+ [Fact]
+ public void The_written_notes_are_kept_and_the_generated_list_left_out()
+ {
+ string json = Release("## What's new\r\n\r\n- **Clearer help.** Plain words (#21).\r\n\r\n## What's Changed\r\n* Improved CI by @someone in https://github.com/x/y/pull/19\r\n\r\n**Full Changelog**: https://github.com/x/y/compare/v1.2.1...v1.2.2");
+
+ Assert.Equal("## What's new\n\n- **Clearer help.** Plain words (#21).", GithubReleaseNotes.Parse(json));
+ }
+
+ [Fact]
+ public void Notes_without_a_generated_list_are_kept_whole()
+ {
+ Assert.Equal("## Fixes\n\n- One.", GithubReleaseNotes.Parse(Release("## Fixes\n\n- One.\n")));
+ }
+
+ [Theory]
+ [InlineData("## What's Changed\n* A pull request by @someone")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void A_release_with_only_the_generated_list_has_no_notes(string? body) => Assert.Null(GithubReleaseNotes.Parse(Release(body)));
+}
diff --git a/tests/OrthoStreamXP.Tests/ReleaseNotesTests.cs b/tests/OrthoStreamXP.Tests/ReleaseNotesTests.cs
new file mode 100644
index 0000000..386c9a5
--- /dev/null
+++ b/tests/OrthoStreamXP.Tests/ReleaseNotesTests.cs
@@ -0,0 +1,66 @@
+using OrthoStreamXP.Gui;
+
+namespace OrthoStreamXP.Tests;
+
+/// The release notes' Markdown, as the update window shows it.
+public sealed class ReleaseNotesTests
+{
+ [Fact]
+ public void Headings_bullets_and_paragraphs_become_blocks()
+ {
+ var blocks = ReleaseNotes.Parse("""
+ ## What's new
+
+ - **Clearer help.** The text explains
+ the setting in plain words (#21).
+ - Second change.
+
+ First line of a paragraph
+ and its second line.
+ """);
+
+ Assert.Equal([NoteKind.Heading, NoteKind.Bullet, NoteKind.Bullet, NoteKind.Paragraph], blocks.Select(b => b.Kind));
+ Assert.Equal("What's new", blocks[0].Text);
+ Assert.Equal("Clearer help. The text explains the setting in plain words (#21).", blocks[1].Text);
+ Assert.Equal("Second change.", blocks[2].Text);
+ Assert.Equal("First line of a paragraph and its second line.", blocks[3].Text);
+ }
+
+ [Fact]
+ public void Bold_text_is_marked_and_its_markers_dropped()
+ {
+ var spans = ReleaseNotes.Parse("- **Bold.** Then plain, **bold again**.")[0].Spans;
+
+ Assert.Equal([new NoteSpan("Bold.", true), new NoteSpan(" Then plain, ", false), new NoteSpan("bold again", true), new NoteSpan(".", false)], spans);
+ }
+
+ [Fact]
+ public void An_unclosed_bold_marker_stays_as_written()
+ {
+ Assert.Equal("2 ** 3 is eight", ReleaseNotes.Parse("2 ** 3 is eight")[0].Text);
+ Assert.DoesNotContain(ReleaseNotes.Parse("2 ** 3 is eight")[0].Spans, s => s.Bold);
+ }
+
+ [Fact]
+ public void Links_keep_their_text_and_code_its_content()
+ {
+ var block = ReleaseNotes.Parse("See [the guide](https://example.com/guide) and set `cache` [here].")[0];
+
+ Assert.Equal("See the guide and set cache [here].", block.Text);
+ }
+
+ [Fact]
+ public void Rules_and_star_bullets_are_understood()
+ {
+ var blocks = ReleaseNotes.Parse("* One\n---\n* Two");
+
+ Assert.Equal(["One", "Two"], blocks.Select(b => b.Text));
+ Assert.All(blocks, b => Assert.Equal(NoteKind.Bullet, b.Kind));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" \n\n ")]
+ public void No_notes_give_no_blocks(string? markdown) => Assert.Empty(ReleaseNotes.Parse(markdown));
+}
diff --git a/tests/OrthoStreamXP.Tests/ViewsTests.cs b/tests/OrthoStreamXP.Tests/ViewsTests.cs
index 7242d44..b4cbacd 100644
--- a/tests/OrthoStreamXP.Tests/ViewsTests.cs
+++ b/tests/OrthoStreamXP.Tests/ViewsTests.cs
@@ -24,6 +24,17 @@ public void Every_page_loads_with_the_theme()
page.Measure(new Size(1200, 900));
page.Arrange(new Rect(0, 0, 1200, 900));
}
+
+ // The update window, with notes and without.
+ foreach (string? notes in new[] { "## What's new\n\n- **Feature.** What it does.\n\nA paragraph.", null })
+ {
+ var offer = new OrthoStreamXP.Gui.UpdateOffer("9.9.9", "1.0.0", OrthoStreamXP.Gui.ReleaseNotes.Parse(notes), "https://example.com");
+ var dialog = new OrthoStreamXP.Gui.UpdateDialog(offer);
+ var content = (FrameworkElement)dialog.Content;
+ content.Measure(new Size(600, 560));
+ content.Arrange(new Rect(0, 0, 600, 560));
+ dialog.Close();
+ }
}
catch (Exception ex)
{