Skip to content

Commit f0a1c3e

Browse files
Geeven SinghCopilot
andcommitted
Phase 2.4: periodic re-check timer + skip-this-version + friendly enum labels
Closes out the 2.x sub-phase sequence. Three independent polish items that finish wiring the three settings AppSettings introduced in 2.2 and complete the banner UX 2.3 started. Periodic re-check timer: UpdateNotificationViewModel now schedules a DispatcherTimer after each Check / Dismiss / Skip, with the interval driven by AppSettings.UpdateCheckCadence. StartupOnly opts out entirely. If the banner is still visible at tick time the recheck is skipped (user is still looking at a previous notification). Tests cover the pure cadence->TimeSpan mapping (UpdateCheckCadenceExtensions); the DispatcherTimer integration itself stays untested per the §10 view-wiring carve-out and the constructor takes a useDispatcherTimer flag so VM tests opt out. Skip-this-version persistence: AppSettings.SkippedUpdateVersion (nullable string) added in schema v8 with no-op migration. UpdateNotificationViewModel reads it at check time and short-circuits if the detected version matches; the new SkipCommand writes the pending version via the setSkippedVersion callback. Banner gains a third button ('Skip this version'). Skip is per-version: detecting a newer version overwrites the previous skip and surfaces the banner again. Friendly enum labels in the Settings dialog: new generic EnumDisplayConverter maps AutoUpdateMode and UpdateCheckCadence values to user-facing strings ('Every six hours', 'Notify only (show banner)', etc.). Wired via ItemTemplate on the Updates section's ComboBoxes. VM constructor signature grew: now takes Func<UpdateCheckCadence>, Func<string?>, Action<string?> alongside the existing Func<AutoUpdateMode>. App.OnStartup hands each one a closure over settingsService.Current.* and settingsService.Update so live setting changes take effect on the next check / skip without recreating the VM. Tests share a NewVm() helper to keep call sites readable. Tests: 1403 passing (+22). Breakdown: 5 UpdateCheckCadenceExtensions, 11 EnumDisplayConverter, 4 new UpdateNotificationViewModel (skip-match / skip-differ / SkipCommand x2), 2 SettingsService (v7->v8 migration + skip round-trip). 0 warnings, 0 errors in dotnet build -c Release. CHANGELOG [Unreleased] documents the new banner button and the timer. AI-Local-Session: 4519f6b6-393a-4476-8efa-410e5396c3a9 AI-Cloud-Session: 72f9e474-60ab-42c2-b2a0-28fee827cbbb Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8720b7e commit f0a1c3e

14 files changed

Lines changed: 487 additions & 40 deletions

CHANGELOG.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ body. Keep section headings exact and write notes in Markdown.
1414

1515
### Added
1616

17+
- **Auto-update banner gets a "Skip this version" button.** Persists
18+
the skipped version into `settings.json` so future checks for the
19+
same version stay quiet across launches. Detection of a newer
20+
version overwrites the previous skip and shows the banner again.
21+
- **Periodic re-check timer for auto-updates.** Driven by the
22+
Settings → Updates → "Check frequency" dropdown (StartupOnly /
23+
Hourly / Every six hours / Daily / Weekly). `StartupOnly` opts
24+
out of periodic checks entirely; the others schedule a re-check
25+
after each `Check`/`Dismiss`/`Skip`. If the banner is still
26+
visible at tick time the recheck is skipped (the user is still
27+
looking at a previous notification).
1728
- **Auto-update banner at the top of the main window.** Replaces
1829
the silent placeholder from the previous Phase 2.1 / 2.2
1930
iterations:
@@ -23,9 +34,8 @@ body. Keep section headings exact and write notes in Markdown.
2334
- In `NotifyOnly` mode (the default), the banner appears as soon
2435
as an update is detected, with an **Install** button that
2536
triggers the download + apply-on-next-launch sequence.
26-
- Either mode's banner has a **Dismiss** button that hides it
27-
for the rest of the session. (A persistent "skip this
28-
version" gesture is planned as a follow-up.)
37+
- Either mode's banner has **Dismiss** (session-scoped) and
38+
**Skip this version** (persists) buttons.
2939
- **Settings → Updates section.** Three new settings control how
3040
DiffViewer handles available updates from GitHub Releases. The
3141
settings are inert on portable / from-source builds (those have
@@ -35,14 +45,14 @@ body. Keep section headings exact and write notes in Markdown.
3545
migrated installs), `Automatic`, or `Disabled`. `NotifyOnly`
3646
is the safe default: silent auto-updates are opt-in.
3747
- **Check frequency**`StartupOnly` / `Hourly` / `EverySixHours`
38-
/ `Daily` (default) / `Weekly`. Currently only the startup
39-
check fires; the periodic timer that honors this setting is
40-
a planned follow-up.
41-
- **Include pre-release versions** — off by default. Now wired
48+
/ `Daily` (default) / `Weekly`.
49+
- **Include pre-release versions** — off by default; wired
4250
through to the underlying GitHub release source.
43-
- `settings.json` schema bump v6 → v7. The migration is a no-op:
44-
all three new fields default to safe values, so pre-v7 files load
45-
with the same effective behaviour they had before.
51+
- Dropdowns display friendly labels ("Every six hours" instead
52+
of the raw `EverySixHours` enum identifier).
53+
- `settings.json` schema bumps v6 → v7 → v8. Both migrations are
54+
no-ops: all new fields default to safe values, so pre-v6 files
55+
load with the same effective behaviour they had before.
4656

4757
## [1.4.0] - 2026-05-29
4858

DiffViewer.Tests/Services/SettingsServiceTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,46 @@ public void Save_Then_Load_RoundTripsAutoUpdateFields()
548548
svc2.Current.IncludePreReleases.Should().BeTrue();
549549
}
550550

