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/release-notes/v1.2.3.md b/release-notes/v1.2.3.md
new file mode 100644
index 0000000..9bf733a
--- /dev/null
+++ b/release-notes/v1.2.3.md
@@ -0,0 +1,9 @@
+## What's new
+
+- **Updates install only when you choose.** When a new version is ready, the banner on the **Home** page stays until you act on it. Closing and reopening OrthoStreamXP no longer installs the update on its own.
+- **See what's new before updating.** **Update…** on the banner opens a window with the new version's release notes. Choose **Update and restart** to install it now, or **Not now** to keep the version you have.
+
+## Good to know
+
+- This is the last update that may install by itself when OrthoStreamXP starts, because the version you have now still works the old way. From 1.2.3 on, updates wait for you.
+- As before, updates can only be installed while OrthoStreamXP is stopped, and your settings, cache and flight history stay.
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)
{