Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
21 changes: 20 additions & 1 deletion SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveSandbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
}

Expand All @@ -34,6 +36,8 @@ public SettingsSaveSandbox()

public string ConnectionDirectory { get; }

public string OpcDirectory { get; }

public string ConnectionFile => Path.Combine(ConnectionDirectory, ConnectionFileName);

public void Dispose()
Expand Down Expand Up @@ -61,7 +65,13 @@ public static TError SingleError<TError>(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<ConfigurationSectionName, IReadOnlyDictionary<string, string>> edits)
Expand All @@ -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<string, byte[]> Snapshot()
{
return Directory.GetFiles(ConfigDirectory, "*", SearchOption.AllDirectories)
Expand Down
50 changes: 50 additions & 0 deletions SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsViewModel>.Instance,
restartApplication ?? (() => { }));
Expand Down
Loading
Loading