551+
[Fact]
552+
public void Load_V7File_MigratesToV8_SkippedUpdateVersionDefaultsToNull()
553+
{
554+
// v7 schema (current minus 1) had no skippedUpdateVersion
555+
// field. After v7->v8 migration the field should hydrate to
556+
// null (nothing skipped) and other fields should be
557+
// preserved. Auto-update fields from v7 carry through
558+
// unchanged.
559+
var v7 = new JsonObject
560+
{
561+
["schemaVersion"] = 7,
562+
["fontSize"] = 21,
563+
["autoUpdate"] = AutoUpdateMode.Automatic.ToString(),
564+
["updateCheckCadence"] = UpdateCheckCadence.Hourly.ToString(),
565+
["includePreReleases"] = true,
566+
};
567+
File.WriteAllText(_settingsPath, v7.ToJsonString());
568+
569+
var svc = new SettingsService(_settingsPath);
570+
571+
svc.LastLoadOutcome.Should().Be(SettingsLoadOutcome.Migrated);
572+
svc.Current.FontSize.Should().Be(21);
573+
svc.Current.AutoUpdate.Should().Be(AutoUpdateMode.Automatic);
574+
svc.Current.UpdateCheckCadence.Should().Be(UpdateCheckCadence.Hourly);
575+
svc.Current.IncludePreReleases.Should().BeTrue();
576+
svc.Current.SkippedUpdateVersion.Should().BeNull();
577+
}
578+
579+
[Fact]
580+
public void Save_Then_Load_RoundTripsSkippedUpdateVersion()
581+
{
582+
var svc1 = new SettingsService(_settingsPath);
583+
svc1.Save(svc1.Current with { SkippedUpdateVersion = "1.5.0-rc1" });
584+
585+
var svc2 = new SettingsService(_settingsPath);
586+
587+
svc2.LastLoadOutcome.Should().Be(SettingsLoadOutcome.Loaded);
588+
svc2.Current.SkippedUpdateVersion.Should().Be("1.5.0-rc1");
589+
}
590+
551591
[Fact]
552592
public void RepoUrlMappings_StableOrderingOnDisk_AcrossSaves()
553593
{
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.Globalization;
2+
using System.Windows.Data;
3+
using DiffViewer.Models;
4+
using DiffViewer.Utility;
5+
using FluentAssertions;
6+
using Xunit;
7+
8+
namespace DiffViewer.Tests.Utility;
9+
10+
public sealed class EnumDisplayConverterTests
11+
{
12+
[Theory]
13+
[InlineData(AutoUpdateMode.Automatic, "Automatic (silent install)")]
14+
[InlineData(AutoUpdateMode.NotifyOnly, "Notify only (show banner)")]
15+
[InlineData(AutoUpdateMode.Disabled, "Disabled")]
16+
public void Convert_AutoUpdateMode_ProducesFriendlyString(AutoUpdateMode mode, string expected)
17+
{
18+
var result = EnumDisplayConverter.Instance.Convert(
19+
mode, typeof(string), parameter: null, culture: CultureInfo.InvariantCulture);
20+
21+
result.Should().Be(expected);
22+
}
23+
24+
[Theory]
25+
[InlineData(UpdateCheckCadence.StartupOnly, "Startup only")]
26+
[InlineData(UpdateCheckCadence.Hourly, "Hourly")]
27+
[InlineData(UpdateCheckCadence.EverySixHours, "Every six hours")]
28+
[InlineData(UpdateCheckCadence.Daily, "Daily")]
29+
[InlineData(UpdateCheckCadence.Weekly, "Weekly")]
30+
public void Convert_UpdateCheckCadence_ProducesFriendlyString(UpdateCheckCadence cadence, string expected)
31+
{
32+
var result = EnumDisplayConverter.Instance.Convert(
33+
cadence, typeof(string), parameter: null, culture: CultureInfo.InvariantCulture);
34+
35+
result.Should().Be(expected);
36+
}
37+
38+
[Fact]
39+
public void Convert_UnknownValue_FallsBackToToString()
40+
{
41+
var result = EnumDisplayConverter.Instance.Convert(
42+
"some-string", typeof(string), parameter: null, culture: CultureInfo.InvariantCulture);
43+
44+
result.Should().Be("some-string");
45+
}
46+
47+
[Fact]
48+
public void Convert_NullValue_ReturnsEmptyString()
49+
{
50+
var result = EnumDisplayConverter.Instance.Convert(
51+
null, typeof(string), parameter: null, culture: CultureInfo.InvariantCulture);
52+
53+
result.Should().Be(string.Empty);
54+
}
55+
56+
[Fact]
57+
public void ConvertBack_ReturnsDoNothing()
58+
{
59+
// The converter only feeds the display path; the underlying
60+
// enum value is what SelectedItem binds to. ConvertBack must
61+
// not interfere.
62+
var result = EnumDisplayConverter.Instance.ConvertBack(
63+
"Daily", typeof(UpdateCheckCadence), parameter: null, culture: CultureInfo.InvariantCulture);
64+
65+
result.Should().Be(Binding.DoNothing);
66+
}
67+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using DiffViewer.Models;
3+
using DiffViewer.Utility;
4+
using FluentAssertions;
5+
using Xunit;
6+
7+
namespace DiffViewer.Tests.Utility;
8+
9+
public sealed class UpdateCheckCadenceExtensionsTests
10+
{
11+
[Fact]
12+
public void ToInterval_StartupOnly_IsNull()
13+
{
14+
UpdateCheckCadence.StartupOnly.ToInterval().Should().BeNull();
15+
}
16+
17+
[Theory]
18+
[InlineData(UpdateCheckCadence.Hourly, 1)]
19+
[InlineData(UpdateCheckCadence.EverySixHours, 6)]
20+
[InlineData(UpdateCheckCadence.Daily, 24)]
21+
public void ToInterval_HourlyCadences_MapToHours(UpdateCheckCadence cadence, int expectedHours)
22+
{
23+
cadence.ToInterval().Should().Be(TimeSpan.FromHours(expectedHours));
24+
}
25+
26+
[Fact]
27+
public void ToInterval_Weekly_IsSevenDays()
28+
{
29+
UpdateCheckCadence.Weekly.ToInterval().Should().Be(TimeSpan.FromDays(7));
30+
}
31+
}

DiffViewer.Tests/ViewModels/UpdateNotificationViewModelTests.cs

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.Threading;
34
using System.Threading.Tasks;
@@ -15,7 +16,7 @@ public sealed class UpdateNotificationViewModelTests
1516
public async Task StartAsync_WhenDisabled_DoesNotCheck()
1617
{
1718
var fake = new FakeUpdateService();
18-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.Disabled);
19+
var sut = NewVm(fake, () => AutoUpdateMode.Disabled);
1920

2021
await sut.StartAsync(CancellationToken.None);
2122

@@ -28,7 +29,7 @@ public async Task StartAsync_WhenAutomatic_ChecksDownloadsAndQueuesSilently_Then
2829
{
2930
var available = NewAvailable("1.5.0");
3031
var fake = new FakeUpdateService { CheckResult = available };
31-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.Automatic);
32+
var sut = NewVm(fake, () => AutoUpdateMode.Automatic);
3233

3334
await sut.StartAsync(CancellationToken.None);
3435

@@ -45,7 +46,7 @@ public async Task StartAsync_WhenNotifyOnly_ChecksOnly_ThenShowsBannerWithInstal
4546
{
4647
var available = NewAvailable("2.0.0");
4748
var fake = new FakeUpdateService { CheckResult = available };
48-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
49+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly);
4950

5051
await sut.StartAsync(CancellationToken.None);
5152

@@ -61,7 +62,7 @@ public async Task StartAsync_WhenNotifyOnly_ChecksOnly_ThenShowsBannerWithInstal
6162
public async Task StartAsync_WhenNoUpdateAvailable_LeavesBannerHidden()
6263
{
6364
var fake = new FakeUpdateService { CheckResult = UpdateCheckResult.NoUpdateAvailable };
64-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
65+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly);
6566

6667
await sut.StartAsync(CancellationToken.None);
6768

@@ -71,12 +72,46 @@ public async Task StartAsync_WhenNoUpdateAvailable_LeavesBannerHidden()
7172
sut.ShowInstallButton.Should().BeFalse();
7273
}
7374

75+
[Fact]
76+
public async Task StartAsync_WhenDetectedVersionMatchesSkipped_LeavesBannerHidden()
77+
{
78+
// A previously-skipped version should silently consume the
79+
// detection: no banner, no download, no apply queue. The
80+
// periodic re-check will still try again next interval (in
81+
// case the user changes their mind).
82+
var available = NewAvailable("1.5.0");
83+
var fake = new FakeUpdateService { CheckResult = available };
84+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly, getSkipped: () => "1.5.0");
85+
86+
await sut.StartAsync(CancellationToken.None);
87+
88+
fake.CheckCalls.Should().Be(1);
89+
fake.DownloadCalls.Should().BeEmpty();
90+
sut.IsBannerVisible.Should().BeFalse();
91+
}
92+
93+
[Fact]
94+
public async Task StartAsync_WhenDetectedVersionDiffersFromSkipped_ShowsBanner()
95+
{
96+
// A previously-skipped older version should NOT suppress a
97+
// newer detection — Skip is per-version, not "skip everything
98+
// forever".
99+
var available = NewAvailable("2.0.0");
100+
var fake = new FakeUpdateService { CheckResult = available };
101+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly, getSkipped: () => "1.5.0");
102+
103+
await sut.StartAsync(CancellationToken.None);
104+
105+
sut.IsBannerVisible.Should().BeTrue();
106+
sut.StatusText.Should().Contain("2.0.0");
107+
}
108+
74109
[Fact]
75110
public async Task Install_AfterNotifyOnlyCheck_DownloadsAndQueues_HidesInstallButton()
76111
{
77112
var available = NewAvailable("3.1.2");
78113
var fake = new FakeUpdateService { CheckResult = available };
79-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
114+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly);
80115
await sut.StartAsync(CancellationToken.None);
81116

