diff --git a/CLAUDE.md b/CLAUDE.md index 693ab0a..f64befc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,8 @@ The settings window (`SemiPlot.UI/Settings`) is the viewer's only writer of the edits only keys that already exist, each in the file that owns it, and only the keys the operator changed; it creates no file, and the startup-failure window offers it only when both sections read and carry every key it edits. The production loaders validate a staged copy before any file is replaced, and every change takes effect at the -next start (`docs/architecture/overview.md#the-settings-window`). +next start except the theme, which every running process applies live +(`docs/architecture/overview.md#the-live-theme`). `.editorconfig`'s style and quality analyzer rules fail `dotnet build` (`TreatWarningsAsErrors`, `EnforceCodeStyleInBuild`) and `dotnet format SemiPlot.slnx --verify-no-changes` alike, so a diff --git a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs index 290adc4..ed63881 100644 --- a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs +++ b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs @@ -128,7 +128,7 @@ private static Dictionary ScalarsOf(Dictionary { try { - var content = File.ReadAllText(file); + var content = ReadShared(file); var repeated = FindRepeatedKey(content); if (repeated is not null) @@ -146,6 +146,20 @@ private static Dictionary ScalarsOf(Dictionary } } + private static string ReadShared(string file) + { + using var stream = OpenShared(file); + using var reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } + + /// Opens a section file for reading without blocking another process's File.Replace of it. + internal static FileStream OpenShared(string file) + { + return new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + } + // WithDuplicateKeyChecking refuses the file but names the key only inside its own message, and the // operator needs the key, so the top-level mapping is walked for it first. private static string? FindRepeatedKey(string content) diff --git a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs index 2f910a5..2f126c3 100644 --- a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs +++ b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs @@ -163,7 +163,7 @@ private Result Copy(string file, string name) try { - content = File.ReadAllBytes(file); + content = ReadAllBytesShared(file); } catch (Exception exception) { @@ -184,6 +184,15 @@ private Result Copy(string file, string name) } } + private static byte[] ReadAllBytesShared(string file) + { + using var stream = ConfigurationSection.OpenShared(file); + using var content = new MemoryStream(); + stream.CopyTo(content); + + return content.ToArray(); + } + private Result Write(string name, string content) { try diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs index 08862ae..9041155 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs @@ -145,6 +145,37 @@ public void AnEmptyFileContributesNothingAndFailsNothing() .Be(new KeyValuePair("locale", "ru")); } + [Fact] + public void AFileAnotherHandleHoldsOpenForWritingStillReads() + { + WriteFile("a.yaml", "locale: ru\n"); + using var writer = new FileStream( + Path.Combine(_directory, "a.yaml"), FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + + var result = ConfigurationSection.Read(_directory, ConfigurationSectionName.App); + + result.IsSuccess.Should().BeTrue(Describe(result)); + MappingOf(result.Value).Should().Contain("locale", "ru"); + } + + [Fact] + public void AFileTheSectionReadHoldsOpenCanStillBeReplaced() + { + WriteFile("a.yaml", "locale: ru\n"); + var staged = Path.Combine(_directory, "a.yaml.staged"); + File.WriteAllText(staged, "locale: en\n"); + + using (ConfigurationSection.OpenShared(Path.Combine(_directory, "a.yaml"))) + { + File.Replace(staged, Path.Combine(_directory, "a.yaml"), destinationBackupFileName: null); + } + + var result = ConfigurationSection.Read(_directory, ConfigurationSectionName.App); + + result.IsSuccess.Should().BeTrue(Describe(result)); + MappingOf(result.Value).Should().Contain("locale", "en"); + } + [Fact] public void AFileWrittenWithAByteOrderMarkStillContributesItsFirstKey() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs index 0e1c9b1..9e03e2e 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs @@ -188,6 +188,24 @@ public void ASectionFolderRemovedAfterTheReadFailsWithUnlistable() error.Directory.Should().Be(_section); } + [Fact] + public void AFileAnotherSavesWriteProbeHoldsOpenIsStillCopied() + { + WriteFile("a.yaml", "locale: ru\n"); + WriteFile("b.yaml", "theme: light\n"); + var owned = OwnedOf(ConfigurationSectionName.App); + var held = Path.Combine(_section, "b.yaml"); + Result> staged; + + using (new FileStream(held, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) + { + staged = Stage(owned, ConfigurationSectionName.App, ("locale", "en")); + } + + staged.IsSuccess.Should().BeTrue(Describe(staged)); + File.ReadAllBytes(Path.Combine(_staging, "b.yaml")).Should().Equal(File.ReadAllBytes(held)); + } + [Fact] public void ACopyThatCannotBeWrittenFailsWithUnwritableNamingTheFile() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs new file mode 100644 index 0000000..45d32c3 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs @@ -0,0 +1,415 @@ +using System.ComponentModel; +using System.Reactive.Concurrency; +using System.Reactive.Subjects; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Reactive.Testing; + +using SemiPlot.UI.Settings; +using SemiPlot.UI.Startup; + +using Xunit; + +namespace SemiPlot.Tests.Unit.UI.Settings; + +[Trait("Component", "UI")] +[Trait("Area", "Di")] +[Trait("Category", "Unit")] +public sealed class AppSectionWatcherTests : IDisposable +{ + private static readonly TimeSpan _tick = TimeSpan.FromTicks(1); + + private readonly TestScheduler _dataScheduler = new(); + private readonly TestScheduler _uiScheduler = new(); + private readonly Subject _fileEvents = new(); + private readonly List> _emitted = []; + private Result _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Light)); + private Exception? _loadFailure; + private int _loads; + private int _opens; + private IDisposable? _subscription; + + public void Dispose() + { + _subscription?.Dispose(); + _fileEvents.Dispose(); + } + + [Fact] + public void AThemeChangeOnDiskAppliesTheTheme() + { + Watch(appliedTheme: AppThemeVariant.Light); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + + _fileEvents.OnNext(Result.Ok()); + _dataScheduler.AdvanceBy(AppSectionWatcher.QuietPeriod.Ticks); + + _loads.Should().Be(1, "the load runs on the data scheduler"); + _emitted.Should().BeEmpty("the theme reaches the application on the UI scheduler"); + + _uiScheduler.AdvanceBy(_tick.Ticks); + + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void ALanguageChangeOnDiskAppliesNothing() + { + Watch(appliedTheme: AppThemeVariant.Light); + _onDisk = Result.Ok(new AppSettings(UiLanguage.En, AppThemeVariant.Light)); + + Signal(); + + _loads.Should().Be(1); + _emitted.Should().BeEmpty(); + } + + [Fact] + public void TheSameThemeTwiceAppliesOnce() + { + Watch(appliedTheme: AppThemeVariant.Light); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + + Signal(); + Signal(); + + _loads.Should().Be(2); + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void AStartWithoutSettingsAppliesTheFirstThemeItLoads() + { + Watch(appliedTheme: null); + + Signal(); + + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Light); + } + + [Fact] + public void AThemeSavedBeforeTheWatchStartsIsAppliedAfterOneQuietPeriod() + { + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + Watch(appliedTheme: AppThemeVariant.Light); + + AdvanceBoth(); + + _loads.Should().Be(1, "the save landed between the start's read and the watcher, so no event reports it"); + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void AStartWithoutSettingsLoadsNothingBeforeAFileEvent() + { + Watch(appliedTheme: null); + + AdvanceBoth(); + + _loads.Should().Be(0, "the start has already reported the section it could not load"); + } + + [Fact] + public void ABurstOfEventsCollapsesToOneLoad() + { + Watch(appliedTheme: AppThemeVariant.Light); + var withinQuietPeriod = AppSectionWatcher.QuietPeriod - _tick; + + for (var index = 0; index < 5; index++) + { + _fileEvents.OnNext(Result.Ok()); + _dataScheduler.AdvanceBy(withinQuietPeriod.Ticks); + } + + _loads.Should().Be(0, "each event restarts the quiet period"); + + _dataScheduler.AdvanceBy(_tick.Ticks); + + _loads.Should().Be(1); + } + + [Fact] + public void AFailedLoadReachesTheReportAndKeepsTheAppliedTheme() + { + Watch(appliedTheme: AppThemeVariant.Light); + var failure = new AppSettingsError("app", AppSettingsProblem.KeyMissing, AppSettingsLoader.ThemeKey); + _onDisk = Result.Fail(failure); + + Signal(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle().Which.Should().BeSameAs(failure); + + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Light)); + Signal(); + + _emitted.Should().ContainSingle("the theme on disk is the one already applied"); + } + + [Fact] + public void ALoadThatThrowsIsEmittedAsAFailureAndTheWatchGoesOn() + { + Watch(appliedTheme: AppThemeVariant.Light); + var thrown = new IOException("The section read threw."); + _loadFailure = thrown; + + Signal(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle() + .Which.Should().BeOfType().Which.Exception.Should().BeSameAs(thrown); + + _loadFailure = null; + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + Signal(); + + _emitted.Should().HaveCount(2).And.Subject.Last().Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void ABufferOverflowReloadsOnce() + { + using var watcher = new RaisingWatcher(); + WatchThrough(Opening(Opened(watcher))); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + + watcher.RaiseError(new InternalBufferOverflowException()); + AdvanceBoth(); + + _loads.Should().Be(1); + _opens.Should().Be(1, "an overflow loses events, not the watcher"); + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void AStoppedWatcherIsReportedOnceAndItsWatchOpensAgain() + { + using var stopped = new RaisingWatcher(); + using var reopened = new RaisingWatcher(); + var networkNameDeleted = new Win32Exception(64); + WatchThrough(Opening(Opened(stopped), Result.Fail("The share is still gone."), Opened(reopened))); + AdvanceBoth(); + + stopped.RaiseError(networkNameDeleted); + AdvanceBoth(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle() + .Which.Should().BeOfType().Which.Exception.Should().BeSameAs(networkNameDeleted); + stopped.IsDisposed.Should().BeTrue(); + + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + AdvanceBoth(); + + _emitted.Should().ContainSingle("the outage is reported once"); + + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + AdvanceBoth(); + + _opens.Should().Be(3); + _loads.Should().Be(2, "the watch opened again loads once for the events it missed"); + _emitted.Should().HaveCount(2).And.Subject.Last().Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void ARefusedOpenIsReportedOnceAndOpenedAgain() + { + using var watcher = new RaisingWatcher(); + var refusal = new UnauthorizedAccessException("The folder cannot be listed."); + WatchThrough(Opening( + Result.Fail(new ExceptionalError(refusal)), + Result.Fail(new ExceptionalError(refusal)), + Opened(watcher))); + AdvanceBoth(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle() + .Which.Should().BeOfType().Which.Exception.Should().BeSameAs(refusal); + + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + AdvanceBoth(); + + _opens.Should().Be(3); + _emitted.Should().HaveCount(2).And.Subject.Last().Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void EveryWatcherEventReachesTheStream() + { + using var watcher = new RaisingWatcher(); + var events = 0; + using var subscription = new AppSectionWatcher(watcher).Events.Subscribe(_ => events++); + + watcher.RaiseEveryFileEvent(); + + events.Should().Be(4); + } + + [Fact] + public async Task AReplacedFileInARealFolderYieldsTheNewTheme() + { + var configDirectory = Directory.CreateTempSubdirectory("semiplot-app-watcher-").FullName; + try + { + var sectionDirectory = Directory.CreateDirectory(Path.Combine(configDirectory, "app")).FullName; + var stagingDirectory = Directory.CreateDirectory(Path.Combine(configDirectory, "staging")).FullName; + var target = Path.Combine(sectionDirectory, "app.yaml"); + var staged = Path.Combine(stagingDirectory, "app.yaml"); + File.WriteAllText(target, "locale: ru\ntheme: light\n"); + File.WriteAllText(staged, "locale: ru\ntheme: dark\n"); + var applied = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + + using var subscription = AppSectionWatcher.ThemeChanges( + AppSectionWatcher.Watch(() => AppSectionWatcher.Open(sectionDirectory), DefaultScheduler.Instance), + AppThemeVariant.Light, + () => AppSettingsLoader.Load(sectionDirectory), + DefaultScheduler.Instance, + ImmediateScheduler.Instance) + .Subscribe(theme => applied.TrySetResult(theme)); + + File.Replace(staged, target, Path.Combine(stagingDirectory, "app.yaml.replaced")); + + var theme = await applied.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + theme.IsSuccess.Should().BeTrue(string.Join("; ", theme.Errors.Select(error => error.Message))); + theme.Value.Should().Be(AppThemeVariant.Dark); + } + finally + { + Directory.Delete(configDirectory, recursive: true); + } + } + + [Fact] + public async Task AnAppFolderDeletedAndRecreatedIsWatchedAgain() + { + // Linux raises nothing when the watched folder itself goes: docs/architecture/overview.md#the-live-theme + if (!OperatingSystem.IsWindows()) + { + return; + } + + var configDirectory = Directory.CreateTempSubdirectory("semiplot-app-watcher-").FullName; + try + { + var sectionDirectory = Directory.CreateDirectory(Path.Combine(configDirectory, "app")).FullName; + var file = Path.Combine(sectionDirectory, "app.yaml"); + File.WriteAllText(file, "locale: ru\ntheme: light\n"); + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dark = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var subscription = AppSectionWatcher.ThemeChanges( + AppSectionWatcher.Watch(() => AppSectionWatcher.Open(sectionDirectory), DefaultScheduler.Instance), + AppThemeVariant.Light, + () => AppSettingsLoader.Load(sectionDirectory), + DefaultScheduler.Instance, + ImmediateScheduler.Instance) + .Subscribe(theme => + { + if (theme.IsFailed && theme.Errors.OfType().Any(error => error.Exception is Win32Exception)) + { + stopped.TrySetResult(); + } + else if (theme.IsSuccess && theme.Value == AppThemeVariant.Dark) + { + dark.TrySetResult(); + } + }); + + Directory.Delete(sectionDirectory, recursive: true); + await stopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await Task.Delay(AppSectionWatcher.QuietPeriod * 3, TestContext.Current.CancellationToken); + Directory.CreateDirectory(sectionDirectory); + File.WriteAllText(file, "locale: ru\ntheme: dark\n"); + + await dark.Task.WaitAsync( + AppSectionWatcher.ReopenInterval + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + finally + { + Directory.Delete(configDirectory, recursive: true); + } + } + + private void Watch(AppThemeVariant? appliedTheme) + { + _subscription = AppSectionWatcher.ThemeChanges(_fileEvents, appliedTheme, Load, _dataScheduler, _uiScheduler) + .Subscribe(_emitted.Add); + } + + private void WatchThrough(Func> open) + { + _subscription = AppSectionWatcher.ThemeChanges( + AppSectionWatcher.Watch(open, _dataScheduler), AppThemeVariant.Light, Load, _dataScheduler, _uiScheduler) + .Subscribe(_emitted.Add); + } + + private Func> Opening(params Result[] opens) + { + var pending = new Queue>(opens); + + return () => + { + _opens++; + + return pending.Dequeue(); + }; + } + + private static Result Opened(FileSystemWatcher watcher) + { + return Result.Ok(new AppSectionWatcher(watcher)); + } + + private Result Load() + { + _loads++; + + if (_loadFailure is { } failure) + { + throw failure; + } + + return _onDisk; + } + + private void Signal() + { + _fileEvents.OnNext(Result.Ok()); + AdvanceBoth(); + } + + private void AdvanceBoth() + { + _dataScheduler.AdvanceBy(AppSectionWatcher.QuietPeriod.Ticks); + _uiScheduler.AdvanceBy(_tick.Ticks); + } + + private sealed class RaisingWatcher : FileSystemWatcher + { + public bool IsDisposed { get; private set; } + + public void RaiseError(Exception error) + { + OnError(new ErrorEventArgs(error)); + } + + public void RaiseEveryFileEvent() + { + const string SectionDirectory = "app"; + + OnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, SectionDirectory, "app.yaml")); + OnCreated(new FileSystemEventArgs(WatcherChangeTypes.Created, SectionDirectory, "app.yaml")); + OnDeleted(new FileSystemEventArgs(WatcherChangeTypes.Deleted, SectionDirectory, "app.yaml")); + OnRenamed(new RenamedEventArgs(WatcherChangeTypes.Renamed, SectionDirectory, "app.yaml", "app.yaml.tmp")); + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs new file mode 100644 index 0000000..1355e32 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs @@ -0,0 +1,74 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace SemiPlot.Tests.Unit.UI.Settings; + +/// Refuses one kind of access to a directory until disposed. +internal sealed class DirectoryAccessDenial : IDisposable +{ + private const UnixFileMode FullAccess = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + private readonly string _directory; + private readonly FileSystemAccessRule? _denyRule; + + // Windows ignores the read-only attribute on a folder, so the refusal is an access rule there. + private DirectoryAccessDenial(string directory, DeniedAccess denied) + { + _directory = directory; + + if (OperatingSystem.IsWindows()) + { + var rights = denied == DeniedAccess.NewFolders + ? FileSystemRights.CreateDirectories + : FileSystemRights.ListDirectory; + var user = WindowsIdentity.GetCurrent().User!; + _denyRule = new FileSystemAccessRule(user, rights, AccessControlType.Deny); + var info = new DirectoryInfo(directory); + var security = info.GetAccessControl(); + security.AddAccessRule(_denyRule); + info.SetAccessControl(security); + } + else + { + File.SetUnixFileMode(directory, FullAccess & ~(denied == DeniedAccess.NewFolders + ? UnixFileMode.UserWrite + : UnixFileMode.UserRead)); + } + } + + private enum DeniedAccess + { + NewFolders, + Listing + } + + /// Refuses new folders inside the directory. + public static DirectoryAccessDenial OfNewFolders(string directory) + { + return new DirectoryAccessDenial(directory, DeniedAccess.NewFolders); + } + + /// Refuses listing the directory, while it still answers that it exists. + public static DirectoryAccessDenial OfListing(string directory) + { + return new DirectoryAccessDenial(directory, DeniedAccess.Listing); + } + + public void Dispose() + { + if (OperatingSystem.IsWindows()) + { + if (_denyRule is not null) + { + var info = new DirectoryInfo(_directory); + var security = info.GetAccessControl(); + security.RemoveAccessRule(_denyRule); + info.SetAccessControl(security); + } + } + else + { + File.SetUnixFileMode(_directory, FullAccess); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs deleted file mode 100644 index 778552f..0000000 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Security.AccessControl; -using System.Security.Principal; - -namespace SemiPlot.Tests.Unit.UI.Settings; - -/// Refuses new folders inside a directory until disposed. -internal sealed class DirectoryWriteDenial : IDisposable -{ - private readonly string _directory; - private readonly FileSystemAccessRule? _denyRule; - - // Windows ignores the read-only attribute on a folder, so the refusal is an access rule there. - public DirectoryWriteDenial(string directory) - { - _directory = directory; - - if (OperatingSystem.IsWindows()) - { - var user = WindowsIdentity.GetCurrent().User!; - _denyRule = new FileSystemAccessRule(user, FileSystemRights.CreateDirectories, AccessControlType.Deny); - var info = new DirectoryInfo(directory); - var security = info.GetAccessControl(); - security.AddAccessRule(_denyRule); - info.SetAccessControl(security); - } - else - { - File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserExecute); - } - } - - public void Dispose() - { - if (OperatingSystem.IsWindows()) - { - if (_denyRule is not null) - { - var info = new DirectoryInfo(_directory); - var security = info.GetAccessControl(); - security.RemoveAccessRule(_denyRule); - info.SetAccessControl(security); - } - } - else - { - File.SetUnixFileMode(_directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } - } -} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs index ec7ebd2..57a3e18 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs @@ -141,7 +141,7 @@ public void AConfigDirectoryThatRefusesTheStagingFolderFailsTheSave() var before = _sandbox.Snapshot(); Result result; - using (new DirectoryWriteDenial(_sandbox.ConfigDirectory)) + using (DirectoryAccessDenial.OfNewFolders(_sandbox.ConfigDirectory)) { result = _sandbox.Save(Edit(ConfigurationSectionName.App, ("theme", "dark"))); } @@ -225,6 +225,21 @@ public void AReplaceRefusedAfterTheProbeFailsWithUnwritableNamingTheFile() _sandbox.ShouldMatch(before); } + [Fact] + public void ASaveReplacesATargetAnotherSavesWriteProbeHoldsOpen() + { + var target = _sandbox.ConnectionFile; + Result result; + + using (new FileStream(target, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) + { + result = _sandbox.Save(Edit(ConfigurationSectionName.Connection, ("host", "10.20.30.40"))); + } + + result.IsSuccess.Should().BeTrue(Describe(result)); + _sandbox.LoadConnection().Host.Should().Be("10.20.30.40"); + } + [Fact] public void ARefusalKeepsTheLoadersCause() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs index 1f2f75d..e26a7cf 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs @@ -225,11 +225,24 @@ public async Task ChangingOnlyTheThemeLeavesTheConnectionFileByteIdentical() await viewModel.SaveCommand.Execute(); _messagePanel.Entries.Should().BeEmpty(); - viewModel.IsRestartPending.Should().BeTrue("a saved theme takes effect at the next start"); AppSettingsLoader.Load(_appDirectory).Value.Theme.Should().Be(AppThemeVariant.Dark); File.ReadAllBytes(Path.Combine(_connectionDirectory, "connection.yaml")).Should().Equal(before); } + [AvaloniaFact] + public async Task AThemeOnlySaveSetsNoRestartNotice() + { + ShippedConfiguration.FillPassword(_configDirectory, "secret"); + using var viewModel = Build(); + viewModel.SelectedTheme = viewModel.Themes.Single(choice => choice.Token == "dark"); + + await viewModel.SaveCommand.Execute(); + + _messagePanel.Entries.Should().BeEmpty(); + viewModel.IsRestartPending.Should().BeFalse("every process applies a saved theme live"); + AppSettingsLoader.Load(_appDirectory).Value.Theme.Should().Be(AppThemeVariant.Dark); + } + [AvaloniaFact] public async Task ALanguageSaveSetsTheRestartNotice() { @@ -241,6 +254,7 @@ public async Task ALanguageSaveSetsTheRestartNotice() await viewModel.SaveCommand.Execute(); viewModel.IsRestartPending.Should().BeTrue(); + viewModel.RestartNotice.Should().Be(Resources.SettingsRestartNotice); } [AvaloniaFact] @@ -255,6 +269,7 @@ public async Task AThemeAndLanguageSaveSetsTheRestartNotice() await viewModel.SaveCommand.Execute(); viewModel.IsRestartPending.Should().BeTrue(); + viewModel.RestartNotice.Should().Be(Resources.SettingsRestartNoticeThemeApplied, "the theme half applies live"); } [AvaloniaFact] diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs index d553dba..d16fd2f 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs @@ -1,3 +1,6 @@ +using System.Reactive.Linq; + +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Headless.XUnit; @@ -183,6 +186,7 @@ public async Task TheRestartNotice_IsHiddenBeforeASaveAndVisibleAfterOneThatSucc await HeadlessWait.Until(() => viewModel.IsRestartPending); notice.IsEffectivelyVisible.Should().BeTrue(); + notice.Text.Should().Be(Resources.SettingsRestartNotice); _messagePanel.Entries.Should().BeEmpty(); AppSettingsLoader.Load(_appDirectory).Value.Locale.Should().Be(UiLanguage.En); } @@ -192,6 +196,31 @@ public async Task TheRestartNotice_IsHiddenBeforeASaveAndVisibleAfterOneThatSucc } } + [AvaloniaFact] + public async Task TheRestartNotice_AfterASaveThatAlsoChangedTheTheme_SaysTheThemeIsApplied() + { + using var viewModel = Build(); + var dialog = Realise(viewModel); + try + { + var notice = Named(dialog, "SettingsRestartNotice"); + var otherLanguage = viewModel.Languages.First(choice => choice.Token != viewModel.SelectedLanguage!.Token); + var otherTheme = viewModel.Themes.First(choice => choice.Token != viewModel.SelectedTheme!.Token); + Pick(dialog, Named(dialog, "SettingsLanguage"), otherLanguage); + Pick(dialog, Named(dialog, "SettingsTheme"), otherTheme); + + HeadlessInput.Click(dialog, Named