diff --git a/AGENTS.md b/AGENTS.md index f7abe9d..ed8fec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,8 @@ the tracked file (`docs/architecture/overview.md`). The settings window (`SemiPlot.UI/Settings`) is the viewer's only writer of the section folders. It 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 it edits no key of `opc/` yet. The startup-failure window offers it only -when both sections it edits, `app/` and `connection/`, read and carry every key it edits. The +changed; it creates no file. The startup-failure window offers it only when all three sections it edits, +`app/`, `connection/` and `opc/`, 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 except the theme, which every running process applies live (`docs/architecture/overview.md#the-live-theme`). diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Localization/ResourcesTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Localization/ResourcesTests.cs index 28c37ac..fc292c2 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Localization/ResourcesTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Localization/ResourcesTests.cs @@ -79,6 +79,7 @@ public void CompositeFormats_KeepTheirPlaceholder() { Resources.StatusLayerFormat.Should().Contain("{0}"); Resources.SettingsFieldRequired.Should().Contain("{0}"); + Resources.SettingsKeyAbsent.Should().Contain("{0}").And.Contain("{1}"); Resources.ExportMessageStartUnreadable.Should().Contain("{0}"); Resources.ExportMessageEndUnreadable.Should().Contain("{0}"); Resources.ExportPensHeader.Should().Contain("{0}").And.Contain("{1}"); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveSandbox.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveSandbox.cs index 00a5038..61c084f 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveSandbox.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveSandbox.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using SemiPlot.Core.Configuration; +using SemiPlot.DataSource.OpcUa.Configuration; using SemiPlot.DataSource.Postgres.Configuration; using SemiPlot.UI.Settings; using SemiPlot.UI.Startup; @@ -25,6 +26,7 @@ public SettingsSaveSandbox() ConfigDirectory = _temporary.Path; AppDirectory = Path.Combine(ConfigDirectory, StartupSequence.SettingsDirectoryName); ConnectionDirectory = Path.Combine(ConfigDirectory, StartupProbe.ConnectionDirectoryName); + OpcDirectory = Path.Combine(ConfigDirectory, StartupProbe.OpcDirectoryName); ShippedConfiguration.CopyTo(ConfigDirectory, "secret"); } @@ -34,6 +36,8 @@ public SettingsSaveSandbox() public string ConnectionDirectory { get; } + public string OpcDirectory { get; } + public string ConnectionFile => Path.Combine(ConnectionDirectory, ConnectionFileName); public void Dispose() @@ -61,7 +65,13 @@ public static TError SingleError(Result result) public string DirectoryOf(ConfigurationSectionName section) { - return section == ConfigurationSectionName.App ? AppDirectory : ConnectionDirectory; + return section switch + { + ConfigurationSectionName.App => AppDirectory, + ConfigurationSectionName.Connection => ConnectionDirectory, + ConfigurationSectionName.Opc => OpcDirectory, + _ => throw new ArgumentOutOfRangeException(nameof(section), section, null) + }; } public Result Save(IReadOnlyDictionary> edits) @@ -78,6 +88,15 @@ public PostgresConnectionSettings LoadConnection() return loaded.Value; } + public OpcUaConnectionSettings LoadOpc() + { + var loaded = OpcUaConnectionLoader.Load(OpcDirectory); + + loaded.IsSuccess.Should().BeTrue(Describe(loaded)); + + return loaded.Value; + } + public Dictionary Snapshot() { return Directory.GetFiles(ConfigDirectory, "*", SearchOption.AllDirectories) diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs index 6c7982c..807e5ca 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs @@ -7,6 +7,7 @@ using SemiPlot.Core.Configuration; using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa.Configuration; using SemiPlot.DataSource.Postgres.Configuration; using SemiPlot.UI.Messages; using SemiPlot.UI.Settings; @@ -38,6 +39,8 @@ public void Dispose() [InlineData(ConfigurationSectionName.Connection, "port", "70000", ConnectionFileProblem.OutOfRange)] [InlineData(ConfigurationSectionName.Connection, "host", "scada-01", ConnectionFileProblem.HostNotIPv4)] [InlineData(ConfigurationSectionName.Connection, "password", "", ConnectionFileProblem.MissingField)] + [InlineData(ConfigurationSectionName.Opc, "port", "70000", ConnectionFileProblem.OutOfRange)] + [InlineData(ConfigurationSectionName.Opc, "host", "scada-01", ConnectionFileProblem.HostNotIPv4)] public void AnInvalidValueIsRefusedAndNoFileChanges( ConfigurationSectionName section, string key, @@ -58,6 +61,7 @@ public void AnInvalidValueIsRefusedAndNoFileChanges( problem.Should().Be(expectedProblem); (error as AppSettingsError)?.Key.Should().Be(key); (error as ConnectionFileError)?.Fields.Should().Equal(key); + (error as ConnectionFileError)?.Section.Should().Be(section); _sandbox.ShouldMatch(before); } @@ -253,6 +257,7 @@ public void AnUnmodifiedShippedCopyWithOnlyThePasswordFilledPromotes() [Theory] [InlineData(ConfigurationSectionName.App, "locale", "de")] [InlineData(ConfigurationSectionName.Connection, "port", "70000")] + [InlineData(ConfigurationSectionName.Opc, "port", "70000")] public void ARefusedSaveNamesTheOperatorsFolderAndNeverTheStagingOne( ConfigurationSectionName section, string key, @@ -271,8 +276,53 @@ public void ARefusedSaveNamesTheOperatorsFolderAndNeverTheStagingOne( }; path.Should().Be(realDirectory); (error as ConnectionFileError)?.Fields.Should().Equal(key); + (error as ConnectionFileError)?.Section.Should().Be(section); var detail = ArchiveFailureMapper.Map(error).Detail; detail.Should().Contain(realDirectory).And.NotContain(SettingsSave.StagingPrefix); } + [Fact] + public void AnOpcEditReachesTheOpcFileAndLeavesTheOtherSectionsByteIdentical() + { + var before = _sandbox.Snapshot(); + + var result = _sandbox.Save(Edit(ConfigurationSectionName.Opc, ("port", "55875"))); + + result.IsSuccess.Should().BeTrue(Describe(result)); + var opc = _sandbox.LoadOpc(); + opc.Host.Should().Be("127.0.0.1"); + opc.Port.Should().Be(55875); + var after = _sandbox.Snapshot(); + after.Keys.Should().BeEquivalentTo(before.Keys, "the save creates no file"); + var otherSections = before.Keys + .Where(file => Path.GetDirectoryName(file) + is StartupSequence.SettingsDirectoryName or StartupProbe.ConnectionDirectoryName) + .ToList(); + otherSections.Should().HaveCount(2, "the shipped set carries one app file and one connection file"); + + foreach (var file in otherSections) + { + after[file].Should().Equal(before[file], file); + } + + _sandbox.StagingDirectories().Should().BeEmpty(); + } + + [Fact] + public void TheCertificateStoreUnderTheOpcSectionSurvivesASaveUntouched() + { + var certificates = Path.Combine( + _sandbox.OpcDirectory, OpcUaConnectionLoader.CertificateStoreDirectoryName, "own", "certs"); + Directory.CreateDirectory(certificates); + var certificate = Path.Combine(certificates, "semiplot.der"); + byte[] content = [0x30, 0x82, 0x01, 0x0a]; + File.WriteAllBytes(certificate, content); + + var result = _sandbox.Save(Edit(ConfigurationSectionName.Opc, ("port", "55875"))); + + result.IsSuccess.Should().BeTrue(Describe(result)); + _sandbox.LoadOpc().Port.Should().Be(55875); + File.ReadAllBytes(certificate).Should().Equal(content); + _sandbox.StagingDirectories().Should().BeEmpty(); + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelStand.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelStand.cs index 8bb73e6..7742882 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelStand.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelStand.cs @@ -13,12 +13,9 @@ public static SettingsViewModel Build( MessagePanelViewModel messagePanel, Action? restartApplication = null) { - var (app, connection) = SettingsSave.ReadOwned(configDirectory); - return new SettingsViewModel( configDirectory, - app, - connection, + SettingsSave.ReadOwned(configDirectory), messagePanel, NullLogger.Instance, restartApplication ?? (() => { })); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs index 967c605..703a62b 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs @@ -7,6 +7,8 @@ using ReactiveUI; +using SemiPlot.Core.Configuration; +using SemiPlot.DataSource.OpcUa.Configuration; using SemiPlot.DataSource.Postgres.Configuration; using SemiPlot.UI.Localization; using SemiPlot.UI.Messages; @@ -29,12 +31,15 @@ public sealed class SettingsViewModelTests : IDisposable private readonly string _connectionDirectory; + private readonly string _opcDirectory; + private readonly MessagePanelViewModel _messagePanel = new(); public SettingsViewModelTests() { _appDirectory = Path.Combine(_configDirectory, StartupSequence.SettingsDirectoryName); _connectionDirectory = Path.Combine(_configDirectory, StartupProbe.ConnectionDirectoryName); + _opcDirectory = Path.Combine(_configDirectory, StartupProbe.OpcDirectoryName); ShippedConfiguration.CopyTo(_configDirectory); } @@ -52,6 +57,7 @@ public void ADirectoryPopulatesEveryField() _connectionDirectory, "connection.yaml", "host: 10.20.30.40\nport: 5433\ndatabase: archive\nuser: viewer\npassword: secret\n"); + WriteFile(_opcDirectory, "opc.yaml", "host: 10.20.30.50\nport: 55875\n"); using var viewModel = Build(); @@ -62,6 +68,8 @@ public void ADirectoryPopulatesEveryField() viewModel.Database.Should().Be("archive"); viewModel.User.Should().Be("viewer"); viewModel.Password.Should().Be("secret"); + viewModel.OpcHost.Should().Be("10.20.30.50"); + viewModel.OpcPort.Should().Be(55875); _messagePanel.Entries.Should().BeEmpty(); } @@ -77,6 +85,8 @@ public void TheShippedSetPopulatesEveryFieldAndLeavesThePasswordEmpty() viewModel.Database.Should().Be("semiplot"); viewModel.User.Should().Be("semiplot"); viewModel.Password.Should().BeEmpty(); + viewModel.OpcHost.Should().Be("127.0.0.1"); + viewModel.OpcPort.Should().Be(49875); _messagePanel.Entries.Should().BeEmpty(); } @@ -142,28 +152,37 @@ public void ASectionWithADuplicateKeyBlocksTheSaveAndNamesTheSection() Resources.FormatSettingsSectionUnreadable(Resources.SettingsInterfaceHeader)); } - [AvaloniaTheory] - [InlineData(true, false)] - [InlineData(false, true)] - public void AMissingSectionFolderIsReportedAndBlocksTheSaveWithAStatedReason(bool appAbsent, bool connectionAbsent) + [AvaloniaFact] + public void AnUnreadableOpcSectionIsReportedOnceAndBlocksTheSave() { - if (appAbsent) - { - Directory.Delete(_appDirectory, recursive: true); - } + WriteFile(_opcDirectory, "opc.yaml", "host: [unclosed\n"); - if (connectionAbsent) - { - Directory.Delete(_connectionDirectory, recursive: true); - } + using var viewModel = Build(); + viewModel.Password = "secret"; + + _messagePanel.Entries.Should().ContainSingle().Which.View.Detail.Should().Contain(_opcDirectory); + viewModel.OpcHost.Should().BeEmpty(); + viewModel.OpcPort.Should().BeNull(); + viewModel.Host.Should().Be("127.0.0.1"); + CanSave(viewModel).Should().BeFalse(); + viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsSectionUnreadable(Resources.SettingsOpcHeader)); + } + + [AvaloniaTheory] + [InlineData(ConfigurationSectionName.App)] + [InlineData(ConfigurationSectionName.Connection)] + [InlineData(ConfigurationSectionName.Opc)] + public void AMissingSectionFolderIsReportedAndBlocksTheSaveWithAStatedReason(ConfigurationSectionName section) + { + var (directory, _, header) = SectionOf(section); + Directory.Delete(directory, recursive: true); using var viewModel = Build(); viewModel.Password = "secret"; _messagePanel.Entries.Should().ContainSingle(); CanSave(viewModel).Should().BeFalse(); - viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsSectionUnreadable( - appAbsent ? Resources.SettingsInterfaceHeader : Resources.SettingsConnectionHeader)); + viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsSectionUnreadable(header)); } [AvaloniaFact] @@ -176,7 +195,8 @@ public void AnAppFileWithoutALocaleRefusesTheSaveAndNamesTheAbsentKey() viewModel.Password = "secret"; CanSave(viewModel).Should().BeFalse(); - viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsKeyAbsent(AppSettingsLoader.LocaleKey)); + viewModel.ValidationMessage.Should().Be( + Resources.FormatSettingsKeyAbsent(Resources.SettingsInterfaceHeader, AppSettingsLoader.LocaleKey)); } [AvaloniaFact] @@ -191,7 +211,63 @@ public void AConnectionFileWithoutAPortRefusesTheSaveAndNamesTheAbsentKey() viewModel.Port = 5432; CanSave(viewModel).Should().BeFalse(); - viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsKeyAbsent(PostgresConnectionLoader.PortKey)); + viewModel.ValidationMessage.Should().Be( + Resources.FormatSettingsKeyAbsent(Resources.SettingsConnectionHeader, PostgresConnectionLoader.PortKey)); + } + + [AvaloniaFact] + public void AnOpcFileWithoutAHostRefusesTheSaveAndNamesTheSectionAndTheKey() + { + WriteFile(_opcDirectory, "opc.yaml", "port: 49875\n"); + + using var viewModel = Build(); + viewModel.Password = "secret"; + + CanSave(viewModel).Should().BeFalse(); + viewModel.ValidationMessage.Should().Be( + Resources.FormatSettingsKeyAbsent(Resources.SettingsOpcHeader, OpcUaConnectionLoader.HostKey)); + _messagePanel.Entries.Should().BeEmpty(); + } + + [AvaloniaTheory] + [InlineData(ConfigurationSectionName.App, ConfigurationSectionName.Connection)] + [InlineData(ConfigurationSectionName.Connection, ConfigurationSectionName.Opc)] + public void OfTwoUnreadableSectionsTheMessageNamesTheEarlier( + ConfigurationSectionName earlier, ConfigurationSectionName later) + { + var (earlierDirectory, earlierFile, header) = SectionOf(earlier); + var (laterDirectory, laterFile, _) = SectionOf(later); + WriteFile(earlierDirectory, earlierFile, "host: [unclosed\n"); + WriteFile(laterDirectory, laterFile, "host: [unclosed\n"); + + using var viewModel = Build(); + + viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsSectionUnreadable(header)); + } + + [AvaloniaTheory] + [InlineData(ConfigurationSectionName.App, ConfigurationSectionName.Connection)] + [InlineData(ConfigurationSectionName.Connection, ConfigurationSectionName.Opc)] + public void OfTwoSectionsLackingAKeyTheMessageNamesTheEarlier( + ConfigurationSectionName earlier, ConfigurationSectionName later) + { + var (header, key) = RewriteWithoutOneEditedKey(earlier); + RewriteWithoutOneEditedKey(later); + + using var viewModel = Build(); + + viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsKeyAbsent(header, key)); + } + + [AvaloniaFact] + public void AnUnreadableSectionIsNamedBeforeAKeyAnEarlierSectionLacks() + { + RewriteWithoutOneEditedKey(ConfigurationSectionName.Connection); + WriteFile(_opcDirectory, "opc.yaml", "host: [unclosed\n"); + + using var viewModel = Build(); + + viewModel.ValidationMessage.Should().Be(Resources.FormatSettingsSectionUnreadable(Resources.SettingsOpcHeader)); } [AvaloniaFact] @@ -214,6 +290,7 @@ public async Task ChangingOnlyTheTheme_LeavesTheConnectionFileByteIdenticalAndSe { ShippedConfiguration.FillPassword(_configDirectory, "secret"); var before = File.ReadAllBytes(Path.Combine(_connectionDirectory, "connection.yaml")); + var opcBefore = File.ReadAllBytes(Path.Combine(_opcDirectory, "opc.yaml")); using var viewModel = Build(); viewModel.SelectedTheme = viewModel.Themes.Single(choice => choice.Token == "dark"); @@ -223,6 +300,7 @@ public async Task ChangingOnlyTheTheme_LeavesTheConnectionFileByteIdenticalAndSe viewModel.IsRestartPending.Should().BeFalse("every process applies a saved theme live"); AppSettingsLoader.Load(_appDirectory).Value.Theme.Should().Be(AppThemeVariant.Dark); File.ReadAllBytes(Path.Combine(_connectionDirectory, "connection.yaml")).Should().Equal(before); + File.ReadAllBytes(Path.Combine(_opcDirectory, "opc.yaml")).Should().Equal(opcBefore); } [AvaloniaFact] @@ -265,6 +343,23 @@ public async Task AConnectionSaveSetsTheRestartNotice() viewModel.IsRestartPending.Should().BeTrue(); } + [AvaloniaFact] + public async Task AnOpcSaveSetsTheRestartNotice() + { + ShippedConfiguration.FillPassword(_configDirectory, "secret"); + var connectionBefore = File.ReadAllBytes(Path.Combine(_connectionDirectory, "connection.yaml")); + using var viewModel = Build(); + viewModel.OpcPort = 55875; + + await viewModel.SaveCommand.Execute(); + + _messagePanel.Entries.Should().BeEmpty(); + viewModel.IsRestartPending.Should().BeTrue("the OPC UA session reads its address at the start"); + viewModel.RestartNotice.Should().Be(Resources.SettingsRestartNotice); + OpcUaConnectionLoader.Load(_opcDirectory).Value.Port.Should().Be(55875); + File.ReadAllBytes(Path.Combine(_connectionDirectory, "connection.yaml")).Should().Equal(connectionBefore); + } + [AvaloniaFact] public async Task RestartNow_RunsTheHandedRestartOnlyWhileTheNoticeShows() { @@ -307,6 +402,8 @@ public async Task EveryFieldTheWindowEditsReachesTheFileTheLoadersRead() viewModel.Database = "archive"; viewModel.User = "viewer"; viewModel.Password = "secret"; + viewModel.OpcHost = "10.20.30.50"; + viewModel.OpcPort = 55875; await viewModel.SaveCommand.Execute(); @@ -320,6 +417,9 @@ public async Task EveryFieldTheWindowEditsReachesTheFileTheLoadersRead() connection.Database.Should().Be("archive"); connection.Username.Should().Be("viewer"); connection.Password.Should().Be("secret"); + var opc = OpcUaConnectionLoader.Load(_opcDirectory).Value; + opc.Host.Should().Be("10.20.30.50"); + opc.Port.Should().Be(55875); } [AvaloniaFact] @@ -360,6 +460,7 @@ public async Task ASaveSendsOnlyTheChangedKeyOfASection() [InlineData(nameof(SettingsViewModel.Database), nameof(SettingsViewModel.IsDatabaseValid))] [InlineData(nameof(SettingsViewModel.User), nameof(SettingsViewModel.IsUserValid))] [InlineData(nameof(SettingsViewModel.Password), nameof(SettingsViewModel.IsPasswordValid))] + [InlineData(nameof(SettingsViewModel.OpcHost), nameof(SettingsViewModel.IsOpcHostValid))] public void TheSaveCannotExecuteWhileATextFieldIsBlank(string field, string validity) { using var viewModel = Build(); @@ -388,6 +489,24 @@ public void TheSaveCannotExecuteWhileThePortIsNotAWholeNumberInRange(double? por CanSave(viewModel).Should().BeFalse(); } + [AvaloniaTheory] + [InlineData(null)] + [InlineData(0d)] + [InlineData(65536d)] + [InlineData(49875.5)] + public void TheSaveCannotExecuteWhileTheOpcPortIsNotAWholeNumberInRange(double? port) + { + using var viewModel = Build(); + viewModel.Password = "secret"; + CanSave(viewModel).Should().BeTrue(); + + viewModel.OpcPort = (decimal?)port; + + viewModel.IsOpcPortValid.Should().BeFalse(); + viewModel.IsPortValid.Should().BeTrue("the archive port is a field of its own"); + CanSave(viewModel).Should().BeFalse(); + } + [AvaloniaTheory] [InlineData("port: not-a-number")] [InlineData("port: 70000")] @@ -421,6 +540,38 @@ public void AHostNameInTheFileLoadsIntoAnInvalidHostField() _messagePanel.Entries.Should().BeEmpty(); } + [AvaloniaTheory] + [InlineData("port: not-a-number")] + [InlineData("port: 70000")] + public void AnOpcPortTheRuleRejectsLoadsAsAnEmptyField(string port) + { + WriteFile(_opcDirectory, "opc.yaml", $"host: 127.0.0.1\n{port}\n"); + + using var viewModel = Build(); + viewModel.Password = "secret"; + + viewModel.OpcPort.Should().BeNull(); + viewModel.IsOpcPortValid.Should().BeFalse(); + CanSave(viewModel).Should().BeFalse(); + viewModel.OpcHost.Should().Be("127.0.0.1"); + _messagePanel.Entries.Should().BeEmpty(); + } + + [AvaloniaFact] + public void AHostNameInTheOpcFileLoadsIntoAnInvalidOpcHostField() + { + WriteFile(_opcDirectory, "opc.yaml", "host: scada-01\nport: 49875\n"); + + using var viewModel = Build(); + viewModel.Password = "secret"; + + viewModel.OpcHost.Should().Be("scada-01"); + viewModel.IsOpcHostValid.Should().BeFalse(); + CanSave(viewModel).Should().BeFalse(); + viewModel.IsHostValid.Should().BeTrue("the archive host is a field of its own"); + _messagePanel.Entries.Should().BeEmpty(); + } + [AvaloniaTheory] [InlineData(nameof(SettingsViewModel.SelectedLanguage))] [InlineData(nameof(SettingsViewModel.SelectedTheme))] @@ -472,6 +623,12 @@ public void TheValidationMessageNamesTheFirstInvalidFieldInFormOrder() viewModel.ValidationMessage.Should().Be( Resources.FormatSettingsFieldRequired(Resources.SettingsPasswordLabel), "the shipped password is empty"); + viewModel.OpcHost = "scada-01"; + viewModel.OpcPort = null; + + viewModel.ValidationMessage.Should().Be( + Resources.FormatSettingsFieldRequired(Resources.SettingsPasswordLabel), "the OPC UA block follows the archive"); + viewModel.Database = string.Empty; viewModel.Host = "scada-01"; @@ -487,20 +644,31 @@ public void TheValidationMessageNamesTheFirstInvalidFieldInFormOrder() viewModel.Password = "secret"; + viewModel.ValidationMessage.Should().Be(Resources.SettingsOpcHostInvalid); + + viewModel.OpcHost = "127.0.0.1"; + + viewModel.ValidationMessage.Should().Be(Resources.SettingsOpcPortInvalid); + + viewModel.OpcPort = 49875; + viewModel.ValidationMessage.Should().BeEmpty(); } - [AvaloniaFact] - public void TheValidationMessageNotifiesWithTheFieldItReads() + [AvaloniaTheory] + [InlineData(nameof(SettingsViewModel.Port), nameof(Resources.SettingsPortInvalid))] + [InlineData(nameof(SettingsViewModel.OpcPort), nameof(Resources.SettingsOpcPortInvalid))] + public void TheValidationMessageNotifiesWithTheFieldItReads(string field, string message) { using var viewModel = Build(); + viewModel.Password = "secret"; var notified = new List(); viewModel.PropertyChanged += (_, e) => notified.Add(e.PropertyName); - viewModel.Port = null; + typeof(SettingsViewModel).GetProperty(field)!.SetValue(viewModel, null); notified.Should().Contain(nameof(SettingsViewModel.ValidationMessage)); - viewModel.ValidationMessage.Should().Be(Resources.SettingsPortInvalid); + viewModel.ValidationMessage.Should().Be(Resources.ResourceManager.GetString(message, Resources.Culture)); } private SettingsViewModel Build(Action? restartApplication = null) @@ -528,6 +696,35 @@ private static bool LatestOf(ReactiveCommand c } } + private (string Directory, string File, string Header) SectionOf(ConfigurationSectionName section) + { + return section switch + { + ConfigurationSectionName.App => (_appDirectory, "app.yaml", Resources.SettingsInterfaceHeader), + ConfigurationSectionName.Connection => + (_connectionDirectory, "connection.yaml", Resources.SettingsConnectionHeader), + ConfigurationSectionName.Opc => (_opcDirectory, "opc.yaml", Resources.SettingsOpcHeader), + _ => throw new ArgumentOutOfRangeException(nameof(section), section, null) + }; + } + + private (string Header, string Key) RewriteWithoutOneEditedKey(ConfigurationSectionName section) + { + var (directory, file, header) = SectionOf(section); + var (content, key) = section switch + { + ConfigurationSectionName.App => ("theme: light\n", AppSettingsLoader.LocaleKey), + ConfigurationSectionName.Connection => ( + "host: 127.0.0.1\ndatabase: semiplot\nuser: semiplot\npassword: secret\n", + PostgresConnectionLoader.PortKey), + ConfigurationSectionName.Opc => ("port: 49875\n", OpcUaConnectionLoader.HostKey), + _ => throw new ArgumentOutOfRangeException(nameof(section), section, null) + }; + WriteFile(directory, file, content); + + return (header, key); + } + private static void WriteFile(string directory, string name, string content) { File.WriteAllText(Path.Combine(directory, name), content); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs index 2923cd4..65feead 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs @@ -32,8 +32,6 @@ namespace SemiPlot.Tests.Unit.UI.Settings; [Trait("Category", "Unit")] public sealed class SettingsViewTests : IDisposable { - private const string InvalidClass = "invalid"; - private readonly TemporaryDirectory _configDirectory = new("semiplot-settings-view"); private readonly string _appDirectory; @@ -69,6 +67,9 @@ public void TheRealisedDialog_ShowsTheLoadedValues() Named(dialog, "SettingsDatabase").Text.Should().Be("semiplot"); Named(dialog, "SettingsUser").Text.Should().Be("semiplot"); Named(dialog, "SettingsPassword").Text.Should().Be("secret"); + Named(dialog, "SettingsOpcHost").Text.Should().Be("127.0.0.1"); + Named(dialog, "SettingsOpcPort").Value.Should().Be(49875); + Named(dialog, "SettingsOpcPort").Text.Should().Be("49875"); Named(dialog, "SettingsValidationMessage").Text.Should().BeEmpty(); InvalidFields(dialog).Should().BeEmpty(); } @@ -262,6 +263,55 @@ public void TheDialog_KeepsItsSizeAndItsButtonsWhenAFieldTurnsInvalid() } } + [AvaloniaFact] + public void TheDialog_KeepsItsSizeAndItsButtonsWhenAnOpcFieldTurnsInvalid() + { + using var viewModel = Build(); + var dialog = Realise(new SettingsDialog { DataContext = viewModel }); + try + { + var opcHost = Named(dialog, "SettingsOpcHost"); + var save = Named