82117
await sut.InstallCommand.ExecuteAsync(null);
@@ -96,7 +131,7 @@ public async Task Install_WithNoPendingUpdate_NoOps()
96131
// command. The VM should defend against the missing _pending
97132
// by doing nothing rather than dereferencing null.
98133
var fake = new FakeUpdateService();
99-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
134+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly);
100135

101136
await sut.InstallCommand.ExecuteAsync(null);
102137

@@ -107,13 +142,9 @@ public async Task Install_WithNoPendingUpdate_NoOps()
107142
[Fact]
108143
public async Task Dismiss_HidesBanner_DoesNotCancelQueuedApply()
109144
{
110-
// The "Dismiss" UX is session-scoped — it hides the banner but
111-
// doesn't roll back the queued ApplyOnNextLaunch from the
112-
// Automatic-mode flow. The update still installs on next exit;
113-
// the user just doesn't keep seeing the notification.
114145
var available = NewAvailable("1.0.0");
115146
var fake = new FakeUpdateService { CheckResult = available };
116-
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.Automatic);
147+
var sut = NewVm(fake, () => AutoUpdateMode.Automatic);
117148
await sut.StartAsync(CancellationToken.None);
118149
sut.IsBannerVisible.Should().BeTrue();
119150

@@ -123,6 +154,34 @@ public async Task Dismiss_HidesBanner_DoesNotCancelQueuedApply()
123154
fake.ApplyOnNextLaunchCalls.Should().HaveCount(1); // unchanged
124155
}
125156

157+
[Fact]
158+
public async Task Skip_HidesBanner_AndPersistsSkippedVersion()
159+
{
160+
var available = NewAvailable("1.5.0");
161+
var fake = new FakeUpdateService { CheckResult = available };
162+
var setCalls = new List<string?>();
163+
var sut = NewVm(fake, () => AutoUpdateMode.NotifyOnly, setSkipped: v => setCalls.Add(v));
164+
await sut.StartAsync(CancellationToken.None);
165+
sut.IsBannerVisible.Should().BeTrue();
166+
167+
sut.SkipCommand.Execute(null);
168+
169+
sut.IsBannerVisible.Should().BeFalse();
170+
setCalls.Should().ContainSingle().Which.Should().Be("1.5.0");
171+
}
172+
173+
[Fact]
174+
public void Skip_WithNoPendingUpdate_NoOps()
175+
{
176+
var setCalls = new List<string?>();
177+
var sut = NewVm(new FakeUpdateService(), () => AutoUpdateMode.NotifyOnly,
178+
setSkipped: v => setCalls.Add(v));
179+
180+
sut.SkipCommand.Execute(null);
181+
182+
setCalls.Should().BeEmpty();
183+
}
184+
126185
[Fact]
127186
public async Task GetMode_ReadAtEachStart_AllowsLiveModeChanges()
128187
{
@@ -133,7 +192,7 @@ public async Task GetMode_ReadAtEachStart_AllowsLiveModeChanges()
133192
// future refactor from accidentally capturing a snapshot).
134193
var mode = AutoUpdateMode.Disabled;
135194
var fake = new FakeUpdateService { CheckResult = NewAvailable("9.9.9") };
136-
var sut = new UpdateNotificationViewModel(fake, getMode: () => mode);
195+
var sut = NewVm(fake, () => mode);
137196

138197
await sut.StartAsync(CancellationToken.None);
139198
fake.CheckCalls.Should().Be(0);
@@ -143,6 +202,22 @@ public async Task GetMode_ReadAtEachStart_AllowsLiveModeChanges()
143202
fake.CheckCalls.Should().Be(1);
144203
}
145204

205+
private static UpdateNotificationViewModel NewVm(
206+
IUpdateService updates,
207+
Func<AutoUpdateMode> getMode,
208+
Func<UpdateCheckCadence>? getCadence = null,
209+
Func<string?>? getSkipped = null,
210+
Action<string?>? setSkipped = null)
211+
{
212+
return new UpdateNotificationViewModel(
213+
updates,
214+
getMode,
215+
getCadence ?? (() => UpdateCheckCadence.Daily),
216+
getSkipped ?? (() => null),
217+
setSkipped ?? (_ => { }),
218+
useDispatcherTimer: false);
219+
}
220+
146221
private static UpdateCheckResult NewAvailable(string version) =>
147222
new() { IsAvailable = true, Version = version, OpaqueHandle = new object() };
148223

DiffViewer/App.xaml.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ protected override async void OnStartup(StartupEventArgs e)
172172
?? (IUpdateService)new NullUpdateService();
173173
_updateNotification = new UpdateNotificationViewModel(
174174
updateService,
175-
getMode: () => settingsService.Current.AutoUpdate);
175+
getMode: () => settingsService.Current.AutoUpdate,
176+
getCadence: () => settingsService.Current.UpdateCheckCadence,
177+
getSkippedVersion: () => settingsService.Current.SkippedUpdateVersion,
178+
setSkippedVersion: version => settingsService.Update(s => s with { SkippedUpdateVersion = version }));
176179
window.AttachUpdateNotification(_updateNotification);
177180

178181
window.Closed += async (_, _) =>
@@ -206,6 +209,7 @@ protected override void OnExit(ExitEventArgs e)
206209
try { _shutdownCts?.Cancel(); } catch { }
207210
try { _shutdownCts?.Dispose(); } catch { }
208211
try { _httpClient?.Dispose(); } catch { }
212+
try { _updateNotification?.Dispose(); } catch { }
209213
base.OnExit(e);
210214
}
211215
}

0 commit comments

Comments
 (0)